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 c2e03cedcf..dff0b2accd 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -298,6 +298,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 247ebe441c..64580324b9 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/FluentSlider";
@@ -45,6 +46,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;
@@ -520,6 +522,27 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.max_timer")}
+
+
+
@@ -687,6 +710,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();
@@ -776,6 +816,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 f65c955cfb..ee368d04e8 100644
--- a/src/client/SinglePlayerModal.ts
+++ b/src/client/SinglePlayerModal.ts
@@ -582,6 +582,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/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 152a82587b..a8c722b53e 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -9,6 +9,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";
@@ -229,6 +230,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().
@@ -257,6 +266,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..0895d9557e
--- /dev/null
+++ b/src/client/graphics/layers/CeasefireTimer.ts
@@ -0,0 +1,93 @@
+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";
+
+@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 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();
+
+ if (ceasefireDuration <= 5 * 10 || 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 868c8e1df3..1c3e6824e2 100644
--- a/src/client/index.html
+++ b/src/client/index.html
@@ -464,6 +464,7 @@
+
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 27ceb7c053..5e66359826 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -175,6 +175,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 ec22579a94..c88a349df1 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -241,7 +241,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/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts
index 5997c13202..bb836e5111 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 6e37e066d1..89b8d823a4 100644
--- a/src/core/execution/TransportShipExecution.ts
+++ b/src/core/execution/TransportShipExecution.ts
@@ -5,6 +5,7 @@ import {
MessageType,
Player,
PlayerID,
+ PlayerType,
TerraNullius,
Unit,
UnitType,
@@ -94,6 +95,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 1ddb064cbe..98ace580cb 100644
--- a/src/core/execution/WarshipExecution.ts
+++ b/src/core/execution/WarshipExecution.ts
@@ -61,15 +61,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;
}
@@ -279,4 +284,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 4ac72fd742..2ba24efefb 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -1009,6 +1009,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)) {
@@ -1200,8 +1207,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()
@@ -1209,12 +1218,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;
}
}
@@ -1223,7 +1231,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 a0af53526a..c386ee19a9 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()
) {
diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts
index 186212f420..171950f608 100644
--- a/src/server/GameManager.ts
+++ b/src/server/GameManager.ts
@@ -73,6 +73,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 428275f3c0..7f2121db05 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -119,6 +119,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 22c258f59f..cda20ddb94 100644
--- a/src/server/MapPlaylist.ts
+++ b/src/server/MapPlaylist.ts
@@ -105,6 +105,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 e9b2722ee6..f803f1823d 100644
--- a/tests/util/Setup.ts
+++ b/tests/util/Setup.ts
@@ -65,6 +65,7 @@ export async function setup(
donateGold: false,
donateTroops: false,
bots: 0,
+ spawnImmunityDuration: 0,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,