Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions resources/lang/debug.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,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)",
Expand Down
41 changes: 41 additions & 0 deletions src/client/HostLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/LobbyTeamView";
Expand All @@ -42,6 +43,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;
Expand Down Expand Up @@ -525,6 +527,27 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.max_timer")}
</div>
</label>

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this have a checkbox, like game timer?

also i think minutes is a better unit than seconds

Copy link
Contributor Author

@NewYearNewPhil NewYearNewPhil Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the issue with that is that it would be checked by default, and might be confusing to users a little bit because the 5 sec spawn immunity was not exposed in any UI previously

in the follow up PR for the other options, the card in the host modal is changed to have a slider
image
if the slider is kept, a max value or individiual step sizes are required. or i revert to number input and go for minutes, your call

<label
for="spawn-immunity-duration"
class="option-card"
>
<input
type="number"
id="spawn-immunity-duration"
min="0"
max="300"
step="1"
.value=${String(this.spawnImmunityDurationSeconds)}
style="width: 60px; color: black; text-align: right; border-radius: 8px;"
@input=${this.handleSpawnImmunityDurationInput}
@keydown=${this.handleSpawnImmunityDurationKeyDown}
/>
<div class="option-card-title">
<span>${translateText("host_modal.spawn_immunity_duration")}</span>
</div>
</label>

<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />

<!-- Individual disables for structures/weapons -->
Expand Down Expand Up @@ -686,6 +709,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();
Expand Down Expand Up @@ -775,6 +815,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
Expand Down
1 change: 1 addition & 0 deletions src/client/SinglePlayerModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -233,6 +234,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().
Expand Down Expand Up @@ -261,6 +270,7 @@ export function createRenderer(
playerPanel,
),
spawnTimer,
ceasefireTimer,
leaderboard,
gameLeftSidebar,
unitDisplay,
Expand Down
93 changes: 93 additions & 0 deletions src/client/graphics/layers/CeasefireTimer.ts
Original file line number Diff line number Diff line change
@@ -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`
<div class="w-full h-full flex z-[999]">
<div
class="h-full transition-all duration-100 ease-in-out"
style="width: ${widthPercent}%; background-color: rgba(255, 165, 0, 0.9);"
></div>
</div>
`;
}
}
1 change: 1 addition & 0 deletions src/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@
<settings-modal></settings-modal>
<player-panel></player-panel>
<spawn-timer></spawn-timer>
<ceasefire-timer></ceasefire-timer>
<help-modal></help-modal>
<dark-mode-button></dark-mode-button>
<stats-button></stats-button>
Expand Down
3 changes: 3 additions & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment on lines +173 to +174
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove these comments

spawnImmunityDuration: z.number().int().min(0).max(3000), // In ticks (10 per second)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make optional, do we need a max?

disabledUnits: z.enum(UnitType).array().optional(),
playerTeams: TeamCountConfigSchema.optional(),
});
Expand Down
2 changes: 1 addition & 1 deletion src/core/configuration/DefaultConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export class DefaultConfig implements Config {
return 30 * 10; // 30 seconds
}
spawnImmunityDuration(): Tick {
return 5 * 10;
return this._gameConfig.spawnImmunityDuration;
}

gameConfig(): GameConfig {
Expand Down
4 changes: 3 additions & 1 deletion src/core/execution/AttackExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines 93 to +99
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead i think we should do:

if(!this.player.canAttack(targetPlayer) {
...
this.active = false
return
}

) {
console.warn("cannot attack player during immunity phase");
this.active = false;
Expand Down
14 changes: 14 additions & 0 deletions src/core/execution/TransportShipExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MessageType,
Player,
PlayerID,
PlayerType,
TerraNullius,
Unit,
UnitType,
Expand Down Expand Up @@ -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;
}
}
Comment on lines +98 to +109
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, use canAttack. so we keep all the logic in one place


this.startTroops ??= this.mg
.config()
.boatAttackAmount(this.attacker, this.target);
Expand Down
23 changes: 18 additions & 5 deletions src/core/execution/WarshipExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +64 to +72
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, we should be using canAttack

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the current implementation, warships are completely passive, even in regards to warships / transports from nations, but attacking nations is possible. if we move the check into canAttack for warships, that changes. fine for me if that's what we want, pls confirm

}

this.patrol();

if (this.warship.targetUnit() !== undefined) {
if (!spawnImmunityActive && this.warship.targetUnit() !== undefined) {
this.shootTarget();
return;
}
Expand Down Expand Up @@ -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()
);
}
Comment on lines +288 to +294
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be moved to Game i think.

}
20 changes: 14 additions & 6 deletions src/core/game/PlayerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,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)) {
Expand Down Expand Up @@ -1189,21 +1196,22 @@ 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()
) {
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;
}
}
Expand All @@ -1212,7 +1220,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,
Expand Down
Loading
Loading