From bfd6741e44b994c4f3957094306349f5efbf57fb Mon Sep 17 00:00:00 2001 From: newyearnewphil Date: Sun, 9 Nov 2025 15:49:37 +0100 Subject: [PATCH 1/4] make spawnImmunityDuration configurable --- resources/lang/debug.json | 1 + resources/lang/en.json | 1 + src/client/HostLobbyModal.ts | 40 +++++++++++++++++++++++++ src/client/SinglePlayerModal.ts | 1 + src/core/Schemas.ts | 3 ++ src/core/configuration/DefaultConfig.ts | 2 +- src/server/GameManager.ts | 1 + src/server/GameServer.ts | 3 ++ src/server/MapPlaylist.ts | 1 + tests/util/Setup.ts | 1 + 10 files changed, 53 insertions(+), 1 deletion(-) diff --git a/resources/lang/debug.json b/resources/lang/debug.json index 924720ce31..bf7b627718 100644 --- a/resources/lang/debug.json +++ b/resources/lang/debug.json @@ -145,6 +145,7 @@ "options_title": "host_modal.options_title", "bots": "host_modal.bots", "bots_disabled": "host_modal.bots_disabled", + "spawn_immunity_duration": "host_modal.spawn_immunity_duration", "disable_nations": "host_modal.disable_nations", "instant_build": "host_modal.instant_build", "random_spawn": "host_modal.random_spawn", diff --git a/resources/lang/en.json b/resources/lang/en.json index 189d54202a..e77fbab46e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -249,6 +249,7 @@ "options_title": "Options", "bots": "Bots: ", "bots_disabled": "Disabled", + "spawn_immunity_duration": "Spawn immunity (seconds)", "nations": "Nations: ", "disable_nations": "Disable Nations", "max_timer": "Game length (minutes)", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index ccb01843c8..f0ef5059fb 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -23,6 +23,7 @@ import { TeamCountConfig, } from "../core/Schemas"; import { generateID } from "../core/Util"; +import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import "./components/Difficulties"; import "./components/Maps"; @@ -41,6 +42,7 @@ export class HostLobbyModal extends LitElement { @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; @state() private bots: number = 400; + @state() private spawnImmunityDurationSeconds: number = 5; @state() private infiniteGold: boolean = false; @state() private donateGold: boolean = false; @state() private infiniteTroops: boolean = false; @@ -348,6 +350,26 @@ export class HostLobbyModal extends LitElement { + + ${ !( this.gameMode === GameMode.Team && @@ -685,6 +707,23 @@ export class HostLobbyModal extends LitElement { this.putGameConfig(); } + private handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleSpawnImmunityDurationInput(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value, 10); + if (Number.isNaN(value) || value < 0 || value > 300) { + return; + } + this.spawnImmunityDurationSeconds = value; + this.putGameConfig(); + } + private handleRandomSpawnChange(e: Event) { this.randomSpawn = Boolean((e.target as HTMLInputElement).checked); this.putGameConfig(); @@ -774,6 +813,7 @@ export class HostLobbyModal extends LitElement { randomSpawn: this.randomSpawn, gameMode: this.gameMode, disabledUnits: this.disabledUnits, + spawnImmunityDuration: this.spawnImmunityDurationSeconds * 10, playerTeams: this.teamCount, ...(this.gameMode === GameMode.Team && this.teamCount === HumansVsNations diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 9c51bf9050..201fad6f80 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -585,6 +585,7 @@ export class SinglePlayerModal extends LitElement { infiniteTroops: this.infiniteTroops, instantBuild: this.instantBuild, randomSpawn: this.randomSpawn, + spawnImmunityDuration: 5 * 10, disabledUnits: this.disabledUnits .map((u) => Object.values(UnitType).find((ut) => ut === u)) .filter((ut): ut is UnitType => ut !== undefined), diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index acedd062a9..5aced9d97a 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -170,6 +170,9 @@ export const GameConfigSchema = z.object({ randomSpawn: z.boolean(), maxPlayers: z.number().optional(), maxTimerValue: z.number().int().min(1).max(120).optional(), + // startingGold: z.number().int().min(0).max(10*1000*1000), // maybe in steps instead? good way of setting max? default 0? + // incomeMultiplier: z.number().min(0).max(10), + spawnImmunityDuration: z.number().int().min(0).max(3000), // In ticks (10 per second) disabledUnits: z.enum(UnitType).array().optional(), playerTeams: TeamCountConfigSchema.optional(), }); diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 888d4dae5c..331170bc33 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -260,7 +260,7 @@ export class DefaultConfig implements Config { return 30 * 10; // 30 seconds } spawnImmunityDuration(): Tick { - return 5 * 10; + return this._gameConfig.spawnImmunityDuration; } gameConfig(): GameConfig { diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 0cd4420da7..8afce94f99 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -59,6 +59,7 @@ export class GameManager { randomSpawn: false, gameMode: GameMode.FFA, bots: 400, + spawnImmunityDuration: 5 * 10, disabledUnits: [], ...gameConfig, }, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 927b8addc4..587fc40775 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -118,6 +118,9 @@ export class GameServer { if (gameConfig.randomSpawn !== undefined) { this.gameConfig.randomSpawn = gameConfig.randomSpawn; } + if (gameConfig.spawnImmunityDuration !== undefined) { + this.gameConfig.spawnImmunityDuration = gameConfig.spawnImmunityDuration; + } if (gameConfig.gameMode !== undefined) { this.gameConfig.gameMode = gameConfig.gameMode; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 270858abe8..f0065461b5 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -100,6 +100,7 @@ export class MapPlaylist { gameMode: mode, playerTeams, bots: 400, + spawnImmunityDuration: 5 * 10, disabledUnits: [], } satisfies GameConfig; } diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts index b932608957..9cba52891e 100644 --- a/tests/util/Setup.ts +++ b/tests/util/Setup.ts @@ -64,6 +64,7 @@ export async function setup( donateGold: false, donateTroops: false, bots: 0, + spawnImmunityDuration: 0, infiniteGold: false, infiniteTroops: false, instantBuild: false, From be6a1e67d7482d616a433d4aef34711dac7de63e Mon Sep 17 00:00:00 2001 From: newyearnewphil Date: Fri, 14 Nov 2025 18:39:20 +0100 Subject: [PATCH 2/4] expand spawnImmunity: missiles disabled, warships passive, visual indicator --- src/client/HostLobbyModal.ts | 41 +++++----- src/client/graphics/GameRenderer.ts | 10 +++ src/client/graphics/layers/CeasefireTimer.ts | 86 ++++++++++++++++++++ src/client/index.html | 1 + src/core/execution/AttackExecution.ts | 4 +- src/core/execution/TransportShipExecution.ts | 14 ++++ src/core/execution/WarshipExecution.ts | 23 ++++-- src/core/game/PlayerImpl.ts | 20 +++-- src/core/game/TransportShipUtils.ts | 12 ++- 9 files changed, 178 insertions(+), 33 deletions(-) create mode 100644 src/client/graphics/layers/CeasefireTimer.ts diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index f0ef5059fb..4470b34705 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -350,26 +350,6 @@ export class HostLobbyModal extends LitElement { - - ${ !( this.gameMode === GameMode.Team && @@ -544,6 +524,27 @@ export class HostLobbyModal extends LitElement { ${translateText("host_modal.max_timer")} + + +
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index f398dccba7..bac8e4a473 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -8,6 +8,7 @@ import { UIState } from "./UIState"; import { AdTimer } from "./layers/AdTimer"; import { AlertFrame } from "./layers/AlertFrame"; import { BuildMenu } from "./layers/BuildMenu"; +import { CeasefireTimer } from "./layers/CeasefireTimer"; import { ChatDisplay } from "./layers/ChatDisplay"; import { ChatModal } from "./layers/ChatModal"; import { ControlPanel } from "./layers/ControlPanel"; @@ -232,6 +233,14 @@ export function createRenderer( spawnTimer.game = game; spawnTimer.transformHandler = transformHandler; + const ceasefireTimer = document.querySelector( + "ceasefire-timer", + ) as CeasefireTimer; + if (!(ceasefireTimer instanceof CeasefireTimer)) { + console.error("ceasefire timer not found"); + } + ceasefireTimer.game = game; + // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -260,6 +269,7 @@ export function createRenderer( playerPanel, ), spawnTimer, + ceasefireTimer, leaderboard, gameLeftSidebar, unitDisplay, diff --git a/src/client/graphics/layers/CeasefireTimer.ts b/src/client/graphics/layers/CeasefireTimer.ts new file mode 100644 index 0000000000..ced4b3921b --- /dev/null +++ b/src/client/graphics/layers/CeasefireTimer.ts @@ -0,0 +1,86 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +@customElement("ceasefire-timer") +export class CeasefireTimer extends LitElement implements Layer { + public game: GameView; + + private isVisible = false; + private isActive = false; + private progressRatio = 0; + + createRenderRoot() { + this.style.position = "fixed"; + this.style.top = "0"; + this.style.left = "0"; + this.style.width = "100%"; + this.style.height = "7px"; + this.style.zIndex = "1000"; + this.style.pointerEvents = "none"; + return this; + } + + init() { + this.isVisible = true; + } + + tick() { + if (!this.game || !this.isVisible) { + return; + } + + const ceasefireDuration = this.game.config().spawnImmunityDuration(); + const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); + + if (ceasefireDuration <= 0 || this.game.inSpawnPhase()) { + this.setInactive(); + return; + } + + const ceasefireEnd = spawnPhaseTurns + ceasefireDuration; + const ticks = this.game.ticks(); + + if (ticks >= ceasefireEnd || ticks < spawnPhaseTurns) { + this.setInactive(); + return; + } + + const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns); + this.progressRatio = Math.min( + 1, + Math.max(0, elapsedTicks / ceasefireDuration), + ); + this.isActive = true; + this.requestUpdate(); + } + + private setInactive() { + if (this.isActive) { + this.isActive = false; + this.requestUpdate(); + } + } + + shouldTransform(): boolean { + return false; + } + + render() { + if (!this.isVisible || !this.isActive) { + return html``; + } + + const widthPercent = this.progressRatio * 100; + + return html` +
+
+
+ `; + } +} diff --git a/src/client/index.html b/src/client/index.html index caeee16d70..efdf7e71e5 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -404,6 +404,7 @@ + diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 13099b7b6e..856c9f070f 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -91,10 +91,12 @@ export class AttackExecution implements Execution { } if (this.target.isPlayer()) { + const targetPlayer = this.target as Player; if ( + targetPlayer.type() === PlayerType.Human && this.mg.config().numSpawnPhaseTurns() + this.mg.config().spawnImmunityDuration() > - this.mg.ticks() + this.mg.ticks() ) { console.warn("cannot attack player during immunity phase"); this.active = false; diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 913abb8172..4e16e2a466 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -4,6 +4,7 @@ import { MessageType, Player, PlayerID, + PlayerType, TerraNullius, Unit, UnitType, @@ -88,6 +89,19 @@ export class TransportShipExecution implements Execution { this.target = mg.player(this.targetID); } + if ( + this.target.isPlayer() && + this.mg.config().numSpawnPhaseTurns() + + this.mg.config().spawnImmunityDuration() > + this.mg.ticks() + ) { + const targetPlayer = this.target as Player; + if (targetPlayer.type() === PlayerType.Human) { + this.active = false; + return; + } + } + this.startTroops ??= this.mg .config() .boatAttackAmount(this.attacker, this.target); diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 67cb17aef1..0ab5c59fcf 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -65,15 +65,20 @@ export class WarshipExecution implements Execution { this.warship.modifyHealth(1); } - this.warship.setTargetUnit(this.findTargetUnit()); - if (this.warship.targetUnit()?.type() === UnitType.TradeShip) { - this.huntDownTradeShip(); - return; + const spawnImmunityActive = this.isSpawnImmunityActive(); + if (spawnImmunityActive) { + this.warship.setTargetUnit(undefined); + } else { + this.warship.setTargetUnit(this.findTargetUnit()); + if (this.warship.targetUnit()?.type() === UnitType.TradeShip) { + this.huntDownTradeShip(); + return; + } } this.patrol(); - if (this.warship.targetUnit() !== undefined) { + if (!spawnImmunityActive && this.warship.targetUnit() !== undefined) { this.shootTarget(); return; } @@ -283,4 +288,12 @@ export class WarshipExecution implements Execution { } return undefined; } + + private isSpawnImmunityActive(): boolean { + return ( + this.mg.config().numSpawnPhaseTurns() + + this.mg.config().spawnImmunityDuration() > + this.mg.ticks() + ); + } } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index d5e182cac9..4f39f901c3 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -992,6 +992,13 @@ export class PlayerImpl implements Player { } nukeSpawn(tile: TileRef): TileRef | false { + if ( + this.mg.config().numSpawnPhaseTurns() + + this.mg.config().spawnImmunityDuration() > + this.mg.ticks() + ) { + return false; + } const owner = this.mg.owner(tile); if (owner.isPlayer()) { if (this.isOnSameTeam(owner)) { @@ -1177,8 +1184,10 @@ export class PlayerImpl implements Player { } public canAttack(tile: TileRef): boolean { + const owner = this.mg.owner(tile); if ( - this.mg.hasOwner(tile) && + owner.isPlayer() && + owner.type() === PlayerType.Human && this.mg.config().numSpawnPhaseTurns() + this.mg.config().spawnImmunityDuration() > this.mg.ticks() @@ -1186,12 +1195,11 @@ export class PlayerImpl implements Player { return false; } - if (this.mg.owner(tile) === this) { + if (owner === this) { return false; } - const other = this.mg.owner(tile); - if (other.isPlayer()) { - if (this.isFriendly(other)) { + if (owner.isPlayer()) { + if (this.isFriendly(owner)) { return false; } } @@ -1200,7 +1208,7 @@ export class PlayerImpl implements Player { return false; } if (this.mg.hasOwner(tile)) { - return this.sharesBorderWith(other); + return this.sharesBorderWith(owner); } else { for (const t of this.mg.bfs( tile, diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index b457ad94a0..9df5e21262 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -1,6 +1,6 @@ import { PathFindResultType } from "../pathfinding/AStar"; import { MiniAStar } from "../pathfinding/MiniAStar"; -import { Game, Player, UnitType } from "./Game"; +import { Game, Player, PlayerType, UnitType } from "./Game"; import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap"; export function canBuildTransportShip( @@ -8,6 +8,16 @@ export function canBuildTransportShip( player: Player, tile: TileRef, ): TileRef | false { + if ( + game.config().numSpawnPhaseTurns() + game.config().spawnImmunityDuration() > + game.ticks() + ) { + const targetOwner = game.owner(tile); + if (targetOwner.isPlayer() && targetOwner.type() === PlayerType.Human) { + return false; + } + } + if ( player.unitCount(UnitType.TransportShip) >= game.config().boatMaxNumber() ) { From b777bcf01a660df519b12e04d80e7546823c65e7 Mon Sep 17 00:00:00 2001 From: newyearnewphil Date: Fri, 14 Nov 2025 19:30:20 +0100 Subject: [PATCH 3/4] move ceasefire timer bar below spawn timer on team games --- src/client/graphics/layers/CeasefireTimer.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/client/graphics/layers/CeasefireTimer.ts b/src/client/graphics/layers/CeasefireTimer.ts index ced4b3921b..c880a8d7d1 100644 --- a/src/client/graphics/layers/CeasefireTimer.ts +++ b/src/client/graphics/layers/CeasefireTimer.ts @@ -1,5 +1,6 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; +import { GameMode } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { Layer } from "./Layer"; @@ -31,6 +32,12 @@ export class CeasefireTimer extends LitElement implements Layer { return; } + const showTeamOwnershipBar = + this.game.config().gameConfig().gameMode === GameMode.Team && + !this.game.inSpawnPhase(); + + this.style.top = showTeamOwnershipBar ? "7px" : "0px"; + const ceasefireDuration = this.game.config().spawnImmunityDuration(); const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); From 8f999ac7eeb432d44328b18f1dcc28820b92730b Mon Sep 17 00:00:00 2001 From: newyearnewphil Date: Sat, 15 Nov 2025 10:20:57 +0100 Subject: [PATCH 4/4] hide CeasefireTimer if duration <= default --- src/client/graphics/layers/CeasefireTimer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/graphics/layers/CeasefireTimer.ts b/src/client/graphics/layers/CeasefireTimer.ts index c880a8d7d1..0895d9557e 100644 --- a/src/client/graphics/layers/CeasefireTimer.ts +++ b/src/client/graphics/layers/CeasefireTimer.ts @@ -41,7 +41,7 @@ export class CeasefireTimer extends LitElement implements Layer { const ceasefireDuration = this.game.config().spawnImmunityDuration(); const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); - if (ceasefireDuration <= 0 || this.game.inSpawnPhase()) { + if (ceasefireDuration <= 5 * 10 || this.game.inSpawnPhase()) { this.setInactive(); return; }