diff --git a/.husky/pre-commit b/.husky/pre-commit index a282f31f5..99aa617f5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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 diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index fbb1b83aa..e1e3fa02e 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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]); } diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index a7a743ff1..50c8dd936 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -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; diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 446dde847..f1cf8f973 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -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() { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 3a309359f..7946477d7 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -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; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 0aece2a9c..1d2679cf6 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -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(); } diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts index 57baff6ee..409eeea86 100644 --- a/src/core/execution/SpawnExecution.ts +++ b/src/core/execution/SpawnExecution.ts @@ -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; + } } diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index be9793cb8..66646d3b1 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -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(); 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; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 822155c8a..21aa78e91 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index b4712954a..2ab9e10de 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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]; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 922212923..22780e132 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -171,6 +171,7 @@ export interface PlayerUpdate { hasSpawned: boolean; betrayals?: bigint; lastDeleteUnitTick: Tick; + initialSpawnTile?: TileRef; } export interface AllianceView { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index ccceacef9..13f5a1516 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -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, ); } }); diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 8118a1dec..cd79bc850 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -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 {