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; + } }