diff --git a/resources/images/EmbargoIcon.svg b/resources/images/EmbargoIcon.svg
new file mode 100755
index 000000000..62e41ad6b
--- /dev/null
+++ b/resources/images/EmbargoIcon.svg
@@ -0,0 +1,29 @@
+
+
+
diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index 87b64d149..a1dcffd05 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -102,6 +102,14 @@ export class SendDonateIntentEvent implements GameEvent {
) {}
}
+export class SendEmbargoIntentEvent implements GameEvent {
+ constructor(
+ public readonly sender: PlayerView,
+ public readonly target: PlayerView,
+ public readonly action: "start" | "stop",
+ ) {}
+}
+
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(public readonly ratio: number) {}
}
@@ -159,6 +167,9 @@ export class Transport {
);
this.eventBus.on(SendEmojiIntentEvent, (e) => this.onSendEmojiIntent(e));
this.eventBus.on(SendDonateIntentEvent, (e) => this.onSendDonateIntent(e));
+ this.eventBus.on(SendEmbargoIntentEvent, (e) =>
+ this.onSendEmbargoIntent(e),
+ );
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) =>
this.onSendSetTargetTroopRatioEvent(e),
);
@@ -409,6 +420,16 @@ export class Transport {
});
}
+ private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
+ this.sendIntent({
+ type: "embargo",
+ clientID: this.lobbyConfig.clientID,
+ playerID: this.lobbyConfig.playerID,
+ targetID: event.target.id(),
+ action: event.action,
+ });
+ }
+
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts
index 1b9013176..49b3f4f49 100644
--- a/src/client/graphics/layers/NameLayer.ts
+++ b/src/client/graphics/layers/NameLayer.ts
@@ -14,6 +14,7 @@ import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
import allianceRequestIcon from "../../../../resources/images/AllianceRequestIcon.svg";
import crownIcon from "../../../../resources/images/CrownIcon.svg";
import targetIcon from "../../../../resources/images/TargetIcon.svg";
+import embargoIcon from "../../../../resources/images/EmbargoIcon.svg";
import { ClientID } from "../../../core/Schemas";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { createCanvas, renderTroops } from "../../Utils";
@@ -45,6 +46,7 @@ export class NameLayer implements Layer {
private allianceIconImage: HTMLImageElement;
private targetIconImage: HTMLImageElement;
private crownIconImage: HTMLImageElement;
+ private embargoIconImage: HTMLImageElement;
private container: HTMLDivElement;
private myPlayer: PlayerView | null = null;
private firstPlace: PlayerView | null = null;
@@ -65,6 +67,8 @@ export class NameLayer implements Layer {
this.crownIconImage.src = crownIcon;
this.targetIconImage = new Image();
this.targetIconImage.src = targetIcon;
+ this.embargoIconImage = new Image();
+ this.embargoIconImage.src = embargoIcon;
}
resizeCanvas() {
@@ -381,6 +385,24 @@ export class NameLayer implements Layer {
existingEmoji.remove();
}
+ const existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]');
+ const hasEmbargo =
+ render.player.hasEmbargoAgainst(myPlayer) ||
+ myPlayer.hasEmbargoAgainst(render.player);
+ if (myPlayer && hasEmbargo) {
+ if (!existingEmbargo) {
+ iconsDiv.appendChild(
+ this.createIconElement(
+ this.embargoIconImage.src,
+ iconSize,
+ "embargo",
+ ),
+ );
+ }
+ } else if (existingEmbargo) {
+ existingEmbargo.remove();
+ }
+
// Update all icon sizes
const icons = iconsDiv.getElementsByTagName("img");
for (const icon of icons) {
diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts
index bfd0c4c19..50d4ffc76 100644
--- a/src/client/graphics/layers/PlayerPanel.ts
+++ b/src/client/graphics/layers/PlayerPanel.ts
@@ -18,6 +18,7 @@ import {
SendDonateIntentEvent,
SendEmojiIntentEvent,
SendTargetPlayerIntentEvent,
+ SendEmbargoIntentEvent,
} from "../../Transport";
import { EmojiTable } from "./EmojiTable";
@@ -76,6 +77,26 @@ export class PlayerPanel extends LitElement implements Layer {
this.hide();
}
+ private handleEmbargoClick(
+ e: Event,
+ myPlayer: PlayerView,
+ other: PlayerView,
+ ) {
+ e.stopPropagation();
+ this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "start"));
+ this.hide();
+ }
+
+ private handleStopEmbargoClick(
+ e: Event,
+ myPlayer: PlayerView,
+ other: PlayerView,
+ ) {
+ e.stopPropagation();
+ this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "stop"));
+ this.hide();
+ }
+
private handleEmojiClick(e: Event, myPlayer: PlayerView, other: PlayerView) {
e.stopPropagation();
this.emojiTable.showTable((emoji: string) => {
@@ -131,6 +152,7 @@ export class PlayerPanel extends LitElement implements Layer {
: this.actions.interaction?.canSendEmoji;
const canBreakAlliance = this.actions.interaction?.canBreakAlliance;
const canTarget = this.actions.interaction?.canTarget;
+ const canEmbargo = this.actions.interaction?.canEmbargo;
return html`
+
+
+ Embargo against you
+
+
+ ${other.hasEmbargoAgainst(myPlayer) ? "Yes" : "No"}
+
+
+
${canTarget
@@ -249,6 +280,27 @@ export class PlayerPanel extends LitElement implements Layer {
`
: ""}
+ ${canEmbargo && other != myPlayer
+ ? html``
+ : ""}
+ ${!canEmbargo && other != myPlayer
+ ? html``
+ : ""}
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 56c0e7e65..469122381 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -167,6 +167,7 @@ export class GameRunner {
canSendAllianceRequest: player.canSendAllianceRequest(other),
canBreakAlliance: player.isAlliedWith(other),
canDonate: player.canDonate(other),
+ canEmbargo: !player.hasEmbargoAgainst(other),
};
}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index a190457fa..bf6aff0ef 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -22,7 +22,8 @@ export type Intent =
| EmojiIntent
| DonateIntent
| TargetTroopRatioIntent
- | BuildUnitIntent;
+ | BuildUnitIntent
+ | EmbargoIntent;
export type AttackIntent = z.infer;
export type SpawnIntent = z.infer;
@@ -35,6 +36,7 @@ export type BreakAllianceIntent = z.infer;
export type TargetPlayerIntent = z.infer;
export type EmojiIntent = z.infer;
export type DonateIntent = z.infer;
+export type EmbargoIntent = z.infer;
export type TargetTroopRatioIntent = z.infer<
typeof TargetTroopRatioIntentSchema
>;
@@ -139,6 +141,7 @@ const BaseIntentSchema = z.object({
"emoji",
"troop_ratio",
"build_unit",
+ "embargo",
]),
clientID: ID,
playerID: ID,
@@ -202,6 +205,13 @@ export const EmojiIntentSchema = BaseIntentSchema.extend({
emoji: EmojiSchema,
});
+export const EmbargoIntentSchema = BaseIntentSchema.extend({
+ type: z.literal("embargo"),
+ playerID: ID,
+ targetID: ID,
+ action: z.union([z.literal("start"), z.literal("stop")]),
+});
+
export const DonateIntentSchema = BaseIntentSchema.extend({
type: z.literal("donate"),
playerID: ID,
@@ -235,6 +245,7 @@ const IntentSchema = z.union([
DonateIntentSchema,
TargetTroopRatioIntentSchema,
BuildUnitIntentSchema,
+ EmbargoIntentSchema,
]);
export const TurnSchema = z.object({
diff --git a/src/core/execution/EmbargoExecution.ts b/src/core/execution/EmbargoExecution.ts
new file mode 100644
index 000000000..a37fe20f6
--- /dev/null
+++ b/src/core/execution/EmbargoExecution.ts
@@ -0,0 +1,44 @@
+import { consolex } from "../Consolex";
+import { Execution, Game, Player, PlayerID } from "../game/Game";
+
+export class EmbargoExecution implements Execution {
+ private active = true;
+
+ constructor(
+ private player: Player,
+ private targetID: PlayerID,
+ private readonly action: "start" | "stop",
+ ) {}
+
+ init(mg: Game, _: number): void {
+ if (!mg.hasPlayer(this.player.id())) {
+ console.warn(`EmbargoExecution: sender ${this.player.id()} not found`);
+ this.active = false;
+ return;
+ }
+ if (!mg.hasPlayer(this.targetID)) {
+ console.warn(`EmbargoExecution recipient ${this.targetID} not found`);
+ this.active = false;
+ return;
+ }
+ }
+
+ tick(_: number): void {
+ if (this.action == "start") this.player.addEmbargo(this.targetID);
+ else this.player.stopEmbargo(this.targetID);
+
+ this.active = false;
+ }
+
+ owner(): Player {
+ return null;
+ }
+
+ isActive(): boolean {
+ return this.active;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+}
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index e876c9cac..c6b84e850 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -34,6 +34,7 @@ import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution";
import { ConstructionExecution } from "./ConstructionExecution";
import { fixProfaneUsername, isProfaneUsername } from "../validations/username";
import { NoOpExecution } from "./NoOpExecution";
+import { EmbargoExecution } from "./EmbargoExecution";
export class Executor {
// private random = new PseudoRandom(999)
@@ -53,6 +54,7 @@ export class Executor {
}
createExec(intent: Intent): Execution {
+ let player: Player;
if (intent.type != "spawn") {
if (!this.mg.hasPlayer(intent.playerID)) {
console.warn(
@@ -60,7 +62,7 @@ export class Executor {
);
return new NoOpExecution();
}
- const player = this.mg.player(intent.playerID);
+ player = this.mg.player(intent.playerID);
if (player.clientID() != intent.clientID) {
console.warn(
`intent ${intent.type} has incorrect clientID ${intent.clientID} for player ${player.name()} with clientID ${player.clientID()}`,
@@ -68,6 +70,7 @@ export class Executor {
return new NoOpExecution();
}
}
+
switch (intent.type) {
case "attack": {
return new AttackExecution(
@@ -124,6 +127,8 @@ export class Executor {
);
case "troop_ratio":
return new SetTargetTroopRatioExecution(intent.playerID, intent.ratio);
+ case "embargo":
+ return new EmbargoExecution(player, intent.targetID, intent.action);
case "build_unit":
return new ConstructionExecution(
intent.playerID,
diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts
index 18053299a..562f03450 100644
--- a/src/core/execution/PortExecution.ts
+++ b/src/core/execution/PortExecution.ts
@@ -69,22 +69,20 @@ export class PortExecution implements Execution {
return;
}
- const alliedPorts = this.player()
- .alliances()
- .map((a) => a.other(this.player()))
+ const tradingPartnersPorts = this.player()
+ .tradingPartners()
.flatMap((p) => p.units(UnitType.Port));
- const alliedPortsSet = new Set(alliedPorts);
+ const tradingPartnersPortsSet = new Set(tradingPartnersPorts);
- const allyConnections = new Set(
+ const tradingPartnersConnections = new Set(
Array.from(this.portPaths.keys()).map((p) => p.owner()),
);
- allyConnections;
- for (const port of alliedPorts) {
- if (allyConnections.has(port.owner())) {
+ for (const port of tradingPartnersPorts) {
+ if (tradingPartnersConnections.has(port.owner())) {
continue;
}
- allyConnections.add(port.owner());
+ tradingPartnersConnections.add(port.owner());
if (this.computingPaths.has(port)) {
const aStar = this.computingPaths.get(port);
switch (aStar.compute()) {
@@ -114,7 +112,7 @@ export class PortExecution implements Execution {
}
for (const port of this.portPaths.keys()) {
- if (!port.isActive() || !alliedPortsSet.has(port)) {
+ if (!port.isActive() || !tradingPartnersPortsSet.has(port)) {
this.portPaths.delete(port);
this.computingPaths.delete(port);
}
diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts
index 022aa30e7..43bb70b10 100644
--- a/src/core/execution/TradeShipExecution.ts
+++ b/src/core/execution/TradeShipExecution.ts
@@ -27,7 +27,7 @@ export class TradeShipExecution implements Execution {
constructor(
private _owner: PlayerID,
private srcPort: Unit,
- private dstPort: Unit,
+ private _dstPort: Unit,
private pathFinder: PathFinder,
// don't modify
private path: TileRef[],
@@ -49,7 +49,12 @@ export class TradeShipExecution implements Execution {
this.active = false;
return;
}
- this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn);
+ this.tradeShip = this.origOwner.buildUnit(
+ UnitType.TradeShip,
+ 0,
+ spawn,
+ this._dstPort,
+ );
}
if (!this.tradeShip.isActive()) {
@@ -64,8 +69,8 @@ export class TradeShipExecution implements Execution {
if (
!this.wasCaptured &&
- (!this.dstPort.isActive() ||
- !this.tradeShip.owner().isAlliedWith(this.dstPort.owner()))
+ (!this._dstPort.isActive() ||
+ !this.tradeShip.owner().canTrade(this._dstPort.owner()))
) {
this.tradeShip.delete(false);
this.active = false;
@@ -122,17 +127,17 @@ export class TradeShipExecution implements Execution {
const gold = this.mg
.config()
.tradeShipGold(
- this.mg.manhattanDist(this.srcPort.tile(), this.dstPort.tile()),
+ this.mg.manhattanDist(this.srcPort.tile(), this._dstPort.tile()),
);
this.srcPort.owner().addGold(gold);
- this.dstPort.owner().addGold(gold);
+ this._dstPort.owner().addGold(gold);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`,
MessageType.SUCCESS,
- this.dstPort.owner().id(),
+ this._dstPort.owner().id(),
);
this.mg.displayMessage(
- `Received ${renderNumber(gold)} gold from trade with ${this.dstPort.owner().displayName()}`,
+ `Received ${renderNumber(gold)} gold from trade with ${this._dstPort.owner().displayName()}`,
MessageType.SUCCESS,
this.srcPort.owner().id(),
);
@@ -154,4 +159,8 @@ export class TradeShipExecution implements Execution {
activeDuringSpawnPhase(): boolean {
return false;
}
+
+ dstPort(): TileRef {
+ return this._dstPort.tile();
+ }
}
diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts
index 59c29b04f..088e96cfb 100644
--- a/src/core/execution/WarshipExecution.ts
+++ b/src/core/execution/WarshipExecution.ts
@@ -78,7 +78,11 @@ export class WarshipExecution implements Execution {
.filter((u) => u.owner() != this.warship.owner())
.filter((u) => u != this.warship)
.filter((u) => !u.owner().isAlliedWith(this.warship.owner()))
- .filter((u) => !this.alreadySentShell.has(u));
+ .filter((u) => !this.alreadySentShell.has(u))
+ .filter(
+ (u) =>
+ u.type() != UnitType.TradeShip || u.dstPort().owner() != this.owner(),
+ );
this.target =
ships.sort((a, b) => {
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index e6d0819ce..c61aa153c 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -214,6 +214,9 @@ export interface Unit {
// Updates
toUpdate(): UnitUpdate;
+
+ // Only for some types, otherwise return null
+ dstPort(): Unit;
}
export interface TerraNullius {
@@ -267,7 +270,12 @@ export interface Player {
units(...types: UnitType[]): Unit[];
unitsIncludingConstruction(type: UnitType): Unit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
- buildUnit(type: UnitType, troops: number, tile: TileRef): Unit;
+ buildUnit(
+ type: UnitType,
+ troops: number,
+ tile: TileRef,
+ dstPort?: Unit,
+ ): Unit;
captureUnit(unit: Unit): void;
// Relations & Diplomacy
@@ -300,10 +308,17 @@ export interface Player {
outgoingEmojis(): EmojiMessage[];
sendEmoji(recipient: Player | typeof AllPlayers, emoji: string): void;
- // Trading
+ // Donation
canDonate(recipient: Player): boolean;
donate(recipient: Player, troops: number): void;
+ // Embargo
+ hasEmbargoAgainst(other: Player): boolean;
+ tradingPartners(): Player[];
+ addEmbargo(other: PlayerID): void;
+ stopEmbargo(other: PlayerID): void;
+ canTrade(other: Player): boolean;
+
// Attacking.
canAttack(tile: TileRef): boolean;
createAttack(
@@ -392,6 +407,7 @@ export interface PlayerInteraction {
canBreakAlliance: boolean;
canTarget: boolean;
canDonate: boolean;
+ canEmbargo: boolean;
}
export interface EmojiMessage {
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index 5c617f0e1..4ed5f9ace 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -97,6 +97,7 @@ export interface PlayerUpdate {
troops: number;
targetTroopRatio: number;
allies: number[];
+ embargoes: Set;
isTraitor: boolean;
targets: number[];
outgoingEmojis: EmojiMessage[];
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index ad1e9622a..b7e24a07d 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -191,6 +191,10 @@ export class PlayerView {
return this.data.outgoingAllianceRequests.some((id) => other.id() == id);
}
+ hasEmbargoAgainst(other: PlayerView): boolean {
+ return this.data.embargoes.has(other.id());
+ }
+
profile(): Promise {
return this.game.worker.playerProfile(this.smallID());
}
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 1c5801512..88fb686b9 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -66,6 +66,8 @@ export class PlayerImpl implements Player {
isTraitor_ = false;
+ private embargoes: Set = new Set();
+
public _borderTiles: Set = new Set();
public _units: UnitImpl[] = [];
@@ -127,6 +129,7 @@ export class PlayerImpl implements Player {
troops: this.troops(),
targetTroopRatio: this.targetTroopRatio(),
allies: this.alliances().map((a) => a.other(this).smallID()),
+ embargoes: this.embargoes,
isTraitor: this.isTraitor(),
targets: this.targets().map((p) => p.smallID()),
outgoingEmojis: this.outgoingEmojis(),
@@ -506,6 +509,28 @@ export class PlayerImpl implements Player {
);
}
+ hasEmbargoAgainst(other: Player): boolean {
+ return this.embargoes.has(other.id());
+ }
+
+ canTrade(other: Player): boolean {
+ return !other.hasEmbargoAgainst(this) && !this.hasEmbargoAgainst(other);
+ }
+
+ addEmbargo(other: PlayerID): void {
+ this.embargoes.add(other);
+ }
+
+ stopEmbargo(other: PlayerID): void {
+ this.embargoes.delete(other);
+ }
+
+ tradingPartners(): Player[] {
+ return this.mg
+ .players()
+ .filter((other) => other != this && this.canTrade(other));
+ }
+
gold(): Gold {
return Number(this._gold);
}
@@ -592,7 +617,12 @@ export class PlayerImpl implements Player {
);
}
- buildUnit(type: UnitType, troops: number, spawnTile: TileRef): UnitImpl {
+ buildUnit(
+ type: UnitType,
+ troops: number,
+ spawnTile: TileRef,
+ dstPort?: Unit,
+ ): UnitImpl {
const cost = this.mg.unitInfo(type).cost(this);
const b = new UnitImpl(
type,
@@ -601,6 +631,7 @@ export class PlayerImpl implements Player {
troops,
this.mg.nextUnitID(),
this,
+ dstPort,
);
this._units.push(b);
this.removeGold(cost);
diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts
index b6a925a4a..564557148 100644
--- a/src/core/game/UnitImpl.ts
+++ b/src/core/game/UnitImpl.ts
@@ -21,6 +21,7 @@ export class UnitImpl implements Unit {
private _troops: number,
private _id: number,
public _owner: PlayerImpl,
+ private _dstPort?: Unit,
) {
// default to 60% health (or 1.2 is no health specified)
this._health = toInt((this.mg.unitInfo(_type).maxHealth ?? 2) * 0.6);
@@ -145,4 +146,8 @@ export class UnitImpl implements Unit {
toString(): string {
return `Unit:${this._type},owner:${this.owner().name()}`;
}
+
+ dstPort(): Unit {
+ return this._dstPort;
+ }
}