diff --git a/resources/lang/en.json b/resources/lang/en.json index 222fa60ca..9a4d317dc 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -510,6 +510,8 @@ "left_click_menu": "Left Click Menu", "attack_ratio_label": "⚔️ Attack Ratio", "attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)", + "troop_ratio_label": "Troop/Gold Ratio", + "troop_ratio_desc": "Adjust the balance between troops (combat) and workers (gold production) (1–100%)", "territory_patterns_label": "🏳️ Territory Skins", "territory_patterns_desc": "Choose whether to display territory skin designs in game", "performance_overlay_label": "Performance Overlay", diff --git a/src/client/Transport.ts b/src/client/Transport.ts index d86f0fe82..5324461bf 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -145,6 +145,10 @@ export class CancelBoatIntentEvent implements GameEvent { constructor(public readonly unitID: number) {} } +export class SendSetTargetTroopRatioEvent implements GameEvent { + constructor(public readonly ratio: number) {} +} + export class SendWinnerEvent implements GameEvent { constructor( public readonly winner: Winner, @@ -235,6 +239,9 @@ export class Transport { this.eventBus.on(SendEmbargoAllIntentEvent, (e) => this.onSendEmbargoAllIntent(e), ); + this.eventBus.on(SendSetTargetTroopRatioEvent, (e) => + this.onSendSetTargetTroopRatioEvent(e), + ); this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)); this.eventBus.on(PauseGameIntentEvent, (e) => this.onPauseGameIntent(e)); @@ -552,6 +559,13 @@ export class Transport { }); } + private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) { + this.sendIntent({ + type: "troop_ratio", + ratio: event.ratio, + }); + } + private onBuildUnitIntent(event: BuildUnitIntentEvent) { this.sendIntent({ type: "build_unit", diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 73c0a14e6..36f3bd63e 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -362,6 +362,16 @@ export class UserSettingModal extends BaseModal { } } + private sliderTroopRatio(e: CustomEvent<{ value: number }>) { + const value = e.detail?.value; + if (typeof value === "number") { + const ratio = value / 100; + localStorage.setItem("settings.troopRatio", ratio.toString()); + } else { + console.warn("Slider event missing detail.value", e); + } + } + private changeAttackRatioIncrement( e: CustomEvent<{ value: number | string }>, ) { @@ -915,6 +925,16 @@ export class UserSettingModal extends BaseModal { @change=${this.sliderAttackRatio} > + + { let newAttackRatio = this.attackRatio + event.attackRatio / 100; @@ -78,6 +97,13 @@ export class ControlPanel extends LitElement implements Layer { } tick() { + if (this.initTroopRatio) { + this.eventBus.emit( + new SendSetTargetTroopRatioEvent(this.targetTroopRatio), + ); + this.initTroopRatio = false; + } + if (!this._isVisible && !this.game.inSpawnPhase()) { this.setVisibile(true); } @@ -92,11 +118,14 @@ export class ControlPanel extends LitElement implements Layer { this._maxTroops = this.game.config().maxTroops(player); this._gold = player.gold(); + this._population = player.population(); this._troops = player.troops(); + this._workers = player.workers(); this._attackingTroops = player .outgoingAttacks() .map((a) => a.troops) .reduce((a, b) => a + b, 0); + this.currentTroopRatio = this._troops / Math.max(this._population, 1); this.troopRate = this.game.config().troopIncreaseRate(player) * 10; this.requestUpdate(); } @@ -193,6 +222,13 @@ export class ControlPanel extends LitElement implements Layer { this.onAttackRatioChange(this.attackRatio); } + private handleTroopRatioSliderInput(e: Event) { + const value = Number((e.target as HTMLInputElement).value); + this.targetTroopRatio = value / 100; + localStorage.setItem("settings.troopRatio", this.targetTroopRatio.toString()); + this.eventBus.emit(new SendSetTargetTroopRatioEvent(this.targetTroopRatio)); + } + private renderTroopBar() { const base = Math.max(this._maxTroops, 1); const greenPercentRaw = (this._troops / base) * 100; @@ -342,6 +378,16 @@ export class ControlPanel extends LitElement implements Layer { ` : ""} +
+ + ${renderTroops(this._troops)} troops | ${renderTroops(this._workers)} + workers + + + ${(this.currentTroopRatio * 100).toFixed(0)}% live / + ${(this.targetTroopRatio * 100).toFixed(0)}% target + +
`; } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 86c74292d..8e4708e1c 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -41,6 +41,7 @@ export type Intent = | EmojiIntent | DonateGoldIntent | DonateTroopsIntent + | TargetTroopRatioIntent | BuildUnitIntent | EmbargoIntent | QuickChatIntent @@ -66,6 +67,9 @@ export type TargetPlayerIntent = z.infer; export type EmojiIntent = z.infer; export type DonateGoldIntent = z.infer; export type DonateTroopsIntent = z.infer; +export type TargetTroopRatioIntent = z.infer< + typeof TargetTroopRatioIntentSchema +>; export type EmbargoIntent = z.infer; export type BuildUnitIntent = z.infer; export type UpgradeStructureIntent = z.infer< @@ -358,6 +362,11 @@ export const DonateTroopIntentSchema = z.object({ troops: z.number().nonnegative().nullable(), }); +export const TargetTroopRatioIntentSchema = z.object({ + type: z.literal("troop_ratio"), + ratio: z.number().min(0).max(1), +}); + export const BuildUnitIntentSchema = z.object({ type: z.literal("build_unit"), unit: z.enum(UnitType), @@ -434,6 +443,7 @@ const IntentSchema = z.discriminatedUnion("type", [ EmojiIntentSchema, DonateGoldIntentSchema, DonateTroopIntentSchema, + TargetTroopRatioIntentSchema, BuildUnitIntentSchema, UpgradeStructureIntentSchema, EmbargoIntentSchema, diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 55fbab613..2b4e87c48 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -84,6 +84,7 @@ export interface Config { startManpower(playerInfo: PlayerInfo): number; troopIncreaseRate(player: Player | PlayerView): number; goldAdditionRate(player: Player | PlayerView): Gold; + troopAdjustmentRate(player: Player): number; attackTilesPerTick( attckTroops: number, attacker: Player, diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index f47d5e793..59846cee8 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -777,9 +777,9 @@ export class DefaultConfig implements Config { troopIncreaseRate(player: Player): number { const max = this.maxTroops(player); - let toAdd = 10 + Math.pow(player.troops(), 0.73) / 4; + let toAdd = 10 + Math.pow(player.population(), 0.73) / 4; - const ratio = 1 - player.troops() / max; + const ratio = 1 - player.population() / max; toAdd *= ratio; if (player.type() === PlayerType.Bot) { @@ -805,18 +805,31 @@ export class DefaultConfig implements Config { } } - return Math.min(player.troops() + toAdd, max) - player.troops(); + return Math.min(player.population() + toAdd, max) - player.population(); } goldAdditionRate(player: Player): Gold { const multiplier = this.goldMultiplier(); - let baseRate: bigint; + let baseRate = 0.045 * player.workers() ** 0.7; if (player.type() === PlayerType.Bot) { - baseRate = 50n; - } else { - baseRate = 100n; + baseRate *= 0.6; } - return BigInt(Math.floor(Number(baseRate) * multiplier)); + return BigInt(Math.floor(baseRate * multiplier)); + } + + troopAdjustmentRate(player: Player): number { + const maxDiff = this.maxTroops(player) / 1000; + const target = player.population() * player.targetTroopRatio(); + const diff = target - player.troops(); + if (Math.abs(diff) < maxDiff) { + return diff; + } + const adjustment = maxDiff * Math.sign(diff); + // Can ramp down troops much faster. + if (adjustment < 0) { + return adjustment * 5; + } + return adjustment; } nukeMagnitudes(unitType: UnitType): NukeMagnitude { diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index d50aaadb7..665023fe6 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -23,6 +23,7 @@ import { NoOpExecution } from "./NoOpExecution"; import { PauseExecution } from "./PauseExecution"; import { QuickChatExecution } from "./QuickChatExecution"; import { RetreatExecution } from "./RetreatExecution"; +import { SetTargetTroopRatioExecution } from "./SetTargetTroopRatioExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TargetPlayerExecution } from "./TargetPlayerExecution"; import { TransportShipExecution } from "./TransportShipExecution"; @@ -91,6 +92,8 @@ export class Executor { ); case "donate_gold": return new DonateGoldExecution(player, intent.recipient, intent.gold); + case "troop_ratio": + return new SetTargetTroopRatioExecution(player, intent.ratio); case "embargo": return new EmbargoExecution(player, intent.targetID, intent.action); case "embargo_all": diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 55d2eb062..527ec9902 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -73,14 +73,19 @@ export class PlayerExecution implements Execution { return; } - const troopInc = this.config.troopIncreaseRate(this.player); - this.player.addTroops(troopInc); + const popInc = this.config.troopIncreaseRate(this.player); + this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio())); + this.player.addTroops(popInc * this.player.targetTroopRatio()); const goldFromWorkers = this.config.goldAdditionRate(this.player); this.player.addGold(goldFromWorkers); // Record stats this.mg.stats().goldWork(this.player, goldFromWorkers); + const adjustRate = this.config.troopAdjustmentRate(this.player); + this.player.addTroops(adjustRate); + this.player.removeWorkers(adjustRate); + for (const alliance of this.player.alliances()) { if (alliance.expiresAt() <= this.mg.ticks()) { alliance.expire(); diff --git a/src/core/execution/SetTargetTroopRatioExecution.ts b/src/core/execution/SetTargetTroopRatioExecution.ts new file mode 100644 index 000000000..d43834003 --- /dev/null +++ b/src/core/execution/SetTargetTroopRatioExecution.ts @@ -0,0 +1,31 @@ +import { Execution, Game, Player } from "../game/Game"; + +export class SetTargetTroopRatioExecution implements Execution { + private active = true; + + constructor( + private player: Player, + private targetTroopsRatio: number, + ) {} + + init(mg: Game, ticks: number): void {} + + tick(ticks: number): void { + if (this.targetTroopsRatio < 0 || this.targetTroopsRatio > 1) { + console.warn( + `target troop ratio of ${this.targetTroopsRatio} for player ${this.player} invalid`, + ); + } else { + this.player.setTargetTroopRatio(this.targetTroopsRatio); + } + this.active = false; + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index c44705d37..92a0d3b8a 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -627,7 +627,13 @@ export interface Player { gold(): Gold; addGold(toAdd: Gold, tile?: TileRef): void; removeGold(toRemove: Gold): Gold; + population(): number; + workers(): number; troops(): number; + targetTroopRatio(): number; + addWorkers(toAdd: number): void; + removeWorkers(toRemove: number): void; + setTargetTroopRatio(target: number): void; setTroops(troops: number): void; addTroops(troops: number): void; removeTroops(troops: number): number; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index a85912bda..07d1e41b6 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -173,7 +173,10 @@ export interface PlayerUpdate { isDisconnected: boolean; tilesOwned: number; gold: Gold; + population: number; + workers: number; troops: number; + targetTroopRatio: number; allies: number[]; embargoes: Set; isTraitor: boolean; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index f08b8178b..07b58be07 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -502,6 +502,15 @@ export class PlayerView { gold(): Gold { return this.data.gold; } + population(): number { + return this.data.population; + } + workers(): number { + return this.data.workers; + } + targetTroopRatio(): number { + return this.data.targetTroopRatio; + } troops(): number { return this.data.troops; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index cd8ebc05a..27e18b430 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -4,6 +4,7 @@ import { ClientID } from "../Schemas"; import { assertNever, distSortUnit, + maxInt, minInt, simpleHash, toInt, @@ -72,6 +73,9 @@ export class PlayerImpl implements Player { private _gold: bigint; private _troops: bigint; + private _workers: bigint; + // Stored as percentage, 0 to 100. + private _targetTroopRatio: bigint; markedTraitorTick = -1; private _betrayalCount: number = 0; @@ -115,7 +119,9 @@ export class PlayerImpl implements Player { private readonly _team: Team | null, ) { this._name = playerInfo.name; + this._targetTroopRatio = 95n; this._troops = toInt(startTroops); + this._workers = 0n; this._gold = mg.config().startingGold(playerInfo); this._displayName = this._name; this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id)); @@ -141,7 +147,10 @@ export class PlayerImpl implements Player { isDisconnected: this.isDisconnected(), tilesOwned: this.numTilesOwned(), gold: this._gold, + population: this.population(), + workers: this.workers(), troops: this.troops(), + targetTroopRatio: this.targetTroopRatio(), allies: this.alliances().map((a) => a.other(this).smallID()), embargoes: new Set([...this.embargoes.keys()].map((p) => p.toString())), isTraitor: this.isTraitor(), @@ -930,6 +939,35 @@ export class PlayerImpl implements Player { return actualRemoved; } + population(): number { + return Number(this._troops + this._workers); + } + + workers(): number { + return Math.max(1, Number(this._workers)); + } + + addWorkers(toAdd: number): void { + this._workers += toInt(toAdd); + } + + removeWorkers(toRemove: number): void { + this._workers = maxInt(1n, this._workers - toInt(toRemove)); + } + + targetTroopRatio(): number { + return Number(this._targetTroopRatio) / 100; + } + + setTargetTroopRatio(target: number): void { + if (target < 0 || target > 1) { + throw new Error( + `invalid targetTroopRatio ${target} set on player ${PlayerImpl}`, + ); + } + this._targetTroopRatio = toInt(target * 100); + } + troops(): number { return Number(this._troops); } @@ -1276,7 +1314,7 @@ export class PlayerImpl implements Player { hash(): number { return ( - simpleHash(this.id()) * (this.troops() + this.numTilesOwned()) + + simpleHash(this.id()) * (this.population() + this.numTilesOwned()) + this._units.reduce((acc, unit) => acc + unit.hash(), 0) ); }