mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:20:45 +00:00
Merge pull request #117 from ilan-schemoul/cance-attacks
feat: cancel attack
This commit is contained in:
@@ -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,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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface AttackUpdate {
|
||||
attackerID: number;
|
||||
targetID: number;
|
||||
troops: number;
|
||||
id: string;
|
||||
retreating: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerUpdate {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user