Boat retreat (#705)

## Description:
Add boat retreat (continue #365 by
[QuentinSiruguet](https://github.com/openfrontio/OpenFrontIO/issues?q=is%3Apr+author%3AQuentinSiruguet))

Basically implements all the pending reviews from #365


![retreat](https://github.com/user-attachments/assets/d2c34366-89d0-42ed-9aa7-5ab1f833d780)



## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

VivaciousBox

---------

Co-authored-by: Quentin SIRUGUET <quentin.siruguet@gmail.com>
This commit is contained in:
Vivacious Box
2025-05-18 22:34:42 +02:00
committed by GitHub
parent 88d9707f6e
commit 9916f21aab
11 changed files with 135 additions and 11 deletions
+16
View File
@@ -132,6 +132,10 @@ export class CancelAttackIntentEvent implements GameEvent {
) {}
}
export class CancelBoatIntentEvent implements GameEvent {
constructor(public readonly unitID: number) {}
}
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(public readonly ratio: number) {}
}
@@ -221,6 +225,10 @@ export class Transport {
this.eventBus.on(CancelAttackIntentEvent, (e) =>
this.onCancelAttackIntentEvent(e),
);
this.eventBus.on(CancelBoatIntentEvent, (e) =>
this.onCancelBoatIntentEvent(e),
);
this.eventBus.on(MoveWarshipIntentEvent, (e) => {
this.onMoveWarshipEvent(e);
});
@@ -568,6 +576,14 @@ export class Transport {
});
}
private onCancelBoatIntentEvent(event: CancelBoatIntentEvent) {
this.sendIntent({
type: "cancel_boat",
clientID: this.lobbyConfig.clientID,
unitID: event.unitID,
});
}
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
this.sendIntent({
type: "move_warship",
+21 -10
View File
@@ -26,6 +26,7 @@ import {
import { ClientID } from "../../../core/Schemas";
import {
CancelAttackIntentEvent,
CancelBoatIntentEvent,
SendAllianceReplyIntentEvent,
} from "../../Transport";
import { Layer } from "./Layer";
@@ -380,6 +381,12 @@ export class EventsDisplay extends LitElement implements Layer {
this.eventBus.emit(new CancelAttackIntentEvent(myPlayer.id(), id));
}
emitBoatCancelIntent(id: number) {
const myPlayer = this.game.playerByClientID(this.clientID);
if (!myPlayer) return;
this.eventBus.emit(new CancelBoatIntentEvent(id));
}
emitGoToPlayerEvent(attackerID: number) {
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
if (!attacker) return;
@@ -572,25 +579,29 @@ export class EventsDisplay extends LitElement implements Layer {
}
private renderBoats() {
if (this.outgoingBoats.length === 0) {
return html``;
}
return html`
${this.outgoingBoats.length > 0
? html`
<tr class="border-t border-gray-700">
<td
class="lg:p-3 p-1 text-left text-blue-400 grid grid-cols-3 gap-2"
>
<td class="lg:p-3 p-1 text-left text-blue-400">
${this.outgoingBoats.map(
(boats) => html`
(boat) => html`
<button
translate="no"
@click=${() => this.emitGoToUnitEvent(boats)}
@click=${() => this.emitGoToUnitEvent(boat)}
>
Boat: ${renderTroops(boats.troops())}
Boat: ${renderTroops(boat.troops())}
</button>
${!boat.retreating()
? html`<button
${boat.retreating() ? "disabled" : ""}
@click=${() => {
this.emitBoatCancelIntent(boat.id());
}}
>
</button>`
: "(retreating...)"}
`,
)}
</td>
+9
View File
@@ -20,6 +20,7 @@ export type Intent =
| AttackIntent
| CancelAttackIntent
| BoatAttackIntent
| CancelBoatIntent
| AllianceRequestIntent
| AllianceRequestReplyIntent
| BreakAllianceIntent
@@ -37,6 +38,7 @@ 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 CancelBoatIntent = z.infer<typeof CancelBoatIntentSchema>;
export type AllianceRequestIntent = z.infer<typeof AllianceRequestIntentSchema>;
export type AllianceRequestReplyIntent = z.infer<
typeof AllianceRequestReplyIntentSchema
@@ -196,6 +198,7 @@ const BaseIntentSchema = z.object({
"cancel_attack",
"spawn",
"boat",
"cancel_boat",
"name",
"targetPlayer",
"emoji",
@@ -294,6 +297,11 @@ export const CancelAttackIntentSchema = BaseIntentSchema.extend({
attackID: z.string(),
});
export const CancelBoatIntentSchema = BaseIntentSchema.extend({
type: z.literal("cancel_boat"),
unitID: z.number(),
});
export const MoveWarshipIntentSchema = BaseIntentSchema.extend({
type: z.literal("move_warship"),
unitId: z.number(),
@@ -318,6 +326,7 @@ const IntentSchema = z.union([
CancelAttackIntentSchema,
SpawnIntentSchema,
BoatAttackIntentSchema,
CancelBoatIntentSchema,
AllianceRequestIntentSchema,
AllianceRequestReplyIntentSchema,
BreakAllianceIntentSchema,
@@ -0,0 +1,59 @@
import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID, UnitType } from "../game/Game";
export class BoatRetreatExecution implements Execution {
private active = true;
private player: Player | undefined;
constructor(
private playerID: PlayerID,
private unitID: number,
) {}
init(mg: Game, ticks: number): void {
if (!mg.hasPlayer(this.playerID)) {
console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`);
this.active = false;
return;
}
this.player = mg.player(this.playerID);
}
tick(ticks: number): void {
if (!this.player) {
console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`);
this.active = false;
return;
}
const unit = this.player
.units()
.find(
(unit) =>
unit.id() === this.unitID && unit.type() === UnitType.TransportShip,
);
if (!unit) {
consolex.warn(`Didn't find outgoing boat with id ${this.unitID}`);
this.active = false;
return;
}
unit.orderBoatRetreat();
this.active = false;
}
owner(): Player {
if (this.player === undefined) {
throw new Error("Not initialized");
}
return this.player;
}
isActive(): boolean {
return this.active;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+3
View File
@@ -7,6 +7,7 @@ import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution";
import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution";
import { AttackExecution } from "./AttackExecution";
import { BoatRetreatExecution } from "./BoatRetreatExecution";
import { BotSpawner } from "./BotSpawner";
import { ConstructionExecution } from "./ConstructionExecution";
import { DonateGoldExecution } from "./DonateGoldExecution";
@@ -59,6 +60,8 @@ export class Executor {
}
case "cancel_attack":
return new RetreatExecution(playerID, intent.attackID);
case "cancel_boat":
return new BoatRetreatExecution(playerID, intent.unitID);
case "move_warship":
return new MoveWarshipExecution(intent.unitId, intent.tile);
case "spawn":
+1 -1
View File
@@ -15,7 +15,7 @@ export class RetreatExecution implements Execution {
init(mg: Game, ticks: number): void {
if (!mg.hasPlayer(this.playerID)) {
console.warn(`RetreatExecution: player ${this.player.id()} not found`);
console.warn(`RetreatExecution: player ${this.playerID} not found`);
return;
}
this.mg = mg;
@@ -165,6 +165,10 @@ export class TransportShipExecution implements Execution {
}
this.lastMove = ticks;
if (this.boat.retreating()) {
this.dst = this.src!; // src is guaranteed to be set at this point
}
const result = this.pathFinder.nextTile(this.boat.tile(), this.dst);
switch (result.type) {
case PathFindResultType.Completed:
+2
View File
@@ -348,6 +348,8 @@ export interface Unit {
// Health
hasHealth(): boolean;
retreating(): boolean;
orderBoatRetreat(): void;
health(): number;
modifyHealth(delta: number): void;
+1
View File
@@ -73,6 +73,7 @@ export interface UnitUpdate {
pos: TileRef;
lastPos: TileRef;
isActive: boolean;
retreating: boolean;
targetUnitId?: number; // Only for trade ships
targetTile?: TileRef; // Only for nukes
health?: number;
+6
View File
@@ -78,6 +78,12 @@ export class UnitView {
troops(): number {
return this.data.troops;
}
retreating(): boolean {
if (this.type() !== UnitType.TransportShip) {
throw Error("Must be a transport ship");
}
return this.data.retreating;
}
tile(): TileRef {
return this.data.pos;
}
+13
View File
@@ -18,6 +18,7 @@ export class UnitImpl implements Unit {
private _targetUnit: Unit | undefined;
private _health: bigint;
private _lastTile: TileRef;
private _retreating: boolean = false;
private _targetedBySAM = false;
private _lastSetSafeFromPirates: number; // Only for trade ships
private _constructionType: UnitType | undefined;
@@ -73,6 +74,7 @@ export class UnitImpl implements Unit {
ownerID: this._owner.smallID(),
lastOwnerID: this._lastOwner?.smallID(),
isActive: this._active,
retreating: this._retreating,
pos: this._tile,
lastPos: this._lastTile,
health: this.hasHealth() ? Number(this._health) : undefined,
@@ -171,6 +173,17 @@ export class UnitImpl implements Unit {
return this._active;
}
retreating(): boolean {
return this._retreating;
}
orderBoatRetreat() {
if (this.type() !== UnitType.TransportShip) {
throw new Error(`Cannot retreat ${this.type()}`);
}
this._retreating = true;
}
constructionType(): UnitType | null {
if (this.type() !== UnitType.Construction) {
throw new Error(`Cannot get construction type on ${this.type()}`);