mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 19:06:48 +00:00
3e65d08942
## Description: Now that cities snap to existing rails, it's possible to line up dozens of cities in a row, producing way too much gold. This PR reduces the gold after each stop to prevent that. Gold only starts decreasing after the 3rd city. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import { vi } from "vitest";
|
|
import { NationStructureBehavior } from "../src/core/execution/nation/NationStructureBehavior";
|
|
import { Difficulty, PlayerType } from "../src/core/game/Game";
|
|
import { Cluster } from "../src/core/game/TrainStation";
|
|
import { PseudoRandom } from "../src/core/PseudoRandom";
|
|
|
|
// ── Fixed trade-gold values matching DefaultConfig ──────────────────────────
|
|
|
|
const TRAIN_GOLD: Record<string, bigint> = {
|
|
self: 10_000n,
|
|
team: 25_000n,
|
|
ally: 35_000n,
|
|
other: 25_000n,
|
|
};
|
|
|
|
const MAX_TRADE_GOLD = Number(TRAIN_GOLD.ally); // denominator
|
|
|
|
// ── Factory helpers ──────────────────────────────────────────────────────────
|
|
|
|
function makeUnit(tile: number): any {
|
|
return { tile: () => tile };
|
|
}
|
|
|
|
function makeStation(unit: any, cluster: Cluster | null = null): any {
|
|
return { unit, getCluster: () => cluster };
|
|
}
|
|
|
|
function makeGame(stations: any[] = []): any {
|
|
return {
|
|
config: () => ({
|
|
trainGold: (rel: string, _citiesVisited: number) => TRAIN_GOLD[rel] ?? 0n,
|
|
}),
|
|
railNetwork: () => ({
|
|
stationManager: () => ({ getAll: () => new Set(stations) }),
|
|
}),
|
|
};
|
|
}
|
|
|
|
function makePlayer(
|
|
ownUnits: any[],
|
|
neighborList: any[],
|
|
opts: {
|
|
canTrade?: (n: any) => boolean;
|
|
isOnSameTeam?: (n: any) => boolean;
|
|
isAlliedWith?: (n: any) => boolean;
|
|
} = {},
|
|
): any {
|
|
return {
|
|
units: vi.fn(() => ownUnits),
|
|
neighbors: vi.fn(() => neighborList),
|
|
canTrade: vi.fn((n: any) => opts.canTrade?.(n) ?? true),
|
|
isOnSameTeam: vi.fn((n: any) => opts.isOnSameTeam?.(n) ?? false),
|
|
isAlliedWith: vi.fn((n: any) => opts.isAlliedWith?.(n) ?? false),
|
|
};
|
|
}
|
|
|
|
function makeNeighbor(
|
|
opts: {
|
|
isPlayer?: boolean;
|
|
type?: PlayerType;
|
|
units?: any[];
|
|
} = {},
|
|
): any {
|
|
return {
|
|
isPlayer: () => opts.isPlayer ?? true,
|
|
type: () => opts.type ?? PlayerType.Human,
|
|
units: vi.fn(() => opts.units ?? []),
|
|
};
|
|
}
|
|
|
|
function makeBehavior(
|
|
game: any,
|
|
player: any,
|
|
random: PseudoRandom = new PseudoRandom(0),
|
|
): NationStructureBehavior {
|
|
return new NationStructureBehavior(random, game, player);
|
|
}
|
|
|
|
// ── shouldUseConnectivityScore ───────────────────────────────────────────────
|
|
|
|
describe("NationStructureBehavior.shouldUseConnectivityScore", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function behaviorWithNextInt(returnValue: number): {
|
|
behavior: NationStructureBehavior;
|
|
random: PseudoRandom;
|
|
} {
|
|
const random = new PseudoRandom(0);
|
|
vi.spyOn(random, "nextInt").mockReturnValue(returnValue);
|
|
const behavior = makeBehavior(makeGame(), makePlayer([], []), random);
|
|
return { behavior, random };
|
|
}
|
|
|
|
it("always returns false for Easy (randomChance = 0)", () => {
|
|
for (const v of [0, 50, 99]) {
|
|
const { behavior, random } = behaviorWithNextInt(v);
|
|
vi.spyOn(random, "nextInt").mockReturnValue(v);
|
|
expect(
|
|
(behavior as any).shouldUseConnectivityScore(Difficulty.Easy),
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("returns true for Medium when nextInt < 60", () => {
|
|
const { behavior } = behaviorWithNextInt(59);
|
|
expect(
|
|
(behavior as any).shouldUseConnectivityScore(Difficulty.Medium),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("returns false for Medium when nextInt === 60 (boundary)", () => {
|
|
const { behavior } = behaviorWithNextInt(60);
|
|
expect(
|
|
(behavior as any).shouldUseConnectivityScore(Difficulty.Medium),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("returns true for Hard when nextInt < 75", () => {
|
|
const { behavior } = behaviorWithNextInt(74);
|
|
expect((behavior as any).shouldUseConnectivityScore(Difficulty.Hard)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("returns false for Hard when nextInt === 75 (boundary)", () => {
|
|
const { behavior } = behaviorWithNextInt(75);
|
|
expect((behavior as any).shouldUseConnectivityScore(Difficulty.Hard)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("always returns true for Impossible (randomChance = 100)", () => {
|
|
for (const v of [0, 50, 99]) {
|
|
const { behavior, random } = behaviorWithNextInt(v);
|
|
vi.spyOn(random, "nextInt").mockReturnValue(v);
|
|
expect(
|
|
(behavior as any).shouldUseConnectivityScore(Difficulty.Impossible),
|
|
).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── buildReachableStations ───────────────────────────────────────────────────
|
|
|
|
describe("NationStructureBehavior.buildReachableStations", () => {
|
|
const selfWeight = Number(TRAIN_GOLD.self) / MAX_TRADE_GOLD;
|
|
const allyWeight = Number(TRAIN_GOLD.ally) / MAX_TRADE_GOLD;
|
|
const teamWeight = Number(TRAIN_GOLD.team) / MAX_TRADE_GOLD;
|
|
const otherWeight = Number(TRAIN_GOLD.other) / MAX_TRADE_GOLD;
|
|
|
|
it("includes own registered units with self weight and correct cluster", () => {
|
|
const cluster = new Cluster();
|
|
const unit = makeUnit(10);
|
|
const station = makeStation(unit, cluster);
|
|
const player = makePlayer([unit], []);
|
|
const behavior = makeBehavior(makeGame([station]), 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);
|
|
});
|
|
|
|
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 result = (behavior as any).buildReachableStations();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].cluster).toBeNull();
|
|
expect(result[0].weight).toBeCloseTo(selfWeight);
|
|
});
|
|
|
|
it("excludes own units not registered in the station manager", () => {
|
|
const unit = makeUnit(20);
|
|
// No stations in station manager
|
|
const player = makePlayer([unit], []);
|
|
const behavior = makeBehavior(makeGame([]), player);
|
|
|
|
const result = (behavior as any).buildReachableStations();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes bot neighbors", () => {
|
|
const unit = makeUnit(30);
|
|
const station = makeStation(unit, null);
|
|
const bot = makeNeighbor({ type: PlayerType.Bot, units: [unit] });
|
|
const player = makePlayer([], [bot]);
|
|
const behavior = makeBehavior(makeGame([station]), player);
|
|
|
|
const result = (behavior as any).buildReachableStations();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes non-player neighbors", () => {
|
|
const unit = makeUnit(40);
|
|
const station = makeStation(unit, null);
|
|
const nonPlayer = makeNeighbor({ isPlayer: false, units: [unit] });
|
|
const player = makePlayer([], [nonPlayer]);
|
|
const behavior = makeBehavior(makeGame([station]), player);
|
|
|
|
const result = (behavior as any).buildReachableStations();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes embargoed (canTrade = false) neighbors", () => {
|
|
const unit = makeUnit(50);
|
|
const station = makeStation(unit, null);
|
|
const neighbor = makeNeighbor({ units: [unit] });
|
|
const player = makePlayer([], [neighbor], { canTrade: () => false });
|
|
const behavior = makeBehavior(makeGame([station]), player);
|
|
|
|
const result = (behavior as any).buildReachableStations();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("includes non-embargoed neutral neighbor with 'other' weight", () => {
|
|
const unit = makeUnit(60);
|
|
const cluster = new Cluster();
|
|
const station = makeStation(unit, cluster);
|
|
const neighbor = makeNeighbor({ units: [unit] });
|
|
const player = makePlayer([], [neighbor], {
|
|
canTrade: () => true,
|
|
isOnSameTeam: () => false,
|
|
isAlliedWith: () => false,
|
|
});
|
|
const behavior = makeBehavior(makeGame([station]), 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);
|
|
});
|
|
|
|
it("uses 'ally' weight for allied neighbor", () => {
|
|
const unit = makeUnit(70);
|
|
const station = makeStation(unit, null);
|
|
const neighbor = makeNeighbor({ units: [unit] });
|
|
const player = makePlayer([], [neighbor], {
|
|
canTrade: () => true,
|
|
isOnSameTeam: () => false,
|
|
isAlliedWith: (n) => n === neighbor,
|
|
});
|
|
const behavior = makeBehavior(makeGame([station]), player);
|
|
|
|
const result = (behavior as any).buildReachableStations();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].weight).toBeCloseTo(allyWeight);
|
|
});
|
|
|
|
it("uses 'team' weight for team neighbor (team check precedes ally)", () => {
|
|
const unit = makeUnit(80);
|
|
const station = makeStation(unit, null);
|
|
const neighbor = makeNeighbor({ units: [unit] });
|
|
const player = makePlayer([], [neighbor], {
|
|
canTrade: () => true,
|
|
isOnSameTeam: (n) => n === neighbor,
|
|
isAlliedWith: () => false,
|
|
});
|
|
const behavior = makeBehavior(makeGame([station]), player);
|
|
|
|
const result = (behavior as any).buildReachableStations();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].weight).toBeCloseTo(teamWeight);
|
|
});
|
|
|
|
it("excludes neighbor units not registered in the station manager", () => {
|
|
const unit = makeUnit(90);
|
|
// Station manager has no stations, so unit is unknown
|
|
const neighbor = makeNeighbor({ units: [unit] });
|
|
const player = makePlayer([], [neighbor]);
|
|
const behavior = makeBehavior(makeGame([]), player);
|
|
|
|
const result = (behavior as any).buildReachableStations();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("collects own and neighbor units together", () => {
|
|
const ownUnit = makeUnit(100);
|
|
const ownStation = makeStation(ownUnit, null);
|
|
const neighborUnit = makeUnit(200);
|
|
const neighborStation = makeStation(neighborUnit, null);
|
|
const neighbor = makeNeighbor({ units: [neighborUnit] });
|
|
const player = makePlayer([ownUnit], [neighbor]);
|
|
const behavior = makeBehavior(
|
|
makeGame([ownStation, neighborStation]),
|
|
player,
|
|
);
|
|
|
|
const result = (behavior as any).buildReachableStations();
|
|
|
|
expect(result).toHaveLength(2);
|
|
const tiles = result.map((r: any) => r.tile).sort();
|
|
expect(tiles).toEqual([100, 200]);
|
|
});
|
|
});
|
|
|
|
// ── getOrBuildReachableStations cache behaviour ──────────────────────────────
|
|
|
|
describe("NationStructureBehavior.getOrBuildReachableStations", () => {
|
|
let behavior: NationStructureBehavior;
|
|
let buildSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
const player = makePlayer([], []);
|
|
behavior = makeBehavior(makeGame(), player);
|
|
buildSpy = vi.spyOn(behavior as any, "buildReachableStations");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("calls buildReachableStations exactly once on first access", () => {
|
|
(behavior as any).getOrBuildReachableStations();
|
|
|
|
expect(buildSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns the same array instance on repeated calls", () => {
|
|
const first = (behavior as any).getOrBuildReachableStations();
|
|
const second = (behavior as any).getOrBuildReachableStations();
|
|
|
|
expect(first).toBe(second);
|
|
});
|
|
|
|
it("does not call buildReachableStations a second time when cache is warm", () => {
|
|
(behavior as any).getOrBuildReachableStations();
|
|
(behavior as any).getOrBuildReachableStations();
|
|
|
|
expect(buildSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rebuilds after the cache is reset to null", () => {
|
|
(behavior as any).getOrBuildReachableStations();
|
|
(behavior as any).reachableStationsCache = null;
|
|
(behavior as any).getOrBuildReachableStations();
|
|
|
|
expect(buildSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|