Files
OpenFrontIO/src/core/game/TrainStation.ts
T
FloPinguinandGitHub 9821e8e041 Add host cheats for streamers (Specifically Enzo) (#3671)
## Description:

- Adds a "Host Cheats" toggle in the private lobby options section that
reveals a dedicated section with four host-only cheats: infinite gold,
infinite troops, gold multiplier, and starting gold
- Only the lobby creator receives the cheat effects in-game (checked via
`isLobbyCreator` in DefaultConfig)
- Joining players see active host cheats displayed as yellow badges in
the lobby UI
- Adds `hostCheats` optional object to `GameConfigSchema` and wires it
through the server config update whitelist
- Raises the intent size limit for `update_game_config` messages
(lobby-only, not stored in turn history) to prevent rate-limiter kicks
(I always got too-much-data-kicked after selecting "host cheats" lol)

<img width="861" height="525" alt="image"
src="https://github.com/user-attachments/assets/51e51ec4-c2e8-46ca-b258-11a93487964f"
/>


<img width="933" height="825" alt="image"
src="https://github.com/user-attachments/assets/5acbd38d-2097-42e1-ba78-0fb17d6afe82"
/>

## 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:

FloPinguin
2026-04-15 15:20:08 -07:00

263 lines
6.4 KiB
TypeScript

import { TrainExecution } from "../execution/TrainExecution";
import { PseudoRandom } from "../PseudoRandom";
import { Game, Player, Unit, UnitType } from "./Game";
import { TileRef } from "./GameMap";
import { GameUpdateType } from "./GameUpdates";
import { Railroad } from "./Railroad";
/**
* Handle train stops at various station types
*/
interface TrainStopHandler {
onStop(mg: Game, station: TrainStation, trainExecution: TrainExecution): void;
}
class TradeStationStopHandler implements TrainStopHandler {
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {
const stationOwner = station.unit.owner();
const trainOwner = trainExecution.owner();
const gold = mg
.config()
.trainGold(
rel(trainOwner, stationOwner),
trainExecution.tradeStopsVisited(),
trainOwner,
);
// Share revenue with the station owner if it's not the current player
if (trainOwner !== stationOwner) {
stationOwner.addGold(gold, station.tile());
mg.stats().trainExternalTrade(stationOwner, gold);
}
trainOwner.addGold(gold, station.tile());
mg.stats().trainSelfTrade(trainOwner, gold);
}
}
class FactoryStopHandler implements TrainStopHandler {
onStop(
mg: Game,
station: TrainStation,
trainExecution: TrainExecution,
): void {}
}
export function createTrainStopHandlers(
random: PseudoRandom,
): Partial<Record<UnitType, TrainStopHandler>> {
return {
[UnitType.City]: new TradeStationStopHandler(),
[UnitType.Port]: new TradeStationStopHandler(),
[UnitType.Factory]: new FactoryStopHandler(),
};
}
export class TrainStation {
id: number = -1; // assigned by StationManager
private readonly stopHandlers: Partial<Record<UnitType, TrainStopHandler>> =
{};
private cluster: Cluster | null = null;
private railroads: Set<Railroad> = new Set();
// Quick lookup from neighboring station to connecting railroad
private railroadByNeighbor: Map<TrainStation, Railroad> = new Map();
constructor(
private mg: Game,
public unit: Unit,
) {
this.stopHandlers = createTrainStopHandlers(new PseudoRandom(mg.ticks()));
}
tradeAvailable(otherPlayer: Player): boolean {
const player = this.unit.owner();
return otherPlayer === player || player.canTrade(otherPlayer);
}
clearRailroads() {
this.railroads.clear();
this.railroadByNeighbor.clear();
}
addRailroad(railRoad: Railroad) {
this.railroads.add(railRoad);
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
this.railroadByNeighbor.set(neighbor, railRoad);
}
removeRailroad(railRoad: Railroad) {
this.railroads.delete(railRoad);
const neighbor = railRoad.from === this ? railRoad.to : railRoad.from;
this.railroadByNeighbor.delete(neighbor);
}
removeNeighboringRails(station: TrainStation) {
const toRemove = [...this.railroads].find(
(r) => r.from === station || r.to === station,
);
if (toRemove) {
this.mg.addUpdate({
type: GameUpdateType.RailroadDestructionEvent,
id: toRemove.id,
});
this.removeRailroad(toRemove);
}
}
neighbors(): TrainStation[] {
const neighbors: TrainStation[] = [];
for (const r of this.railroads) {
if (r.from !== this) {
neighbors.push(r.from);
} else {
neighbors.push(r.to);
}
}
return neighbors;
}
tile(): TileRef {
return this.unit.tile();
}
isActive(): boolean {
return this.unit.isActive();
}
getRailroads(): Set<Railroad> {
return this.railroads;
}
getRailroadTo(station: TrainStation): Railroad | null {
return this.railroadByNeighbor.get(station) ?? null;
}
setCluster(cluster: Cluster | null) {
// Properly disconnect cluster if it's already set
if (this.cluster !== null) {
this.cluster.removeStation(this);
}
this.cluster = cluster;
}
getCluster(): Cluster | null {
return this.cluster;
}
onTrainStop(trainExecution: TrainExecution) {
const type = this.unit.type();
const handler = this.stopHandlers[type];
if (handler) {
handler.onStop(this.mg, this, trainExecution);
}
}
}
/**
* Cluster of connected stations
*/
export class Cluster {
public stations: Set<TrainStation> = new Set();
private tradeStations: Set<TrainStation> = new Set();
private isTradeStation(station: TrainStation): boolean {
const type = station.unit.type();
return type === UnitType.City || type === UnitType.Port;
}
has(station: TrainStation) {
return this.stations.has(station);
}
addStation(station: TrainStation) {
this.stations.add(station);
if (this.isTradeStation(station)) {
this.tradeStations.add(station);
}
station.setCluster(this);
}
removeStation(station: TrainStation) {
this.stations.delete(station);
this.tradeStations.delete(station);
}
addStations(stations: Set<TrainStation>) {
for (const station of stations) {
this.addStation(station);
}
}
merge(other: Cluster) {
for (const s of other.stations) {
this.addStation(s);
}
}
hasAnyTradeDestination(player: Player): boolean {
for (const station of this.tradeStations) {
if (station.tradeAvailable(player)) {
return true;
}
}
return false;
}
randomTradeDestination(
player: Player,
random: PseudoRandom,
): TrainStation | null {
let selected: TrainStation | null = null;
let eligibleSeen = 0;
for (const station of this.tradeStations) {
if (!station.tradeAvailable(player)) continue;
eligibleSeen++;
// Reservoir sampling: keep each eligible station with probability 1/eligibleSeen.
if (random.nextInt(0, eligibleSeen) === 0) {
selected = station;
}
}
return selected;
}
availableForTrade(player: Player): Set<TrainStation> {
const tradingStations = new Set<TrainStation>();
for (const station of this.tradeStations) {
if (station.tradeAvailable(player)) {
tradingStations.add(station);
}
}
return tradingStations;
}
size() {
return this.stations.size;
}
clear() {
this.stations.clear();
this.tradeStations.clear();
}
}
function rel(
player: Player,
other: Player,
): "self" | "team" | "ally" | "other" {
if (player === other) {
return "self";
}
if (player.isOnSameTeam(other)) {
return "team";
}
if (player.isAlliedWith(other)) {
return "ally";
}
return "other";
}