This commit is contained in:
icslucas
2025-10-25 19:37:40 +02:00
parent 565b1345ad
commit b10cadff20
13 changed files with 405 additions and 22 deletions
+1 -1
View File
@@ -5,4 +5,4 @@
export PATH="/usr/local/bin:$HOME/.npm-global/bin:$HOME/.nvm/versions/node/$(node -v)/bin:$PATH"
# Then run lint-staged if tests pass
npx lint-staged
cmd lint-staged
+12
View File
@@ -47,6 +47,7 @@ import {
} from "./Transport";
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPositionEvent } from "./graphics/layers/Leaderboard";
import SoundManager from "./sound/SoundManager";
export interface LobbyConfig {
@@ -194,6 +195,7 @@ async function createClientGame(
export class ClientGameRunner {
private myPlayer: PlayerView | null = null;
private isActive = false;
private hasZoomedToSpawn = false;
private turnsSeen = 0;
private hasJoined = false;
@@ -292,6 +294,16 @@ export class ClientGameRunner {
this.gameView.update(gu);
this.renderer.tick();
const myPlayer = this.gameView.myPlayer();
if (!this.hasZoomedToSpawn && myPlayer && myPlayer.numTilesOwned() > 0) {
const initialSpawnTile = myPlayer.initialSpawnTile();
if (initialSpawnTile) {
const cell = this.gameView.cell(initialSpawnTile);
this.eventBus.emit(new GoToPositionEvent(cell.x, cell.y));
this.hasZoomedToSpawn = true;
}
}
if (gu.updates[GameUpdateType.Win].length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
}
+7
View File
@@ -53,6 +53,13 @@ export class HostLobbyModal extends LitElement {
@state() private clients: ClientInfo[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [];
<<<<<<< Updated upstream
=======
private readonly nukeWarsDisabledUnits = [
UnitType.MIRV,
];
>>>>>>> Stashed changes
@state() private lobbyCreatorClientID: string = "";
@state() private lobbyIdVisible: boolean = true;
+7
View File
@@ -49,6 +49,13 @@ export class SinglePlayerModal extends LitElement {
@state() private disabledUnits: UnitType[] = [];
<<<<<<< Updated upstream
=======
private readonly nukeWarsDisabledUnits = [
UnitType.MIRV,
];
>>>>>>> Stashed changes
private userSettings: UserSettings = new UserSettings();
connectedCallback() {
+2 -1
View File
@@ -17,6 +17,7 @@ import {
PlayerInfo,
PlayerProfile,
PlayerType,
UnitType,
} from "./game/Game";
import { createGame } from "./game/GameImpl";
import { TileRef } from "./game/GameMap";
@@ -186,7 +187,7 @@ export class GameRunner {
const tile =
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
const actions = {
canAttack: tile !== null && player.canAttack(tile),
canAttack: tile !== null && player.canAttack(tile, UnitType.City),
buildableUnits: player.buildableUnits(tile),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
} as PlayerActions;
+16
View File
@@ -323,6 +323,12 @@ export class DefaultConfig implements Config {
}
isUnitDisabled(unitType: UnitType): boolean {
<<<<<<< Updated upstream
=======
if (this._gameConfig.gameMode === GameMode.NukeWars) {
return unitType === UnitType.MIRV;
}
>>>>>>> Stashed changes
return this._gameConfig.disabledUnits?.includes(unitType) ?? false;
}
@@ -616,6 +622,16 @@ export class DefaultConfig implements Config {
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
}
<<<<<<< Updated upstream
=======
numPreparationPhaseTurns(): number {
if (this._gameConfig.gameMode === GameMode.NukeWars) {
return 180 * 10; // 180 seconds * 10 ticks/sec
}
return 0;
}
>>>>>>> Stashed changes
numBots(): number {
return this.bots();
}
+95
View File
@@ -37,6 +37,13 @@ export class SpawnExecution implements Execution {
player = this.mg.addPlayer(this.playerInfo);
}
<<<<<<< Updated upstream
=======
const spawnTile = this.isNukeWarsAndBaikal(player)
? this.findBestNukeWarsSpawn(player)
: this.tile;
>>>>>>> Stashed changes
player.tiles().forEach((t) => player.relinquish(t));
getSpawnTiles(this.mg, this.tile).forEach((t) => {
player.conquer(t);
@@ -58,4 +65,92 @@ export class SpawnExecution implements Execution {
activeDuringSpawnPhase(): boolean {
return true;
}
private isNukeWarsAndBaikal(player: Player): boolean {
const gc = this.mg.config().gameConfig();
return (
gc.gameMode === GameMode.NukeWars && gc.gameMap === GameMapType.Baikal
);
}
private findBestNukeWarsSpawn(player: Player): TileRef {
const mapWidth = this.mg.width();
const midpoint = Math.floor(mapWidth / 2);
const wantLeft = player.smallID() % 2 === 1;
let bestTile: TileRef | null = null;
let bestScore = Infinity;
this.mg.forEachTile((t) => {
const xt = this.mg.x(t);
const onCorrectHalf = wantLeft ? xt < midpoint : xt >= midpoint;
if (onCorrectHalf && !this.mg.hasOwner(t) && this.mg.isLand(t)) {
const distToOriginal = this.mg.manhattanDist(this.tile, t);
const distToMidpoint = Math.abs(xt - midpoint);
const distToTeam = this.minDistToTeam(player, t);
// Score combines distance from original tile, distance from midpoint, and distance from team members.
// We want to be close to the original spawn, but also spread out from teammates.
const score =
distToOriginal +
distToMidpoint * -0.5 + // Bias towards the center
(isFinite(distToTeam) ? -distToTeam * 0.9 : 0) + // Bias away from teammates
this.bandScore(player, t); // Bias towards a vertical band to spread out spawns
if (score < bestScore) {
bestScore = score;
bestTile = t;
}
}
});
return bestTile ?? this.tile;
}
private minDistToTeam(player: Player, tile: TileRef): number {
let minDist = Infinity;
const team = player.team();
if (!team) {
return minDist;
}
for (const p of this.mg.players()) {
if (p.team() !== team || p === player) {
continue;
}
for (const owned of p.tiles()) {
const d = this.mg.manhattanDist(owned, tile);
if (d < minDist) {
minDist = d;
}
if (minDist === 0) {
return 0;
}
}
}
return minDist;
}
private bandScore(player: Player, tile: TileRef): number {
const team = player.team();
if (!team) {
return 0;
}
const teamPlayers = this.mg
.players()
.filter((pp) => pp.team() === team)
.sort((a, b) => a.smallID() - b.smallID());
const teamIndex = teamPlayers.findIndex((pp) => pp === player);
const teamCount = Math.max(1, teamPlayers.length);
const numBands = Math.max(1, Math.round(Math.sqrt(teamCount)));
const desiredBand = Math.floor((teamIndex / teamCount) * numBands);
const y = this.mg.y(tile);
const bandIndex = Math.floor((y / this.mg.height()) * numBands);
const bandPenalty = 24; // tunes vertical spread strength
return Math.abs(bandIndex - desiredBand) * bandPenalty;
}
}
+65 -8
View File
@@ -31,7 +31,11 @@ export class WinCheckExecution implements Execution {
if (this.mg.config().gameConfig().gameMode === GameMode.FFA) {
this.checkWinnerFFA();
<<<<<<< Updated upstream
} else {
=======
} else if (gameMode === GameMode.NukeWars || gameMode === GameMode.Team) {
>>>>>>> Stashed changes
this.checkWinnerTeam();
}
}
@@ -63,32 +67,76 @@ export class WinCheckExecution implements Execution {
checkWinnerTeam(): void {
if (this.mg === null) throw new Error("Not initialized");
const teamToTiles = new Map<Team, number>();
for (const player of this.mg.players()) {
const team = player.team();
// Sanity check, team should not be null here
if (team === null) continue;
teamToTiles.set(
team,
(teamToTiles.get(team) ?? 0) + player.numTilesOwned(),
);
}
const sorted = Array.from(teamToTiles.entries()).sort(
(a, b) => b[1] - a[1],
);
const sorted = Array.from(teamToTiles.entries()).sort((a, b) => b[1] - a[1]);
if (sorted.length === 0) {
return;
}
const gameMode = this.mg.config().gameConfig().gameMode;
if (gameMode === GameMode.NukeWars) {
this.checkNukeWarsWinCondition(sorted);
} else {
this.checkTeamWinCondition(sorted);
}
}
<<<<<<< Updated upstream
=======
private checkNukeWarsWinCondition(sorted: [Team, number][]): void {
if (this.mg === null) throw new Error("Not initialized");
const numTilesWithoutFallout =
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
for (const [team, tiles] of sorted) {
const percentage = (tiles / numTilesWithoutFallout) * 100;
if (percentage < 5 && team !== ColoredTeams.Bot) {
const otherTeam = sorted.find(
([t, _]) => t !== team && t !== ColoredTeams.Bot,
);
if (otherTeam) {
this.mg.setWinner(otherTeam[0], this.mg.stats().stats());
console.log(
`${otherTeam[0]} has won the game by reducing ${team} territory below 5%`,
);
this.active = false;
return;
}
}
}
if (this.isTimeElapsed()) {
const winner = sorted.find(([t, _]) => t !== ColoredTeams.Bot);
if (winner) {
this.mg.setWinner(winner[0], this.mg.stats().stats());
console.log(
`${winner[0]} has won the game by having most territory when time elapsed`,
);
this.active = false;
}
}
}
private checkTeamWinCondition(sorted: [Team, number][]): void {
if (this.mg === null) throw new Error("Not initialized");
const max = sorted[0];
const timeElapsed =
(this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10;
const numTilesWithoutFallout =
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
const percentage = (max[1] / numTilesWithoutFallout) * 100;
if (
percentage > this.mg.config().percentageTilesOwnedToWin() ||
(this.mg.config().gameConfig().maxTimerValue !== undefined &&
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0)
this.isTimeElapsed()
) {
if (max[0] === ColoredTeams.Bot) return;
this.mg.setWinner(max[0], this.mg.stats().stats());
@@ -97,6 +145,15 @@ export class WinCheckExecution implements Execution {
}
}
private isTimeElapsed(): boolean {
if (this.mg === null) throw new Error("Not initialized");
const timeElapsed =
(this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10;
const maxTimerValue = this.mg.config().gameConfig().maxTimerValue;
return maxTimerValue !== undefined && timeElapsed >= maxTimerValue * 60;
}
>>>>>>> Stashed changes
isActive(): boolean {
return this.active;
}
+1 -1
View File
@@ -635,7 +635,7 @@ export interface Player {
canTrade(other: Player): boolean;
// Attacking.
canAttack(tile: TileRef): boolean;
canAttack(tile: TileRef, unitType: UnitType): boolean;
createAttack(
target: Player | TerraNullius,
+29 -1
View File
@@ -96,15 +96,30 @@ export class GameImpl implements Game {
this._width = _map.width();
this._height = _map.height();
this.unitGrid = new UnitGrid(this._map);
<<<<<<< Updated upstream
if (_config.gameConfig().gameMode === GameMode.Team) {
=======
if (this.isTeamBasedGame()) {
>>>>>>> Stashed changes
this.populateTeams();
}
this.addPlayers();
}
private isTeamBasedGame(): boolean {
const gameMode = this._config.gameConfig().gameMode;
return gameMode === GameMode.Team || gameMode === GameMode.NukeWars;
}
private populateTeams() {
let numPlayerTeams = this._config.playerTeams();
<<<<<<< Updated upstream
=======
if (this._config.gameConfig().gameMode === GameMode.NukeWars) {
numPlayerTeams = 2;
}
>>>>>>> Stashed changes
if (typeof numPlayerTeams !== "number") {
const players = this._humans.length + this._nations.length;
switch (numPlayerTeams) {
@@ -323,6 +338,15 @@ export class GameImpl implements Game {
return this._ticks <= this.config().numSpawnPhaseTurns();
}
<<<<<<< Updated upstream
=======
inPreparationPhase(): boolean {
const spawn = this.config().numSpawnPhaseTurns();
const prep = this.config().numPreparationPhaseTurns();
return this._ticks > spawn && this._ticks <= spawn + prep;
}
>>>>>>> Stashed changes
ticks(): number {
return this._ticks;
}
@@ -340,7 +364,7 @@ export class GameImpl implements Game {
const inited: Execution[] = [];
const unInited: Execution[] = [];
this.unInitExecs.forEach((e) => {
if (!this.inSpawnPhase() || e.activeDuringSpawnPhase()) {
if (!this.inSpawnPhase() || (e.activeDuringSpawnPhase && e.activeDuringSpawnPhase())) {
e.init(this, this._ticks);
inited.push(e);
} else {
@@ -665,7 +689,11 @@ export class GameImpl implements Game {
}
teams(): Team[] {
<<<<<<< Updated upstream
if (this._config.gameConfig().gameMode !== GameMode.Team) {
=======
if (!this.isTeamBasedGame()) {
>>>>>>> Stashed changes
return [];
}
return [this.botTeam, ...this.playerTeams];
+1
View File
@@ -171,6 +171,7 @@ export interface PlayerUpdate {
hasSpawned: boolean;
betrayals?: bigint;
lastDeleteUnitTick: Tick;
initialSpawnTile?: TileRef;
}
export interface AllianceView {
+22 -9
View File
@@ -181,6 +181,7 @@ export class UnitView {
export class PlayerView {
public anonymousName: string | null = null;
private decoder?: PatternDecoder;
private _initialSpawnTile: TileRef | null = null;
private _territoryColor: Colord;
private _borderColor: Colord;
@@ -430,6 +431,14 @@ export class PlayerView {
return this.data.isDisconnected;
}
initialSpawnTile(): TileRef | null {
return this._initialSpawnTile;
}
setInitialSpawnTile(tile: TileRef) {
this._initialSpawnTile = tile;
}
lastDeleteUnitTick(): Tick {
return this.data.lastDeleteUnitTick;
}
@@ -513,17 +522,21 @@ export class GameView implements GameMap {
player.data = pu;
player.nameData = gu.playerNameViewData[pu.id];
} else {
const newPlayerView = new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
);
if (pu.initialSpawnTile !== undefined) {
newPlayerView.setInitialSpawnTile(pu.initialSpawnTile);
}
this._players.set(
pu.id,
new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
),
newPlayerView,
);
}
});
+147 -1
View File
@@ -66,6 +66,7 @@ class Donation {
export class PlayerImpl implements Player {
public _lastTileChange: number = 0;
public _pseudo_random: PseudoRandom;
public _initialSpawnTile: TileRef | null = null;
private _gold: bigint;
private _troops: bigint;
@@ -175,6 +176,7 @@ export class PlayerImpl implements Player {
hasSpawned: this.hasSpawned(),
betrayals: stats?.betrayals,
lastDeleteUnitTick: this.lastDeleteUnitTick,
initialSpawnTile: this._initialSpawnTile ?? undefined,
};
}
@@ -307,6 +309,9 @@ export class PlayerImpl implements Player {
this._troops = toInt(troops);
}
conquer(tile: TileRef) {
if (this._initialSpawnTile === null && this._tiles.size === 0) {
this._initialSpawnTile = tile;
}
this.mg.conquer(this, tile);
}
orderRetreat(id: string) {
@@ -911,6 +916,79 @@ export class PlayerImpl implements Player {
});
}
<<<<<<< Updated upstream
=======
private isInTeamSpawnZone(tile: TileRef): boolean {
const gameMode = this.mg.config().gameConfig().gameMode;
if (gameMode !== GameMode.NukeWars) {
return true;
}
const team = this.team();
if (!team) return false;
// Simple geometric split:
// Team 1 (first team) gets left half (x < width/2)
// Team 2 (second team) gets right half (x >= width/2)
const x = this.mg.x(tile);
const mapWidth = this.mg.width();
const midpoint = Math.floor(mapWidth / 2);
// Team 1 gets left half, Team 2 gets right half
const isTeam1 = team === this.mg.teams()[0];
return isTeam1 ? x < midpoint : x >= midpoint;
}
private isNukeWars(): boolean {
return this.mg.config().gameConfig().gameMode === GameMode.NukeWars;
}
private isNukeWarsAndBaikal(): boolean {
const gc = this.mg.config().gameConfig();
return (
gc.gameMode === GameMode.NukeWars && gc.gameMap === GameMapType.Baikal
);
}
private canBuildShipNukeWars(
unitType: UnitType,
targetTile: TileRef,
): boolean {
// Transport ships cannot enter enemy team territory
if (unitType === UnitType.TransportShip) {
const targetOwner = this.mg.owner(targetTile);
if (
targetOwner.isPlayer() &&
!this.isOnSameTeam(targetOwner as Player)
) {
this.mg.displayMessage(
"Transport ships cannot enter enemy team territory in Nuke Wars",
MessageType.ATTACK_FAILED,
this.id(),
);
return false;
}
}
// Warships and TradeShips are allowed to go over to the enemy's spawn
return true;
}
private canBuildNukeNukeWars(unitType: UnitType): boolean {
if (
(unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb) &&
this.mg.inPreparationPhase()
) {
this.mg.displayMessage(
"Nuclear weapons cannot be launched during the preparation phase",
MessageType.ATTACK_FAILED,
this.id(),
);
return false;
}
return true;
}
>>>>>>> Stashed changes
canBuild(
unitType: UnitType,
targetTile: TileRef,
@@ -920,10 +998,31 @@ export class PlayerImpl implements Player {
return false;
}
<<<<<<< Updated upstream
=======
if (this.isNukeWarsAndBaikal()) {
if (!this.canBuildShipNukeWars(unitType, targetTile)) {
return false;
}
if (
this.mg.inPreparationPhase() &&
!this.isInTeamSpawnZone(targetTile)
) {
this.mg.displayMessage(
"During preparation phase, you can only build in your own territory",
MessageType.ATTACK_FAILED,
this.id(),
);
return false;
}
}
>>>>>>> Stashed changes
const cost = this.mg.unitInfo(unitType).cost(this);
if (!this.isAlive() || this.gold() < cost) {
return false;
}
switch (unitType) {
case UnitType.MIRV:
if (!this.mg.hasOwner(targetTile)) {
@@ -932,6 +1031,12 @@ export class PlayerImpl implements Player {
return this.nukeSpawn(targetTile);
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
<<<<<<< Updated upstream
=======
if (this.isNukeWars() && !this.canBuildNukeNukeWars(unitType)) {
return false;
}
>>>>>>> Stashed changes
return this.nukeSpawn(targetTile);
case UnitType.MIRVWarhead:
return targetTile;
@@ -1033,7 +1138,16 @@ export class PlayerImpl implements Player {
}
private validStructureSpawnTiles(tile: TileRef): TileRef[] {
<<<<<<< Updated upstream
if (this.mg.owner(tile) !== this) {
=======
const owner = this.mg.owner(tile);
if (this.isNukeWars() && this.mg.inPreparationPhase()) {
if (!owner.isPlayer() || !this.isOnSameTeam(owner as Player)) {
return [];
}
} else if (owner !== this) {
>>>>>>> Stashed changes
return [];
}
const searchRadius = 15;
@@ -1145,7 +1259,7 @@ export class PlayerImpl implements Player {
return this._incomingAttacks;
}
public canAttack(tile: TileRef): boolean {
public canAttack(tile: TileRef, unitType: UnitType): boolean {
if (
this.mg.hasOwner(tile) &&
this.mg.config().numSpawnPhaseTurns() +
@@ -1168,6 +1282,38 @@ export class PlayerImpl implements Player {
if (!this.mg.isLand(tile)) {
return false;
}
<<<<<<< Updated upstream
=======
// Nuke Wars specific attack rules
if (this.isNukeWarsAndBaikal()) {
const mapWidth = this.mg.width();
const tx = this.mg.x(tile);
const attackerLeft = this.smallID() % 2 === 1;
const tileLeft = tx < Math.floor(mapWidth / 2);
// During spawn phase, only attack within own half
if (this.mg.inSpawnPhase()) {
if (attackerLeft !== tileLeft) {
return false;
}
} else {
// After spawn phase, only nuclear missiles, warships, and tradeships can cross the midpoint
const canCross =
unitType === UnitType.AtomBomb ||
unitType === UnitType.HydrogenBomb ||
unitType === UnitType.MIRV ||
unitType === UnitType.MIRVWarhead ||
unitType === UnitType.Warship ||
unitType === UnitType.TradeShip;
if (attackerLeft !== tileLeft && !canCross) {
return false;
}
}
}
>>>>>>> Stashed changes
if (this.mg.hasOwner(tile)) {
return this.sharesBorderWith(other);
} else {