Configurable immunity timer (#2763)

## Description:

Resolve discussions about stalled PR
https://github.com/openfrontio/OpenFrontIO/pull/2460

<img width="724" height="348" alt="image"
src="https://github.com/user-attachments/assets/c2c9fa79-cace-431a-9ca4-b3656612fa9d"
/>

Changes:
- Added a `Player::canAttackPlayer(other)` function to determine whether
a player can be attacked.
- This function is now used in most places where a fight can occur:
    - AttackExecution (land attacks)
    - Naval invasion
    - Warship fight
- Nukes can't be thrown during the truce
- Immunity only affect human players. Nations and bot will fight as
usual, and can be fought against.
- The immunity timer uses minutes in the modal window.

UI:

- The immunity phase is displayed with a timer bar at the top. This is
from the original PR, to be discussed if it's not deemed visible enough:

<img width="632" height="215" alt="image"
src="https://github.com/user-attachments/assets/f5ab9aa0-bd4f-4503-b8d6-b40b121fba65"
/>


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

IngloriousTom

---------

Co-authored-by: newyearnewphil <git@nynp.dev>
This commit is contained in:
DevelopingTom
2026-01-04 05:04:48 +01:00
committed by GitHub
parent ab5b044362
commit af0b8a8d50
19 changed files with 385 additions and 33 deletions
+1
View File
@@ -451,6 +451,7 @@
<settings-modal></settings-modal> <settings-modal></settings-modal>
<player-panel></player-panel> <player-panel></player-panel>
<spawn-timer></spawn-timer> <spawn-timer></spawn-timer>
<immunity-timer></immunity-timer>
<help-modal></help-modal> <help-modal></help-modal>
<game-info-modal></game-info-modal> <game-info-modal></game-info-modal>
<dark-mode-button></dark-mode-button> <dark-mode-button></dark-mode-button>
+1
View File
@@ -145,6 +145,7 @@
"options_title": "host_modal.options_title", "options_title": "host_modal.options_title",
"bots": "host_modal.bots", "bots": "host_modal.bots",
"bots_disabled": "host_modal.bots_disabled", "bots_disabled": "host_modal.bots_disabled",
"player_immunity_duration": "host_modal.player_immunity_duration",
"disable_nations": "host_modal.disable_nations", "disable_nations": "host_modal.disable_nations",
"instant_build": "host_modal.instant_build", "instant_build": "host_modal.instant_build",
"random_spawn": "host_modal.random_spawn", "random_spawn": "host_modal.random_spawn",
+1
View File
@@ -307,6 +307,7 @@
"options_title": "Options", "options_title": "Options",
"bots": "Bots: ", "bots": "Bots: ",
"bots_disabled": "Disabled", "bots_disabled": "Disabled",
"player_immunity_duration": "PVP immunity duration (minutes)",
"nations": "Nations: ", "nations": "Nations: ",
"disable_nations": "Disable Nations", "disable_nations": "Disable Nations",
"max_timer": "Game length (minutes)", "max_timer": "Game length (minutes)",
+67 -1
View File
@@ -45,6 +45,8 @@ export class HostLobbyModal extends LitElement {
@state() private gameMode: GameMode = GameMode.FFA; @state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2; @state() private teamCount: TeamCountConfig = 2;
@state() private bots: number = 400; @state() private bots: number = 400;
@state() private spawnImmunity: boolean = false;
@state() private spawnImmunityDurationMinutes: number | undefined = undefined;
@state() private infiniteGold: boolean = false; @state() private infiniteGold: boolean = false;
@state() private donateGold: boolean = false; @state() private donateGold: boolean = false;
@state() private infiniteTroops: boolean = false; @state() private infiniteTroops: boolean = false;
@@ -514,7 +516,7 @@ export class HostLobbyModal extends LitElement {
id="end-timer-value" id="end-timer-value"
min="0" min="0"
max="120" max="120"
.value=${String(this.maxTimerValue ?? "")} .value=${String(this.maxTimerValue ?? 0)}
style="width: 60px; color: black; text-align: right; border-radius: 8px;" style="width: 60px; color: black; text-align: right; border-radius: 8px;"
@input=${this.handleMaxTimerValueChanges} @input=${this.handleMaxTimerValueChanges}
@keydown=${this.handleMaxTimerValueKeyDown} @keydown=${this.handleMaxTimerValueKeyDown}
@@ -524,6 +526,47 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.max_timer")} ${translateText("host_modal.max_timer")}
</div> </div>
</label> </label>
<label
for="spawn-immunity"
class="option-card ${this.spawnImmunity ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="spawn-immunity"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
if (!checked) {
this.spawnImmunityDurationMinutes = undefined;
}
this.spawnImmunity = checked;
this.putGameConfig();
}}
.checked=${this.spawnImmunity}
/>
${
this.spawnImmunity === false
? ""
: html`<input
type="number"
id="spawn-immunity-duration"
min="0"
max="120"
step="1"
.value=${String(
this.spawnImmunityDurationMinutes ?? 0,
)}
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.player_immunity_duration")}</span>
</div>
</label>
<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" /> <hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />
<!-- Individual disables for structures/weapons --> <!-- Individual disables for structures/weapons -->
@@ -691,6 +734,23 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig(); 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 > 120) {
return;
}
this.spawnImmunityDurationMinutes = value;
this.putGameConfig();
}
private handleRandomSpawnChange(e: Event) { private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked); this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig(); this.putGameConfig();
@@ -757,6 +817,9 @@ export class HostLobbyModal extends LitElement {
} }
private async putGameConfig() { private async putGameConfig() {
const spawnImmunityTicks = this.spawnImmunityDurationMinutes
? this.spawnImmunityDurationMinutes * 60 * 10
: 0;
this.dispatchEvent( this.dispatchEvent(
new CustomEvent("update-game-config", { new CustomEvent("update-game-config", {
detail: { detail: {
@@ -775,6 +838,9 @@ export class HostLobbyModal extends LitElement {
randomSpawn: this.randomSpawn, randomSpawn: this.randomSpawn,
gameMode: this.gameMode, gameMode: this.gameMode,
disabledUnits: this.disabledUnits, disabledUnits: this.disabledUnits,
spawnImmunityDuration: this.spawnImmunity
? spawnImmunityTicks
: undefined,
playerTeams: this.teamCount, playerTeams: this.teamCount,
...(this.gameMode === GameMode.Team && ...(this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations this.teamCount === HumansVsNations
+10
View File
@@ -18,6 +18,7 @@ import { FxLayer } from "./layers/FxLayer";
import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar";
import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { ImmunityTimer } from "./layers/ImmunityTimer";
import { Layer } from "./layers/Layer"; import { Layer } from "./layers/Layer";
import { Leaderboard } from "./layers/Leaderboard"; import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MainRadialMenu } from "./layers/MainRadialMenu";
@@ -234,6 +235,14 @@ export function createRenderer(
spawnTimer.game = game; spawnTimer.game = game;
spawnTimer.transformHandler = transformHandler; spawnTimer.transformHandler = transformHandler;
const immunityTimer = document.querySelector(
"immunity-timer",
) as ImmunityTimer;
if (!(immunityTimer instanceof ImmunityTimer)) {
console.error("immunity timer not found");
}
immunityTimer.game = game;
// When updating these layers please be mindful of the order. // When updating these layers please be mindful of the order.
// Try to group layers by the return value of shouldTransform. // Try to group layers by the return value of shouldTransform.
// Not grouping the layers may cause excessive calls to context.save() and context.restore(). // Not grouping the layers may cause excessive calls to context.save() and context.restore().
@@ -262,6 +271,7 @@ export function createRenderer(
playerPanel, playerPanel,
), ),
spawnTimer, spawnTimer,
immunityTimer,
leaderboard, leaderboard,
gameLeftSidebar, gameLeftSidebar,
unitDisplay, unitDisplay,
@@ -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("immunity-timer")
export class ImmunityTimer 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 immunityDuration = this.game.config().spawnImmunityDuration();
const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns();
if (immunityDuration <= 5 * 10 || this.game.inSpawnPhase()) {
this.setInactive();
return;
}
const immunityEnd = spawnPhaseTurns + immunityDuration;
const ticks = this.game.ticks();
if (ticks >= immunityEnd || ticks < spawnPhaseTurns) {
this.setInactive();
return;
}
const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns);
this.progressRatio = Math.min(
1,
Math.max(0, elapsedTicks / immunityDuration),
);
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
View File
@@ -179,6 +179,7 @@ export const GameConfigSchema = z.object({
randomSpawn: z.boolean(), randomSpawn: z.boolean(),
maxPlayers: z.number().optional(), maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(), maxTimerValue: z.number().int().min(1).max(120).optional(),
spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks
disabledUnits: z.enum(UnitType).array().optional(), disabledUnits: z.enum(UnitType).array().optional(),
playerTeams: TeamCountConfigSchema.optional(), playerTeams: TeamCountConfigSchema.optional(),
}); });
+1 -1
View File
@@ -246,7 +246,7 @@ export class DefaultConfig implements Config {
return 30 * 10; // 30 seconds return 30 * 10; // 30 seconds
} }
spawnImmunityDuration(): Tick { spawnImmunityDuration(): Tick {
return 5 * 10; return this._gameConfig.spawnImmunityDuration ?? 5 * 10; // default to 5 seconds
} }
gameConfig(): GameConfig { gameConfig(): GameConfig {
+3 -10
View File
@@ -92,16 +92,9 @@ export class AttackExecution implements Execution {
} }
} }
if (this.target.isPlayer()) { if (this.target.isPlayer() && !this._owner.canAttackPlayer(this.target)) {
if ( this.active = false;
this.mg.config().numSpawnPhaseTurns() + return;
this.mg.config().spawnImmunityDuration() >
this.mg.ticks()
) {
console.warn("cannot attack player during immunity phase");
this.active = false;
return;
}
} }
this.startTroops ??= this.mg this.startTroops ??= this.mg
@@ -93,6 +93,10 @@ export class TransportShipExecution implements Execution {
} else { } else {
this.target = mg.player(this.targetID); this.target = mg.player(this.targetID);
} }
if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) {
this.active = false;
return;
}
this.startTroops ??= this.mg this.startTroops ??= this.mg
.config() .config()
+1 -1
View File
@@ -89,7 +89,7 @@ export class WarshipExecution implements Execution {
if ( if (
unit.owner() === this.warship.owner() || unit.owner() === this.warship.owner() ||
unit === this.warship || unit === this.warship ||
unit.owner().isFriendly(this.warship.owner(), true) || !this.warship.owner().canAttackPlayer(unit.owner(), true) ||
this.alreadySentShell.has(unit) this.alreadySentShell.has(unit)
) { ) {
continue; continue;
+5
View File
@@ -659,6 +659,8 @@ export interface Player {
// Attacking. // Attacking.
canAttack(tile: TileRef): boolean; canAttack(tile: TileRef): boolean;
canAttackPlayer(player: Player, treatAFKFriendly?: boolean): boolean;
isImmune(): boolean;
createAttack( createAttack(
target: Player | TerraNullius, target: Player | TerraNullius,
@@ -713,6 +715,9 @@ export interface Game extends GameMap {
alliances(): MutableAlliance[]; alliances(): MutableAlliance[];
expireAlliance(alliance: Alliance): void; expireAlliance(alliance: Alliance): void;
// Immunity timer
isSpawnImmunityActive(): boolean;
// Game State // Game State
ticks(): Tick; ticks(): Tick;
inSpawnPhase(): boolean; inSpawnPhase(): boolean;
+8
View File
@@ -677,6 +677,14 @@ export class GameImpl implements Game {
}); });
} }
public isSpawnImmunityActive(): boolean {
return (
this.config().numSpawnPhaseTurns() +
this.config().spawnImmunityDuration() >=
this.ticks()
);
}
sendEmojiUpdate(msg: EmojiMessage): void { sendEmojiUpdate(msg: EmojiMessage): void {
this.addUpdate({ this.addUpdate({
type: GameUpdateType.Emoji, type: GameUpdateType.Emoji,
+22 -14
View File
@@ -1010,6 +1010,9 @@ export class PlayerImpl implements Player {
} }
nukeSpawn(tile: TileRef): TileRef | false { nukeSpawn(tile: TileRef): TileRef | false {
if (this.mg.isSpawnImmunityActive()) {
return false;
}
const owner = this.mg.owner(tile); const owner = this.mg.owner(tile);
if (owner.isPlayer()) { if (owner.isPlayer()) {
if (this.isOnSameTeam(owner)) { if (this.isOnSameTeam(owner)) {
@@ -1200,31 +1203,36 @@ export class PlayerImpl implements Player {
return this._incomingAttacks; return this._incomingAttacks;
} }
public isImmune(): boolean {
return this.type() === PlayerType.Human && this.mg.isSpawnImmunityActive();
}
public canAttackPlayer(
player: Player,
treatAFKFriendly: boolean = false,
): boolean {
if (this.type() === PlayerType.Human) {
return !player.isImmune() && !this.isFriendly(player, treatAFKFriendly);
}
// Only humans are affected by immunity, bots and nations should be able to attack freely
return !this.isFriendly(player, treatAFKFriendly);
}
public canAttack(tile: TileRef): boolean { public canAttack(tile: TileRef): boolean {
if ( const owner = this.mg.owner(tile);
this.mg.hasOwner(tile) && if (owner === this) {
this.mg.config().numSpawnPhaseTurns() +
this.mg.config().spawnImmunityDuration() >
this.mg.ticks()
) {
return false; return false;
} }
if (this.mg.owner(tile) === this) { if (owner.isPlayer() && !this.canAttackPlayer(owner)) {
return false; return false;
} }
const other = this.mg.owner(tile);
if (other.isPlayer()) {
if (this.isFriendly(other)) {
return false;
}
}
if (!this.mg.isLand(tile)) { if (!this.mg.isLand(tile)) {
return false; return false;
} }
if (this.mg.hasOwner(tile)) { if (this.mg.hasOwner(tile)) {
return this.sharesBorderWith(other); return this.sharesBorderWith(owner);
} else { } else {
for (const t of this.mg.bfs( for (const t of this.mg.bfs(
tile, tile,
+1 -1
View File
@@ -23,7 +23,7 @@ export function canBuildTransportShip(
if (other === player) { if (other === player) {
return false; return false;
} }
if (other.isPlayer() && player.isFriendly(other)) { if (other.isPlayer() && !player.canAttackPlayer(other)) {
return false; return false;
} }
+3
View File
@@ -121,6 +121,9 @@ export class GameServer {
if (gameConfig.randomSpawn !== undefined) { if (gameConfig.randomSpawn !== undefined) {
this.gameConfig.randomSpawn = gameConfig.randomSpawn; this.gameConfig.randomSpawn = gameConfig.randomSpawn;
} }
if (gameConfig.spawnImmunityDuration !== undefined) {
this.gameConfig.spawnImmunityDuration = gameConfig.spawnImmunityDuration;
}
if (gameConfig.gameMode !== undefined) { if (gameConfig.gameMode !== undefined) {
this.gameConfig.gameMode = gameConfig.gameMode; this.gameConfig.gameMode = gameConfig.gameMode;
} }
+1
View File
@@ -110,6 +110,7 @@ export class MapPlaylist {
gameMode: mode, gameMode: mode,
playerTeams, playerTeams,
bots: 400, bots: 400,
spawnImmunityDuration: 5 * 10,
disabledUnits: [], disabledUnits: [],
} satisfies GameConfig; } satisfies GameConfig;
} }
+156 -4
View File
@@ -27,6 +27,13 @@ function sendBoat(target: TileRef, source: TileRef, troops: number) {
); );
} }
const immunityPhaseTicks = 10;
function waitForImmunityToEnd() {
for (let i = 0; i < immunityPhaseTicks + 1; i++) {
game.executeNextTick();
}
}
describe("Attack", () => { describe("Attack", () => {
beforeEach(async () => { beforeEach(async () => {
game = await setup("ocean_and_land", { game = await setup("ocean_and_land", {
@@ -185,7 +192,7 @@ describe("Attack race condition with alliance requests", () => {
} }
}); });
it("should not mark attacker as traitor when alliance is formed after attack starts", async () => { it("Should not mark attacker as traitor when alliance is formed after attack starts", async () => {
// Player A sends alliance request to Player B // Player A sends alliance request to Player B
const allianceRequest = playerA.createAllianceRequest(playerB); const allianceRequest = playerA.createAllianceRequest(playerB);
expect(allianceRequest).not.toBeNull(); expect(allianceRequest).not.toBeNull();
@@ -229,7 +236,7 @@ describe("Attack race condition with alliance requests", () => {
expect(playerB.outgoingAttacks()).toHaveLength(0); expect(playerB.outgoingAttacks()).toHaveLength(0);
}); });
it("should prevent player from attacking allied player", async () => { it("Should prevent player from attacking allied player", async () => {
// Create an alliance between Player A and Player B // Create an alliance between Player A and Player B
const allianceRequest = playerA.createAllianceRequest(playerB); const allianceRequest = playerA.createAllianceRequest(playerB);
if (allianceRequest) { if (allianceRequest) {
@@ -261,7 +268,7 @@ describe("Attack race condition with alliance requests", () => {
expect(playerB.incomingAttacks()).toHaveLength(0); expect(playerB.incomingAttacks()).toHaveLength(0);
}); });
test("should cancel alliance requests if the recipient attacks", async () => { test("Should cancel alliance requests if the recipient attacks", async () => {
// Player A sends alliance request to Player B // Player A sends alliance request to Player B
const allianceRequest = playerA.createAllianceRequest(playerB); const allianceRequest = playerA.createAllianceRequest(playerB);
expect(allianceRequest).not.toBeNull(); expect(allianceRequest).not.toBeNull();
@@ -285,7 +292,7 @@ describe("Attack race condition with alliance requests", () => {
expect(playerB.incomingAllianceRequests()).toHaveLength(0); expect(playerB.incomingAllianceRequests()).toHaveLength(0);
}); });
test("should cancel the proper alliance request among many", async () => { test("Should cancel the proper alliance request among many", async () => {
// Add a new player to have more alliance requests // Add a new player to have more alliance requests
const playerCInfo = new PlayerInfo( const playerCInfo = new PlayerInfo(
"playerB", "playerB",
@@ -324,3 +331,148 @@ describe("Attack race condition with alliance requests", () => {
expect(playerB.incomingAllianceRequests()).toHaveLength(1); expect(playerB.incomingAllianceRequests()).toHaveLength(1);
}); });
}); });
describe("Attack immunity", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
(game.config() as TestConfig).setSpawnImmunityDuration(immunityPhaseTicks);
const playerAInfo = new PlayerInfo(
"playerA",
PlayerType.Human,
null,
"playerA_id",
);
// close to the water to send boats
playerA = addPlayerToGame(playerAInfo, game, game.ref(7, 0));
const playerBInfo = new PlayerInfo(
"playerB",
PlayerType.Human,
null,
"playerB_id",
);
playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
test("Should not be able to attack during immunity phase", async () => {
// Player A attacks Player B
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(0);
});
test("Should be able to attack after immunity phase", async () => {
waitForImmunityToEnd();
// Player A attacks Player B
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(1);
});
test("Ensure a player can't attack during all the immunity phase", async () => {
// Execute a few ticks but stop right before the immunity phase is over
for (let i = 0; i < immunityPhaseTicks - 1; i++) {
game.executeNextTick();
}
// Player A attacks Player B
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
game.executeNextTick(); // ticks === immunityPhaseTicks here
// Attack is not possible during immunity
expect(playerA.outgoingAttacks()).toHaveLength(0);
// Retry after the immunity is over
game.executeNextTick(); // ticks === immunityPhaseTicks + 1
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
game.executeNextTick();
// Attack is now possible right after
expect(playerA.outgoingAttacks()).toHaveLength(1);
});
test("Should not be able to send a boat during immunity phase", async () => {
// Player A sends a boat targeting Player B
game.addExecution(
new TransportShipExecution(
playerA,
playerB.id(),
game.ref(15, 8),
10,
game.ref(10, 5),
),
);
game.executeNextTick();
expect(playerA.units(UnitType.TransportShip)).toHaveLength(0);
});
test("Should be able to send a boat after immunity phase", async () => {
waitForImmunityToEnd();
// Player A sends a boat targeting Player B
game.addExecution(
new TransportShipExecution(
playerA,
playerB.id(),
game.ref(15, 8),
10,
game.ref(7, 0),
),
);
game.executeNextTick();
expect(playerA.units(UnitType.TransportShip)).toHaveLength(1);
});
test("Should be able to attack nations during immunity phase", async () => {
const nationId = "nation_id";
const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId);
game.addPlayer(nation);
// Player A attacks the nation
const attackExecution = new AttackExecution(null, playerA, nationId, null);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(1);
});
test("Should be able to attack bots during immunity phase", async () => {
const botId = "bot_id";
const bot = new PlayerInfo("bot", PlayerType.Bot, null, botId);
game.addPlayer(bot);
// Player A attacks the bot
const attackExecution = new AttackExecution(null, playerA, botId, null);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(1);
});
test("Can't send nuke during immunity phase", async () => {
constructionExecution(game, playerA, 7, 0, UnitType.MissileSilo);
expect(playerA.units(UnitType.MissileSilo)).toHaveLength(1);
// Player A sends a bomb to player B
constructionExecution(game, playerA, 0, 11, UnitType.AtomBomb, 3);
expect(playerA.units(UnitType.AtomBomb)).toHaveLength(0);
// Now wait for immunity to end
waitForImmunityToEnd();
// And send the exact same order
constructionExecution(game, playerA, 0, 11, UnitType.AtomBomb, 3);
expect(playerA.units(UnitType.AtomBomb)).toHaveLength(1);
});
});
+6 -1
View File
@@ -12,6 +12,7 @@ import { TileRef } from "../../src/core/game/GameMap";
export class TestConfig extends DefaultConfig { export class TestConfig extends DefaultConfig {
private _proximityBonusPortsNb: number = 0; private _proximityBonusPortsNb: number = 0;
private _defaultNukeSpeed: number = 4; private _defaultNukeSpeed: number = 4;
private _spawnImmunityDuration: number = 0;
radiusPortSpawn(): number { radiusPortSpawn(): number {
return 1; return 1;
@@ -54,8 +55,12 @@ export class TestConfig extends DefaultConfig {
return 20; return 20;
} }
setSpawnImmunityDuration(duration: Tick) {
this._spawnImmunityDuration = duration;
}
spawnImmunityDuration(): Tick { spawnImmunityDuration(): Tick {
return 0; return this._spawnImmunityDuration;
} }
attackLogic( attackLogic(