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);