Merge pull request #117 from ilan-schemoul/cance-attacks

feat: cancel attack
This commit is contained in:
evanpelle
2025-03-04 05:20:01 -08:00
committed by GitHub
10 changed files with 181 additions and 8 deletions
+19
View File
@@ -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({
+20 -1
View File
@@ -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`<button
${attack.retreating ? "disabled" : ""}
@click=${() => {
this.emitCancelAttackIntent(attack.id);
}}
>
</button>`
: "(retreating...)"}
</div>
`,
)}
+10
View File
@@ -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<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
export type BoatAttackIntent = z.infer<typeof BoatAttackIntentSchema>;
export type AllianceRequestIntent = z.infer<typeof AllianceRequestIntentSchema>;
@@ -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,
+16 -6
View File
@@ -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;
}
+3
View File
@@ -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(
+50
View File
@@ -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;
}
}
+23
View File
@@ -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;
}
}
+7
View File
@@ -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[];
+2
View File
@@ -77,6 +77,8 @@ export interface AttackUpdate {
attackerID: number;
targetID: number;
troops: number;
id: string;
retreating: boolean;
}
export interface PlayerUpdate {
+31 -1
View File
@@ -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);