diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index a1dcffd05..fb7fd9351 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -110,6 +110,13 @@ export class SendEmbargoIntentEvent implements GameEvent {
) {}
}
+export class CancelAttackIntentEvent implements GameEvent {
+ constructor(
+ public readonly playerID: PlayerID,
+ public readonly attackID: string,
+ ) {}
+}
+
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(public readonly ratio: number) {}
}
@@ -179,6 +186,9 @@ export class Transport {
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e));
this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e));
+ this.eventBus.on(CancelAttackIntentEvent, (e) =>
+ this.onCancelAttackIntentEvent(e),
+ );
}
private startPing() {
@@ -501,6 +511,15 @@ export class Transport {
}
}
+ private onCancelAttackIntentEvent(event: CancelAttackIntentEvent) {
+ this.sendIntent({
+ type: "cancel_attack",
+ clientID: this.lobbyConfig.clientID,
+ playerID: event.playerID,
+ attackID: event.attackID,
+ });
+ }
+
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts
index a479fc5e1..d80f96120 100644
--- a/src/client/graphics/layers/EventsDisplay.ts
+++ b/src/client/graphics/layers/EventsDisplay.ts
@@ -20,7 +20,10 @@ import { AllianceRequestUpdate } from "../../../core/game/GameUpdates";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { ClientID } from "../../../core/Schemas";
import { Layer } from "./Layer";
-import { SendAllianceReplyIntentEvent } from "../../Transport";
+import {
+ CancelAttackIntentEvent,
+ SendAllianceReplyIntentEvent,
+} from "../../Transport";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { onlyImages, sanitize } from "../../../core/Util";
import { GameView, PlayerView } from "../../../core/game/GameView";
@@ -298,6 +301,12 @@ export class EventsDisplay extends LitElement implements Layer {
});
}
+ emitCancelAttackIntent(id: string) {
+ const myPlayer = this.game.playerByClientID(this.clientID);
+ if (!myPlayer) return;
+ this.eventBus.emit(new CancelAttackIntentEvent(myPlayer.id(), id));
+ }
+
onEmojiMessageEvent(update: EmojiUpdate) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (!myPlayer) return;
@@ -386,6 +395,16 @@ export class EventsDisplay extends LitElement implements Layer {
${(
this.game.playerBySmallID(attack.targetID) as PlayerView
)?.name()}
+ ${!attack.retreating
+ ? html``
+ : "(retreating...)"}
`,
)}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index bf6aff0ef..2d20dd7c5 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -14,6 +14,7 @@ export type ClientID = string;
export type Intent =
| SpawnIntent
| AttackIntent
+ | CancelAttackIntent
| BoatAttackIntent
| AllianceRequestIntent
| AllianceRequestReplyIntent
@@ -26,6 +27,7 @@ export type Intent =
| EmbargoIntent;
export type AttackIntent = z.infer;
+export type CancelAttackIntent = z.infer;
export type SpawnIntent = z.infer;
export type BoatAttackIntent = z.infer;
export type AllianceRequestIntent = z.infer;
@@ -134,6 +136,7 @@ const ID = z
const BaseIntentSchema = z.object({
type: z.enum([
"attack",
+ "cancel_attack",
"spawn",
"boat",
"name",
@@ -233,8 +236,15 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({
y: z.number(),
});
+export const CancelAttackIntentSchema = BaseIntentSchema.extend({
+ type: z.literal("cancel_attack"),
+ playerID: ID,
+ attackID: z.string(),
+});
+
const IntentSchema = z.union([
AttackIntentSchema,
+ CancelAttackIntentSchema,
SpawnIntentSchema,
BoatAttackIntentSchema,
AllianceRequestIntentSchema,
diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts
index 0bd6a365f..469b0eb7c 100644
--- a/src/core/execution/AttackExecution.ts
+++ b/src/core/execution/AttackExecution.ts
@@ -15,6 +15,8 @@ import { MessageType } from "../game/Game";
import { renderNumber } from "../../client/Utils";
import { TileRef } from "../game/GameMap";
+const malusForRetreat = 25;
+
export class AttackExecution implements Execution {
private breakAlliance = false;
private active: boolean = true;
@@ -162,7 +164,19 @@ export class AttackExecution implements Execution {
}
}
+ private retreat(malusPercent = 0) {
+ this._owner.addTroops(this.attack.troops() * (1 - malusPercent / 100));
+ this.attack.delete();
+ this.active = false;
+ }
+
tick(ticks: number) {
+ if (this.attack.retreated()) {
+ this.retreat(malusForRetreat);
+ this.active = false;
+ return;
+ }
+
if (!this.attack.isActive()) {
this.active = false;
return;
@@ -175,9 +189,7 @@ export class AttackExecution implements Execution {
}
if (this.target.isPlayer() && this._owner.isAlliedWith(this.target)) {
// In this case a new alliance was created AFTER the attack started.
- this._owner.addTroops(this.attack.troops());
- this.attack.delete();
- this.active = false;
+ this.retreat();
return;
}
@@ -201,9 +213,7 @@ export class AttackExecution implements Execution {
if (this.toConquer.size() == 0) {
this.refreshToConquer();
- this.active = false;
- this._owner.addTroops(this.attack.troops());
- this.attack.delete();
+ this.retreat();
return;
}
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index c6b84e850..224418df8 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -35,6 +35,7 @@ import { ConstructionExecution } from "./ConstructionExecution";
import { fixProfaneUsername, isProfaneUsername } from "../validations/username";
import { NoOpExecution } from "./NoOpExecution";
import { EmbargoExecution } from "./EmbargoExecution";
+import { RetreatExecution } from "./RetreatExecution";
export class Executor {
// private random = new PseudoRandom(999)
@@ -80,6 +81,8 @@ export class Executor {
null,
);
}
+ case "cancel_attack":
+ return new RetreatExecution(intent.playerID, intent.attackID);
case "spawn":
return new SpawnExecution(
new PlayerInfo(
diff --git a/src/core/execution/RetreatExecution.ts b/src/core/execution/RetreatExecution.ts
new file mode 100644
index 000000000..fd341765d
--- /dev/null
+++ b/src/core/execution/RetreatExecution.ts
@@ -0,0 +1,50 @@
+import { Execution, Game, Player, PlayerID } from "../game/Game";
+
+const cancelDelay = 2;
+
+export class RetreatExecution implements Execution {
+ private active = true;
+ private retreatOrdered = false;
+ private player: Player;
+ private executionDateInSecs = new Date().getTime() / 1000 + cancelDelay;
+
+ constructor(
+ private playerID: PlayerID,
+ private attackID: string,
+ ) {}
+
+ init(mg: Game, ticks: number): void {
+ if (!mg.hasPlayer(this.playerID)) {
+ console.warn(`RetreatExecution: player ${this.player.id()} not found`);
+ return;
+ }
+
+ this.player = mg.player(this.playerID);
+ }
+
+ tick(ticks: number): void {
+ const nowInSecs = new Date().getTime() / 1000;
+
+ if (!this.retreatOrdered) {
+ this.player.orderRetreat(this.attackID);
+ this.retreatOrdered = true;
+ }
+
+ if (nowInSecs >= this.executionDateInSecs) {
+ this.player.executeRetreat(this.attackID);
+ this.active = false;
+ }
+ }
+
+ owner(): Player {
+ return this.player;
+ }
+
+ isActive(): boolean {
+ return this.active;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+}
diff --git a/src/core/game/AttackImpl.ts b/src/core/game/AttackImpl.ts
index 7444f6b24..d2f91bff2 100644
--- a/src/core/game/AttackImpl.ts
+++ b/src/core/game/AttackImpl.ts
@@ -4,8 +4,11 @@ import { PlayerImpl } from "./PlayerImpl";
export class AttackImpl implements Attack {
private _isActive = true;
+ public _retreating = false;
+ public _retreated = false;
constructor(
+ private _id: string,
private _target: Player | TerraNullius,
private _attacker: Player,
private _troops: number,
@@ -33,6 +36,10 @@ export class AttackImpl implements Attack {
return this._isActive;
}
+ id() {
+ return this._id;
+ }
+
delete() {
if (this._target.isPlayer()) {
(this._target as PlayerImpl)._incomingAttacks = (
@@ -46,4 +53,20 @@ export class AttackImpl implements Attack {
this._isActive = false;
}
+
+ orderRetreat() {
+ this._retreating = true;
+ }
+
+ executeRetreat() {
+ this._retreated = true;
+ }
+
+ retreating(): boolean {
+ return this._retreating;
+ }
+
+ retreated(): boolean {
+ return this._retreated;
+ }
}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index ab2286f00..0d32ca259 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -149,6 +149,11 @@ export interface Execution {
}
export interface Attack {
+ id(): string;
+ retreating(): boolean;
+ retreated(): boolean;
+ orderRetreat(): void;
+ executeRetreat(): void;
target(): Player | TerraNullius;
attacker(): Player;
troops(): number;
@@ -334,6 +339,8 @@ export interface Player {
): Attack;
outgoingAttacks(): Attack[];
incomingAttacks(): Attack[];
+ orderRetreat(attackID: string): void;
+ executeRetreat(attackID: string): void;
// Misc
executions(): Execution[];
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index 4ed5f9ace..88b73aa6b 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -77,6 +77,8 @@ export interface AttackUpdate {
attackerID: number;
targetID: number;
troops: number;
+ id: string;
+ retreating: boolean;
}
export interface PlayerUpdate {
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 9f99f294a..9e3e919fc 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -41,6 +41,8 @@ import { renderTroops } from "../../client/Utils";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { AttackImpl } from "./AttackImpl";
+import { PseudoRandom } from "../PseudoRandom";
+import { consolex } from "../Consolex";
interface Target {
tick: Tick;
@@ -56,6 +58,7 @@ class Donation {
export class PlayerImpl implements Player {
public _lastTileChange: number = 0;
+ public _pseudo_random: PseudoRandom;
private _gold: bigint;
private _troops: bigint;
@@ -103,6 +106,7 @@ export class PlayerImpl implements Player {
this._workers = 0n;
this._gold = 0n;
this._displayName = this._name; // processName(this._name)
+ this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id));
}
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
@@ -139,6 +143,8 @@ export class PlayerImpl implements Player {
attackerID: a.attacker().smallID(),
targetID: a.target().smallID(),
troops: a.troops(),
+ id: a.id(),
+ retreating: a.retreating(),
}) as AttackUpdate,
),
incomingAttacks: this._incomingAttacks.map(
@@ -147,6 +153,8 @@ export class PlayerImpl implements Player {
attackerID: a.attacker().smallID(),
targetID: a.target().smallID(),
troops: a.troops(),
+ id: a.id(),
+ retreating: a.retreating(),
}) as AttackUpdate,
),
outgoingAllianceRequests: outgoingAllianceRequests,
@@ -246,6 +254,22 @@ export class PlayerImpl implements Player {
conquer(tile: TileRef) {
this.mg.conquer(this, tile);
}
+ orderRetreat(id: string) {
+ const attack = this._outgoingAttacks.filter((attack) => attack.id() == id);
+ if (!attack || !attack[0]) {
+ consolex.warn(`Didn't find outgoing attack with id ${id}`);
+ return;
+ }
+ attack[0].orderRetreat();
+ }
+ executeRetreat(id: string): void {
+ const attack = this._outgoingAttacks.filter((attack) => attack.id() == id);
+ // Execution is delayed so it's not an error that the attack does not exist.
+ if (!attack || !attack[0]) {
+ return;
+ }
+ attack[0].executeRetreat();
+ }
relinquish(tile: TileRef) {
if (this.mg.owner(tile) != this) {
throw new Error(`Cannot relinquish tile not owned by this player`);
@@ -855,7 +879,13 @@ export class PlayerImpl implements Player {
troops: number,
sourceTile: TileRef,
): Attack {
- const attack = new AttackImpl(target, this, troops, sourceTile);
+ const attack = new AttackImpl(
+ this._pseudo_random.nextID(),
+ target,
+ this,
+ troops,
+ sourceTile,
+ );
this._outgoingAttacks.push(attack);
if (target.isPlayer()) {
(target as PlayerImpl)._incomingAttacks.push(attack);