diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 64ffdcd4e..591f9d724 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -1,5 +1,5 @@ -import { LitElement, html, css } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { LitElement, css, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; import "./components/Difficulties"; import "./components/Maps"; @@ -236,6 +236,10 @@ export class HelpModal extends LitElement { 1 / 2 Decrease/Increase attack ratio + + Shift + scroll down / scroll up + Decrease/Increase attack ratio + ALT + R Reset graphics diff --git a/src/client/Transport.ts b/src/client/Transport.ts index c4fd5f700..cc6c9daf5 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -100,6 +100,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) {} } @@ -151,6 +159,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), ); @@ -397,6 +408,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/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 8ea615139..e7a8933b9 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 >; @@ -133,6 +135,7 @@ const BaseIntentSchema = z.object({ "emoji", "troop_ratio", "build_unit", + "embargo", ]), clientID: ID, playerID: ID, @@ -196,6 +199,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, @@ -229,6 +239,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 d3896eee0..4cd77f582 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -95,6 +95,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 0ca8e55ee..d6f3744d7 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -62,6 +62,8 @@ export class PlayerImpl implements Player { isTraitor_ = false; + private embargoes: Set = new Set(); + public _borderTiles: Set = new Set(); public _units: UnitImpl[] = []; @@ -123,6 +125,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(), @@ -502,6 +505,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 this._gold; } @@ -588,7 +613,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, @@ -597,6 +627,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 a03524f9e..cda62a23f 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 = (this.mg.unitInfo(_type).maxHealth ?? 2) * 0.6; @@ -141,4 +142,8 @@ export class UnitImpl implements Unit { toString(): string { return `Unit:${this._type},owner:${this.owner().name()}`; } + + dstPort(): Unit { + return this._dstPort; + } }