mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
Fix nation spawnkilling 🔧 (#3222)
## Description: As far as I can remember, in v28 the spawn immunity applied to both humans and nations. With the configurable spawn immunity (added for v29) the spawn immunity no longer applies to nations... Because its called PVP immunity now. So right now it's possible to spawnkill nations. This is a big problem for the 5M gold modifier games... And you can "cheat" in singleplayer. This PR changes two things: - Nations always have 5 seconds spawn immunity now, no matter whats configured for the PVP immunity - Nations attack TerraNullius earlier (Otherwise the easy nations would sometimes do their first attack after the 5 seconds are over, spawnkills would still be possible) ## 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: FloPinguin --------- Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
This commit is contained in:
@@ -58,6 +58,7 @@ export interface NukeMagnitude {
|
||||
|
||||
export interface Config {
|
||||
spawnImmunityDuration(): Tick;
|
||||
nationSpawnImmunityDuration(): Tick;
|
||||
hasExtendedSpawnImmunity(): boolean;
|
||||
serverConfig(): ServerConfig;
|
||||
gameConfig(): GameConfig;
|
||||
|
||||
@@ -168,6 +168,9 @@ export class DefaultConfig implements Config {
|
||||
this._gameConfig.spawnImmunityDuration ?? DEFAULT_SPAWN_IMMUNITY_TICKS
|
||||
);
|
||||
}
|
||||
nationSpawnImmunityDuration(): Tick {
|
||||
return DEFAULT_SPAWN_IMMUNITY_TICKS;
|
||||
}
|
||||
hasExtendedSpawnImmunity(): boolean {
|
||||
return this.spawnImmunityDuration() > DEFAULT_SPAWN_IMMUNITY_TICKS;
|
||||
}
|
||||
|
||||
@@ -94,33 +94,14 @@ export class NationExecution implements Execution {
|
||||
this.warshipBehavior.trackShipsAndRetaliate();
|
||||
}
|
||||
|
||||
if (ticks % this.attackRate !== this.attackTick) {
|
||||
// Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval)
|
||||
// Otherwise it is possible that we earn more gold than we can spend
|
||||
// The alternative is placing multiple structures in handleStructures, but that causes problems
|
||||
if (
|
||||
this.behaviorsInitialized &&
|
||||
this.player !== null &&
|
||||
this.player.isAlive()
|
||||
) {
|
||||
const offset = ticks % this.attackRate;
|
||||
const oneThird =
|
||||
(this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate;
|
||||
const twoThirds =
|
||||
(this.attackTick + Math.floor((this.attackRate * 2) / 3)) %
|
||||
this.attackRate;
|
||||
if (offset === oneThird || offset === twoThirds) {
|
||||
this.structureBehavior.handleStructures();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.player === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mg.inSpawnPhase()) {
|
||||
if (ticks % this.attackRate !== this.attackTick) {
|
||||
return;
|
||||
}
|
||||
// Place nations without a spawn cell (Dynamically created for HumansVsNations) randomly by SpawnExecution
|
||||
if (this.nation.spawnCell === undefined) {
|
||||
this.mg.addExecution(
|
||||
@@ -155,6 +136,24 @@ export class NationExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticks % this.attackRate !== this.attackTick) {
|
||||
// Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval)
|
||||
// Otherwise it is possible that we earn more gold than we can spend
|
||||
// The alternative is placing multiple structures in handleStructures, but that causes problems
|
||||
if (this.player.isAlive()) {
|
||||
const offset = ticks % this.attackRate;
|
||||
const oneThird =
|
||||
(this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate;
|
||||
const twoThirds =
|
||||
(this.attackTick + Math.floor((this.attackRate * 2) / 3)) %
|
||||
this.attackRate;
|
||||
if (offset === oneThird || offset === twoThirds) {
|
||||
this.structureBehavior.handleStructures();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.emojiBehavior.maybeSendCasualEmoji();
|
||||
this.updateRelationsFromEmbargos();
|
||||
this.allianceBehavior.handleAllianceRequests();
|
||||
|
||||
@@ -759,6 +759,7 @@ export interface Game extends GameMap {
|
||||
|
||||
// Immunity timer
|
||||
isSpawnImmunityActive(): boolean;
|
||||
isNationSpawnImmunityActive(): boolean;
|
||||
|
||||
// Game State
|
||||
ticks(): Tick;
|
||||
|
||||
@@ -727,6 +727,14 @@ export class GameImpl implements Game {
|
||||
);
|
||||
}
|
||||
|
||||
public isNationSpawnImmunityActive(): boolean {
|
||||
return (
|
||||
this.config().numSpawnPhaseTurns() +
|
||||
this.config().nationSpawnImmunityDuration() >
|
||||
this.ticks()
|
||||
);
|
||||
}
|
||||
|
||||
sendEmojiUpdate(msg: EmojiMessage): void {
|
||||
this.addUpdate({
|
||||
type: GameUpdateType.Emoji,
|
||||
|
||||
@@ -824,6 +824,13 @@ export class GameView implements GameMap {
|
||||
this.ticks()
|
||||
);
|
||||
}
|
||||
isNationSpawnImmunityActive(): boolean {
|
||||
return (
|
||||
this._config.numSpawnPhaseTurns() +
|
||||
this._config.nationSpawnImmunityDuration() >
|
||||
this.ticks()
|
||||
);
|
||||
}
|
||||
config(): Config {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
@@ -1261,18 +1261,25 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
public isImmune(): boolean {
|
||||
return this.type() === PlayerType.Human && this.mg.isSpawnImmunityActive();
|
||||
if (this.type() === PlayerType.Human) {
|
||||
return this.mg.isSpawnImmunityActive();
|
||||
}
|
||||
if (this.type() === PlayerType.Nation) {
|
||||
return this.mg.isNationSpawnImmunityActive();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public canAttackPlayer(
|
||||
player: Player,
|
||||
treatAFKFriendly: boolean = false,
|
||||
): boolean {
|
||||
if (this.type() === PlayerType.Human) {
|
||||
return !player.isImmune() && !this.isFriendly(player, treatAFKFriendly);
|
||||
if (this.type() === PlayerType.Bot) {
|
||||
// Bots are not affected by immunity
|
||||
return !this.isFriendly(player, treatAFKFriendly);
|
||||
}
|
||||
// Only humans are affected by immunity, bots and nations should be able to attack freely
|
||||
return !this.isFriendly(player, treatAFKFriendly);
|
||||
// Humans and Nations respect immunity
|
||||
return !player.isImmune() && !this.isFriendly(player, treatAFKFriendly);
|
||||
}
|
||||
|
||||
public canAttack(tile: TileRef): boolean {
|
||||
|
||||
+20
-2
@@ -426,11 +426,29 @@ describe("Attack immunity", () => {
|
||||
expect(playerA.units(UnitType.TransportShip)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Should be able to attack nations during immunity phase", async () => {
|
||||
test("Should not be able to attack nations during nation immunity phase", async () => {
|
||||
(game.config() as TestConfig).setNationSpawnImmunityDuration(
|
||||
immunityPhaseTicks,
|
||||
);
|
||||
const nationId = "nation_id";
|
||||
const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId);
|
||||
game.addPlayer(nation);
|
||||
// Player A attacks the nation
|
||||
// Player A attacks the nation during nation immunity
|
||||
const attackExecution = new AttackExecution(null, playerA, nationId, null);
|
||||
game.addExecution(attackExecution);
|
||||
game.executeNextTick();
|
||||
expect(playerA.outgoingAttacks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Should be able to attack nations after nation immunity phase", async () => {
|
||||
(game.config() as TestConfig).setNationSpawnImmunityDuration(
|
||||
immunityPhaseTicks,
|
||||
);
|
||||
const nationId = "nation_id";
|
||||
const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId);
|
||||
game.addPlayer(nation);
|
||||
waitForImmunityToEnd();
|
||||
// Player A attacks the nation after immunity
|
||||
const attackExecution = new AttackExecution(null, playerA, nationId, null);
|
||||
game.addExecution(attackExecution);
|
||||
game.executeNextTick();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MirvExecution } from "../src/core/execution/MIRVExecution";
|
||||
import { MissileSiloExecution } from "../src/core/execution/MissileSiloExecution";
|
||||
import { NationExecution } from "../src/core/execution/NationExecution";
|
||||
import {
|
||||
Cell,
|
||||
@@ -64,6 +65,11 @@ describe("Nation MIRV Retaliation", () => {
|
||||
}
|
||||
}
|
||||
nation.buildUnit(UnitType.MissileSilo, game.ref(50, 50), {});
|
||||
// Register MissileSiloExecution so the silo can reload after firing
|
||||
const nationSilo = nation.units(UnitType.MissileSilo)[0];
|
||||
if (nationSilo) {
|
||||
game.addExecution(new MissileSiloExecution(nationSilo));
|
||||
}
|
||||
|
||||
// Give both players enough gold for MIRVs
|
||||
attacker.addGold(1_000_000_000n);
|
||||
@@ -85,6 +91,8 @@ describe("Nation MIRV Retaliation", () => {
|
||||
let retaliationAttempted = false;
|
||||
|
||||
for (const gameId of gameIds) {
|
||||
// Advance game to clear any silo cooldowns from previous iteration
|
||||
executeTicks(game, 100);
|
||||
const testExecution = new NationExecution(gameId, testExecutionNation);
|
||||
testExecution.init(game);
|
||||
|
||||
@@ -197,6 +205,11 @@ describe("Nation MIRV Retaliation", () => {
|
||||
const nationTile = Array.from(nation.tiles())[0];
|
||||
if (nationTile) {
|
||||
nation.buildUnit(UnitType.MissileSilo, nationTile, {});
|
||||
// Register MissileSiloExecution so the silo can reload after firing
|
||||
const silo = nation.units(UnitType.MissileSilo)[0];
|
||||
if (silo) {
|
||||
game.addExecution(new MissileSiloExecution(silo));
|
||||
}
|
||||
}
|
||||
|
||||
// Then give dominant player a large amount of territory
|
||||
@@ -253,6 +266,8 @@ describe("Nation MIRV Retaliation", () => {
|
||||
let victoryDenialSuccessful = false;
|
||||
|
||||
for (const gameId of gameIds) {
|
||||
// Advance game to clear any silo cooldowns from previous iteration
|
||||
executeTicks(game, 100);
|
||||
const testExecution = new NationExecution(gameId, testExecutionNation);
|
||||
testExecution.init(game);
|
||||
|
||||
@@ -347,6 +362,11 @@ describe("Nation MIRV Retaliation", () => {
|
||||
const nationTile = Array.from(nation.tiles())[0];
|
||||
if (nationTile) {
|
||||
nation.buildUnit(UnitType.MissileSilo, nationTile, {});
|
||||
// Register MissileSiloExecution so the silo can reload after firing
|
||||
const silo = nation.units(UnitType.MissileSilo)[0];
|
||||
if (silo) {
|
||||
game.addExecution(new MissileSiloExecution(silo));
|
||||
}
|
||||
}
|
||||
|
||||
// Give second player some territory and cities
|
||||
@@ -406,6 +426,8 @@ describe("Nation MIRV Retaliation", () => {
|
||||
let steamrollStopSuccessful = false;
|
||||
|
||||
for (const gameId of gameIds) {
|
||||
// Advance game to clear any silo cooldowns from previous iteration
|
||||
executeTicks(game, 100);
|
||||
const testExecution = new NationExecution(gameId, testExecutionNation);
|
||||
testExecution.init(game);
|
||||
|
||||
@@ -631,6 +653,11 @@ describe("Nation MIRV Retaliation", () => {
|
||||
const nationTile = Array.from(nation.tiles())[0];
|
||||
if (nationTile) {
|
||||
nation.buildUnit(UnitType.MissileSilo, nationTile, {});
|
||||
// Register MissileSiloExecution so the silo can reload after firing
|
||||
const silo = nation.units(UnitType.MissileSilo)[0];
|
||||
if (silo) {
|
||||
game.addExecution(new MissileSiloExecution(silo));
|
||||
}
|
||||
}
|
||||
|
||||
// Give team players a large amount of territory to exceed team threshold,
|
||||
@@ -691,6 +718,8 @@ describe("Nation MIRV Retaliation", () => {
|
||||
let teamVictoryDenialSuccessful = false;
|
||||
|
||||
for (const gameId of gameIds) {
|
||||
// Advance game to clear any silo cooldowns from previous iteration
|
||||
executeTicks(game, 100);
|
||||
const testExecution = new NationExecution(gameId, testExecutionNation);
|
||||
testExecution.init(game);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export class TestConfig extends DefaultConfig {
|
||||
private _proximityBonusPortsNb: number = 0;
|
||||
private _defaultNukeSpeed: number = 4;
|
||||
private _spawnImmunityDuration: number = 0;
|
||||
private _nationSpawnImmunityDuration: number = 0;
|
||||
|
||||
disableNavMesh(): boolean {
|
||||
return this.gameConfig().disableNavMesh ?? true;
|
||||
@@ -67,6 +68,14 @@ export class TestConfig extends DefaultConfig {
|
||||
return this._spawnImmunityDuration;
|
||||
}
|
||||
|
||||
setNationSpawnImmunityDuration(duration: Tick) {
|
||||
this._nationSpawnImmunityDuration = duration;
|
||||
}
|
||||
|
||||
nationSpawnImmunityDuration(): Tick {
|
||||
return this._nationSpawnImmunityDuration;
|
||||
}
|
||||
|
||||
attackLogic(
|
||||
gm: Game,
|
||||
attackTroops: number,
|
||||
|
||||
Reference in New Issue
Block a user