mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:21:27 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user