Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 25 additions & 2 deletions src/core/configuration/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,13 +231,31 @@ const OVERTIME_DEFAULTS = {

export class Config {
private unitInfoCache = new Map<UnitType, UnitInfo>();
private _factoryMultCache = new Float64Array(256);
private _stationMultCache = new Float64Array(256);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(
private _gameConfig: GameConfig,
private _userSettings: UserSettings | null,
private _isReplay: boolean,
public readonly listed: boolean = false,
private _spectator: boolean = false,
) {}
) {
for (let level = 1; level < 256; level++) {
this._factoryMultCache[level] = 1 + 0.25 * (1 - pow(0.85, level - 1));
this._stationMultCache[level] = 1.0 + 0.4 * (log(level) * Math.LOG2E);
}
}

factoryStackMultiplier(level: number): number {
if (level < 256) return this._factoryMultCache[level];
return 1.25;
}

stationStackMultiplier(level: number): number {
if (level < 256) return this._stationMultCache[level];
return 1.0 + 0.4 * (log(level) * Math.LOG2E);
}

isReplay(): boolean {
return this._isReplay;
Expand Down Expand Up @@ -412,6 +430,8 @@ export class Config {
rel: "self" | "team" | "ally" | "other",
citiesVisited: number,
player: Player | PlayerView,
sourceLevel: number = 1,
stationLevel: number = 1,
): Gold {
// No penalty for the first 10 cities.
citiesVisited = Math.max(0, citiesVisited - 9);
Expand All @@ -429,7 +449,10 @@ export class Config {
break;
}
const distPenalty = citiesVisited * 5_000;
const gold = Math.max(5000, baseGold - distPenalty);
let gold = Math.max(5000, baseGold - distPenalty);
gold *=
this.factoryStackMultiplier(sourceLevel) *
this.stationStackMultiplier(stationLevel);
return toInt(gold * this.goldMultiplierFor(player));
}

Expand Down
4 changes: 4 additions & 0 deletions src/core/execution/TrainExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export class TrainExecution implements Execution {
return this._tradeStopsVisited;
}

public sourceLevel(): number {
return this.source.unit.level();
}

init(mg: Game, ticks: number): void {
this.mg = mg;
const stations = this.railNetwork.findStationsPath(
Expand Down
8 changes: 5 additions & 3 deletions src/core/execution/nation/NationStructureBehavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1120,7 +1120,8 @@ export class NationStructureBehavior {
result.push({
tile: unit.tile(),
cluster: unitToCluster.get(unit)!,
weight: selfWeight,
weight:
selfWeight * game.config().stationStackMultiplier(unit.level()),
});
}
}
Expand All @@ -1135,7 +1136,7 @@ export class NationStructureBehavior {
: player.isAlliedWith(neighbor)
? "ally"
: "other";
const weight =
const baseWeight =
Number(game.config().trainGold(relType, 0, player)) / maxTradeGold;
for (const unit of neighbor.units(
UnitType.City,
Expand All @@ -1146,7 +1147,8 @@ export class NationStructureBehavior {
result.push({
tile: unit.tile(),
cluster: unitToCluster.get(unit)!,
weight,
weight:
baseWeight * game.config().stationStackMultiplier(unit.level()),
});
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/game/TrainStation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class TradeStationStopHandler implements TrainStopHandler {
rel(trainOwner, stationOwner),
trainExecution.tradeStopsVisited(),
trainOwner,
trainExecution.sourceLevel(),
station.unit.level(),
);
// Share revenue with the station owner if it's not the current player
if (trainOwner !== stationOwner) {
Expand Down
70 changes: 58 additions & 12 deletions tests/NationStructureBehavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const MAX_TRADE_GOLD = Number(TRAIN_GOLD.ally); // denominator

// ── Factory helpers ──────────────────────────────────────────────────────────

function makeUnit(tile: number): any {
return { tile: () => tile };
function makeUnit(tile: number, level: number = 1): any {
return { tile: () => tile, level: () => level };
}

function makeStation(unit: any, cluster: Cluster | null = null): any {
Expand All @@ -30,6 +30,8 @@ function makeGame(stations: any[] = []): any {
return {
config: () => ({
trainGold: (rel: string, _citiesVisited: number) => TRAIN_GOLD[rel] ?? 0n,
stationStackMultiplier: (level: number) => 5 * level,
factoryStackMultiplier: (level: number) => level,
}),
railNetwork: () => ({
stationManager: () => ({ getAll: () => new Set(stations) }),
Expand Down Expand Up @@ -156,27 +158,62 @@ describe("NationStructureBehavior.buildReachableStations", () => {
const unit = makeUnit(10);
const station = makeStation(unit, cluster);
const player = makePlayer([unit], []);
const behavior = makeBehavior(makeGame([station]), player);
const game = makeGame([station]);
const behavior = makeBehavior(game, player);

const result = (behavior as any).buildReachableStations();

expect(result).toHaveLength(1);
expect(result[0].tile).toBe(10);
expect(result[0].cluster).toBe(cluster);
expect(result[0].weight).toBeCloseTo(selfWeight);
expect(result[0].weight).toBeCloseTo(
selfWeight * game.config().stationStackMultiplier(unit.level()),
);
});

it("applies the station stack multiplier to both own and neighbor units based on level", () => {
const cluster = new Cluster();
const ownUnit = makeUnit(20, 2);
const ownStation = makeStation(ownUnit, cluster);

const neighborUnit = makeUnit(30, 2);
const neighborStation = makeStation(neighborUnit, cluster);
const neighbor = makeNeighbor({ units: [neighborUnit], isPlayer: true });
const player = makePlayer([ownUnit], [neighbor], {
isAlliedWith: () => true,
});

const game = makeGame([ownStation, neighborStation]);
const behavior = makeBehavior(game, player);

const result = (behavior as any).buildReachableStations();

expect(result).toHaveLength(2);
const ownRes = result.find((r: any) => r.tile === 20);
const neighborRes = result.find((r: any) => r.tile === 30);

expect(ownRes.weight).toBeCloseTo(
selfWeight * game.config().stationStackMultiplier(ownUnit.level()),
);
expect(neighborRes.weight).toBeCloseTo(
allyWeight * game.config().stationStackMultiplier(neighborUnit.level()),
);
});

it("assigns null cluster when own unit is a station with no cluster", () => {
const unit = makeUnit(11);
const station = makeStation(unit, null);
const player = makePlayer([unit], []);
const behavior = makeBehavior(makeGame([station]), player);
const game = makeGame([station]);
const behavior = makeBehavior(game, player);

const result = (behavior as any).buildReachableStations();

expect(result).toHaveLength(1);
expect(result[0].cluster).toBeNull();
expect(result[0].weight).toBeCloseTo(selfWeight);
expect(result[0].weight).toBeCloseTo(
selfWeight * game.config().stationStackMultiplier(unit.level()),
);
});

it("excludes own units not registered in the station manager", () => {
Expand Down Expand Up @@ -236,14 +273,17 @@ describe("NationStructureBehavior.buildReachableStations", () => {
isOnSameTeam: () => false,
isAlliedWith: () => false,
});
const behavior = makeBehavior(makeGame([station]), player);
const game = makeGame([station]);
const behavior = makeBehavior(game, player);

const result = (behavior as any).buildReachableStations();

expect(result).toHaveLength(1);
expect(result[0].tile).toBe(60);
expect(result[0].cluster).toBe(cluster);
expect(result[0].weight).toBeCloseTo(otherWeight);
expect(result[0].weight).toBeCloseTo(
otherWeight * game.config().stationStackMultiplier(unit.level()),
);
});

it("uses 'ally' weight for allied neighbor", () => {
Expand All @@ -255,12 +295,15 @@ describe("NationStructureBehavior.buildReachableStations", () => {
isOnSameTeam: () => false,
isAlliedWith: (n) => n === neighbor,
});
const behavior = makeBehavior(makeGame([station]), player);
const game = makeGame([station]);
const behavior = makeBehavior(game, player);

const result = (behavior as any).buildReachableStations();

expect(result).toHaveLength(1);
expect(result[0].weight).toBeCloseTo(allyWeight);
expect(result[0].weight).toBeCloseTo(
allyWeight * game.config().stationStackMultiplier(unit.level()),
);
});

it("uses 'team' weight for team neighbor (team check precedes ally)", () => {
Expand All @@ -272,12 +315,15 @@ describe("NationStructureBehavior.buildReachableStations", () => {
isOnSameTeam: (n) => n === neighbor,
isAlliedWith: () => false,
});
const behavior = makeBehavior(makeGame([station]), player);
const game = makeGame([station]);
const behavior = makeBehavior(game, player);

const result = (behavior as any).buildReachableStations();

expect(result).toHaveLength(1);
expect(result[0].weight).toBeCloseTo(teamWeight);
expect(result[0].weight).toBeCloseTo(
teamWeight * game.config().stationStackMultiplier(unit.level()),
);
});

it("excludes neighbor units not registered in the station manager", () => {
Expand Down
Loading
Loading