Merge pull request #95 from ilan-schemoul/embargoes

feat: everyone trade with everyone, anyone can embargo a player to stop trade
This commit is contained in:
evanpelle
2025-03-02 10:58:05 -08:00
committed by GitHub
16 changed files with 277 additions and 24 deletions
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2285 4675 c-640 -84 -1211 -457 -1551 -1015 -278 -456 -372 -1020
-258 -1550 86 -400 284 -760 579 -1055 351 -352 787 -560 1284 -615 171 -19
442 -8 615 24 423 80 802 282 1111 591 407 407 625 931 625 1505 0 574 -218
1098 -625 1505 -348 349 -786 561 -1270 615 -122 13 -387 11 -510 -5z m574
-436 c375 -64 738 -269 989 -560 504 -583 557 -1420 129 -2067 -33 -50 -63
-92 -67 -92 -3 0 -68 63 -145 140 -77 77 -144 140 -150 140 -13 0 -295 -282
-295 -295 0 -6 63 -73 140 -150 77 -77 140 -142 140 -145 0 -12 -195 -134
-289 -181 -399 -198 -882 -229 -1302 -83 -595 206 -1020 703 -1130 1320 -30
166 -30 422 -1 585 48 265 145 487 324 744 4 6 68 -50 152 -134 l145 -145 22
20 c57 49 279 272 279 280 0 5 -64 73 -142 152 -83 83 -139 146 -133 150 297
207 556 308 878 342 113 12 320 2 456 -21z"/>
<path d="M2350 3715 l0 -126 -45 -19 c-128 -55 -251 -173 -315 -303 -143 -291
-60 -569 230 -764 25 -17 132 -76 238 -131 195 -102 284 -159 302 -192 28 -52
1 -148 -57 -202 -58 -55 -77 -58 -388 -58 l-285 0 0 -215 0 -215 160 0 160 0
0 -105 0 -105 210 0 210 0 0 125 0 126 45 19 c232 100 398 365 382 610 -12
179 -112 332 -299 458 -25 18 -132 76 -237 131 -193 100 -283 158 -301 191
-28 52 -1 148 57 202 58 55 77 58 388 58 l285 0 0 215 0 215 -160 0 -160 0 0
105 0 105 -210 0 -210 0 0 -125z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+21
View File
@@ -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",
+22
View File
@@ -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) {
+52
View File
@@ -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`
<div
@@ -190,6 +212,15 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Embargo against you
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.hasEmbargoAgainst(myPlayer) ? "Yes" : "No"}
</div>
</div>
<!-- Action buttons -->
<div class="flex justify-center gap-2">
${canTarget
@@ -249,6 +280,27 @@ export class PlayerPanel extends LitElement implements Layer {
</button>`
: ""}
</div>
${canEmbargo && other != myPlayer
? html`<button
@click=${(e) => this.handleEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Start embargo
</button>`
: ""}
${!canEmbargo && other != myPlayer
? html`<button
@click=${(e) =>
this.handleStopEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Stop embargo
</button>`
: ""}
</div>
</div>
</div>
+1
View File
@@ -167,6 +167,7 @@ export class GameRunner {
canSendAllianceRequest: player.canSendAllianceRequest(other),
canBreakAlliance: player.isAlliedWith(other),
canDonate: player.canDonate(other),
canEmbargo: !player.hasEmbargoAgainst(other),
};
}
+12 -1
View File
@@ -22,7 +22,8 @@ export type Intent =
| EmojiIntent
| DonateIntent
| TargetTroopRatioIntent
| BuildUnitIntent;
| BuildUnitIntent
| EmbargoIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
@@ -35,6 +36,7 @@ export type BreakAllianceIntent = z.infer<typeof BreakAllianceIntentSchema>;
export type TargetPlayerIntent = z.infer<typeof TargetPlayerIntentSchema>;
export type EmojiIntent = z.infer<typeof EmojiIntentSchema>;
export type DonateIntent = z.infer<typeof DonateIntentSchema>;
export type EmbargoIntent = z.infer<typeof EmbargoIntentSchema>;
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({
+44
View File
@@ -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;
}
}
+6 -1
View File
@@ -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,
+8 -10
View File
@@ -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);
}
+17 -8
View File
@@ -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();
}
}
+5 -1
View File
@@ -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) => {
+18 -2
View File
@@ -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 {
+1
View File
@@ -97,6 +97,7 @@ export interface PlayerUpdate {
troops: number;
targetTroopRatio: number;
allies: number[];
embargoes: Set<PlayerID>;
isTraitor: boolean;
targets: number[];
outgoingEmojis: EmojiMessage[];
+4
View File
@@ -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<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID());
}
+32 -1
View File
@@ -66,6 +66,8 @@ export class PlayerImpl implements Player {
isTraitor_ = false;
private embargoes: Set<PlayerID> = new Set();
public _borderTiles: Set<TileRef> = 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);
+5
View File
@@ -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;
}
}