Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
70314d0
Add visual troop capacity breakdown bar
binmogit Nov 4, 2025
d6f561f
Fix troop breakdown bar rendering
binmogit Nov 4, 2025
ab45db9
Rename variables for clarity: _territoryMax/_cityMax → _territoryCapa…
binmogit Nov 4, 2025
154a26f
Refactor: extract base capacity methods to avoid formula duplication
binmogit Nov 4, 2025
0763b09
Add error handling and accessibility to troop breakdown bar
binmogit Nov 4, 2025
dbd2c87
Reset _maxTroops in error handler for consistent state
binmogit Nov 4, 2025
9695ee7
Add troops on mission visual indicator to capacity bar
binmogit Nov 5, 2025
b44eca2
Merge main into feature/troops-on-mission
binmogit Nov 5, 2025
dfc90ae
Remove tooltip from troop capacity bar for simplicity
binmogit Nov 5, 2025
fe15279
Use totalUnitLevels() helper method in baseCityCapacity()
binmogit Nov 5, 2025
d23e688
Refactor: Calculate troops on mission directly from PlayerView data
binmogit Nov 5, 2025
86c853d
Apply code review optimizations
binmogit Nov 5, 2025
0b556cb
Apply micro-optimizations and remove verbose comments
binmogit Nov 5, 2025
0c9257a
Remove unnecessary comments from ControlPanel
binmogit Nov 6, 2025
8608dfc
Refactor troop capacity: rename methods and add estimation helpers
binmogit Nov 6, 2025
fb18014
Merge upstream/main into feature/troop-breakdown-visualization-with-m…
binmogit Nov 6, 2025
0e4727c
docs: add JSDoc for fork-introduced symbols
binmogit Nov 6, 2025
67b92c1
Fix capacity estimates for non-human players.
binmogit Nov 6, 2025
098ddca
Merge branch 'main' into feature/troop-breakdown-visualization-with-m…
binmogit Nov 6, 2025
42698de
Merge branch 'main' into feature/troop-breakdown-visualization-with-m…
binmogit Nov 7, 2025
bbe0457
More accurate doc
binmogit Nov 7, 2025
c17c037
Refactor: extract estimateTroopSources helper for estimatedTroopsTerr…
binmogit Nov 7, 2025
0a3ee08
Merge branch 'openfrontio:main' into feature/troop-breakdown-visualiz…
binmogit Nov 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 121 additions & 2 deletions src/client/graphics/layers/ControlPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Gold } from "../../../core/game/Game";
import { Gold, UnitType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { AttackRatioEvent } from "../../InputHandler";
Expand All @@ -23,6 +23,32 @@ export class ControlPanel extends LitElement implements Layer {
@state()
private _maxTroops: number;

/**
* Rounded total maximum troop capacity available to the player.
*/
@state()
private _maxTroopsTerritory: number = 0;

/**
* Rounded maximum troop capacity coming from cities.
*/
@state()
private _maxTroopsCity: number = 0;

/**
* Estimated current troops that belong to territory-derived capacity.
* This value is computed from the config.estimatedTroopsTerritory helper.
*/
@state()
private _troopsTerritoryEstimate: number = 0;

/**
* Estimated current troops that belong to city-derived capacity.
* This value is computed from the config.estimatedTroopsCity helper.
*/
@state()
private _troopsCityEstimate: number = 0;

@state()
private troopRate: number;

Expand All @@ -35,6 +61,12 @@ export class ControlPanel extends LitElement implements Layer {
@state()
private _gold: Gold;

@state()
private _troopsOnMission: number = 0;

@state()
private _playerColor: string = "";

private _troopRateIsIncreasing: boolean = true;

private _lastTroopIncreaseRate: number;
Expand Down Expand Up @@ -85,10 +117,45 @@ export class ControlPanel extends LitElement implements Layer {
this.updateTroopIncrease();
}

this._maxTroops = this.game.config().maxTroops(player);
this._gold = player.gold();
this._troops = player.troops();
this.troopRate = this.game.config().troopIncreaseRate(player) * 10;

const outgoingAttacks = player.outgoingAttacks();

const attackTroops = outgoingAttacks.reduce(
(sum, attack) => sum + attack.troops,
0,
);

const boatTroops = player
.units(UnitType.TransportShip)
.reduce((sum, boat) => sum + boat.troops(), 0);

this._troopsOnMission = attackTroops + boatTroops;

try {
const config = this.game.config();
this._maxTroopsTerritory = Math.round(config.maxTroopsTerritory(player));
this._maxTroopsCity = Math.round(config.maxTroopsCity(player));
this._maxTroops = Math.round(config.maxTroops(player));

// Get estimated breakdown of current troops
this._troopsTerritoryEstimate = config.estimatedTroopsTerritory(player);
this._troopsCityEstimate = config.estimatedTroopsCity(player);
} catch (e) {
console.warn("Failed to calculate capacity breakdown:", e);
this._maxTroopsTerritory = 0;
this._maxTroopsCity = 0;
this._maxTroops = 0;
this._troopsTerritoryEstimate = 0;
this._troopsCityEstimate = 0;
}

this._playerColor =
player.territoryColor()?.toRgbString() ??
this.game.config().theme().neutralColor().toRgbString();

this.requestUpdate();
}

Expand Down Expand Up @@ -119,6 +186,8 @@ export class ControlPanel extends LitElement implements Layer {
}

render() {
const playerColor = this._playerColor;

return html`
<style>
input[type="range"] {
Expand Down Expand Up @@ -181,6 +250,56 @@ export class ControlPanel extends LitElement implements Layer {
></span
>
</div>
<!-- Max troops breakdown bar -->
<div
role="progressbar"
aria-valuenow="${this._troops + this._troopsOnMission}"
aria-valuemin="0"
aria-valuemax="${this._maxTroops}"
aria-label="Troop capacity: ${this._troops} available, ${this
._troopsOnMission} on mission, ${this._maxTroops} maximum"
aria-describedby="troop-capacity-description"
class="h-1 bg-black/50 rounded-full overflow-hidden mt-2 mb-3"
>
<div
class="flex h-full"
style="width: ${this._maxTroops > 0
? Math.min(
((this._troops + this._troopsOnMission) / this._maxTroops) *
100,
100,
)
: 0}%"
>
<!-- Available troops (territory + cities) -->
<div class="flex" style="flex-grow: ${this._troops}">
<div
class="h-full opacity-60"
style="background-color: ${playerColor}; flex-grow: ${this
._troopsTerritoryEstimate}"
></div>
${this._troopsCityEstimate > 0
? html`<div
class="h-full opacity-80"
style="background-color: ${playerColor}; flex-grow: ${this
._troopsCityEstimate}"
></div>`
: ""}
</div>
<!-- Troops on mission -->
${this._troopsOnMission > 0
? html`<div
class="h-full bg-red-600 opacity-50"
style="flex-grow: ${this._troopsOnMission}"
></div>`
: ""}
</div>
</div>
<span id="troop-capacity-description" class="sr-only">
Colored bar segments represent territory capacity (lighter shade)
and city capacity (darker shade). Red segment shows troops currently
on mission.
</span>
<div class="flex justify-between">
<span class="font-bold"
>${translateText("control_panel.gold")}:</span
Expand Down
37 changes: 37 additions & 0 deletions src/core/configuration/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,44 @@ export interface Config {
// When computing likelihood of trading for any given port, the X closest port
// are twice more likely to be selected. X is determined below.
proximityBonusPortsNb(totalPorts: number): number;
/**
* Returns the total maximum troop capacity for the given player.
* This is the sum of all capacity sources (territory + cities) and may be
* adjusted by player type (bot/human) or game difficulty.
* @param player The player or player view to compute capacity for.
* @returns Maximum troop capacity as a number.
*/
maxTroops(player: Player | PlayerView): number;

/**
* Maximum troop capacity derived from territory ownership (tiles).
* This represents how many troops the player's territories can hold.
* @param player The player or player view to compute territory capacity for.
*/
maxTroopsTerritory(player: Player | PlayerView): number;

/**
* Maximum troop capacity derived from cities (unit levels of UnitType.City).
* @param player The player or player view to compute city capacity for.
*/
maxTroopsCity(player: Player | PlayerView): number;

/**
* Helper method to estimate how many of the player's current troops are
* attributable to territory-derived capacity. These are proportional
* estimates (based on capacity ratios) and do not represent exact tracking.
* @param player The player or player view to compute an estimate for.
*/
estimatedTroopsTerritory(player: Player | PlayerView): number;

/**
* Helper method to estimate how many of the player's current troops are
* attributable to city-derived capacity. These are proportional estimates
* (based on capacity ratios) and do not represent exact tracking.
* @param player The player or player view to compute an estimate for.
*/
estimatedTroopsCity(player: Player | PlayerView): number;

cityTroopIncrease(): number;
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number;
shellLifetime(): number;
Expand Down
78 changes: 72 additions & 6 deletions src/core/configuration/DefaultConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,16 +825,82 @@ export class DefaultConfig implements Config {
return this.infiniteTroops() ? 1_000_000 : 25_000;
}

/**
* Compute maximum troop capacity contributed by territory (tiles owned).
* The formula scales with the number of tiles owned and includes a base offset.
* @param player Player or PlayerView used to read tiles owned.
* @returns Territory-derived troop capacity.
*/
maxTroopsTerritory(player: Player | PlayerView): number {
return 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000);
}

/**
* Compute maximum troop capacity contributed by cities.
* Capacity is proportional to the total city unit levels owned by the player.
* @param player Player or PlayerView used to read city unit levels.
* @returns City-derived troop capacity.
*/
maxTroopsCity(player: Player | PlayerView): number {
return player.totalUnitLevels(UnitType.City) * this.cityTroopIncrease();
}

/**
* Estimate how many of the player's current troops are attributable to
* territory-derived capacity. This is a proportional estimate based on the
* ratio of territory capacity to total capacity, multiplied by current troops.
* Note: this is only an estimate and not precise tracking of troop sources.
* @param player Player or PlayerView used to compute the estimate.
*/
estimatedTroopsTerritory(player: Player | PlayerView): number {
const maxTroopsTerritory = this.maxTroopsTerritory(player);
const maxTroopsCity = this.maxTroopsCity(player);

const baseCapacity = maxTroopsTerritory + maxTroopsCity;
if (baseCapacity === 0) return 0;

const currentTroops = player.troops();

// Proportionally attribute current troops based on territory's share of the
// unscaled base capacity (territory + city). This keeps the numerator and
// denominator consistent for all player types.
return Math.round((maxTroopsTerritory / baseCapacity) * currentTroops);
}

/**
* Estimate how many of the player's current troops are attributable to
* city-derived capacity. This is a proportional estimate based on the
* ratio of city capacity to total capacity, multiplied by current troops.
* Note: this is only an estimate and not precise tracking of troop sources.
* @param player Player or PlayerView used to compute the estimate.
*/
estimatedTroopsCity(player: Player | PlayerView): number {
const maxTroopsTerritory = this.maxTroopsTerritory(player);
const maxTroopsCity = this.maxTroopsCity(player);

const baseCapacity = maxTroopsTerritory + maxTroopsCity;
if (baseCapacity === 0) return 0;

const currentTroops = player.troops();

// Proportionally attribute current troops based on cities' share of the
// unscaled base capacity (territory + city). This keeps the numerator and
// denominator consistent for all player types.
return Math.round((maxTroopsCity / baseCapacity) * currentTroops);
}

/**
* Compute the player's effective maximum troop capacity. This sums territory
* and city capacities, applies special handling for infiniteTroops and
* adjusts for player type (bot/human) and game difficulty.
* @param player Player or PlayerView used to compute capacity.
* @returns Effective maximum troop capacity for the player.
*/
maxTroops(player: Player | PlayerView): number {
const maxTroops =
player.type() === PlayerType.Human && this.infiniteTroops()
? 1_000_000_000
: 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
player
.units(UnitType.City)
.map((city) => city.level())
.reduce((a, b) => a + b, 0) *
this.cityTroopIncrease();
: this.maxTroopsTerritory(player) + this.maxTroopsCity(player);

if (player.type() === PlayerType.Bot) {
return maxTroops / 3;
Expand Down
1 change: 1 addition & 0 deletions src/core/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ export interface Player {
unitCount(type: UnitType): number;
unitsConstructed(type: UnitType): number;
unitsOwned(type: UnitType): number;
totalUnitLevels(type: UnitType): number;
buildableUnits(tile: TileRef | null): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit<T extends UnitType>(
Expand Down
6 changes: 6 additions & 0 deletions src/core/game/PlayerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ export class PlayerImpl implements Player {
return total;
}

totalUnitLevels(type: UnitType): number {
return this.units(type)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0);
}

sharesBorderWith(other: Player | TerraNullius): boolean {
for (const border of this._borderTiles) {
for (const neighbor of this.mg.map().neighbors(border)) {
Expand Down
Loading