mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 05:42:04 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
@@ -30,7 +30,6 @@ import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
||||
import { PseudoRandom } from "./PseudoRandom";
|
||||
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
||||
import { simpleHash } from "./Util";
|
||||
import { censorNameWithClanTag } from "./validations/username";
|
||||
|
||||
export async function createGameRunner(
|
||||
gameStart: GameStartInfo,
|
||||
@@ -48,7 +47,7 @@ export async function createGameRunner(
|
||||
|
||||
const humans = gameStart.players.map((p) => {
|
||||
return new PlayerInfo(
|
||||
p.clientID === clientID ? p.username : censorNameWithClanTag(p.username),
|
||||
p.username,
|
||||
PlayerType.Human,
|
||||
p.clientID,
|
||||
random.nextID(),
|
||||
|
||||
+42
-33
@@ -148,6 +148,7 @@ const ClientInfoSchema = z.object({
|
||||
export const GameInfoSchema = z.object({
|
||||
gameID: z.string(),
|
||||
clients: z.array(ClientInfoSchema).optional(),
|
||||
lobbyCreatorClientID: z.string().optional(),
|
||||
startsAt: z.number().optional(),
|
||||
serverTime: z.number(),
|
||||
gameConfig: z.lazy(() => GameConfigSchema).optional(),
|
||||
@@ -166,7 +167,10 @@ export const PublicGamesSchema = z.object({
|
||||
});
|
||||
|
||||
export class LobbyInfoEvent implements GameEvent {
|
||||
constructor(public lobby: GameInfo) {}
|
||||
constructor(
|
||||
public lobby: GameInfo,
|
||||
public myClientID: ClientID,
|
||||
) {}
|
||||
}
|
||||
|
||||
export interface ClientInfo {
|
||||
@@ -280,140 +284,137 @@ export const QuickChatKeySchema = z.enum(
|
||||
// Intents
|
||||
//
|
||||
|
||||
const BaseIntentSchema = z.object({
|
||||
clientID: ID,
|
||||
});
|
||||
|
||||
export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({
|
||||
export const AllianceExtensionIntentSchema = z.object({
|
||||
type: z.literal("allianceExtension"),
|
||||
recipient: ID,
|
||||
});
|
||||
|
||||
export const AttackIntentSchema = BaseIntentSchema.extend({
|
||||
export const AttackIntentSchema = z.object({
|
||||
type: z.literal("attack"),
|
||||
targetID: ID.nullable(),
|
||||
troops: z.number().nonnegative().nullable(),
|
||||
sourceTile: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const SpawnIntentSchema = BaseIntentSchema.extend({
|
||||
export const SpawnIntentSchema = z.object({
|
||||
type: z.literal("spawn"),
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
export const BoatAttackIntentSchema = BaseIntentSchema.extend({
|
||||
export const BoatAttackIntentSchema = z.object({
|
||||
type: z.literal("boat"),
|
||||
troops: z.number().nonnegative(),
|
||||
dst: z.number(),
|
||||
});
|
||||
|
||||
export const AllianceRequestIntentSchema = BaseIntentSchema.extend({
|
||||
export const AllianceRequestIntentSchema = z.object({
|
||||
type: z.literal("allianceRequest"),
|
||||
recipient: ID,
|
||||
});
|
||||
|
||||
export const AllianceRequestReplyIntentSchema = BaseIntentSchema.extend({
|
||||
export const AllianceRequestReplyIntentSchema = z.object({
|
||||
type: z.literal("allianceRequestReply"),
|
||||
requestor: ID, // The one who made the original alliance request
|
||||
accept: z.boolean(),
|
||||
});
|
||||
|
||||
export const BreakAllianceIntentSchema = BaseIntentSchema.extend({
|
||||
export const BreakAllianceIntentSchema = z.object({
|
||||
type: z.literal("breakAlliance"),
|
||||
recipient: ID,
|
||||
});
|
||||
|
||||
export const TargetPlayerIntentSchema = BaseIntentSchema.extend({
|
||||
export const TargetPlayerIntentSchema = z.object({
|
||||
type: z.literal("targetPlayer"),
|
||||
target: ID,
|
||||
});
|
||||
|
||||
export const EmojiIntentSchema = BaseIntentSchema.extend({
|
||||
export const EmojiIntentSchema = z.object({
|
||||
type: z.literal("emoji"),
|
||||
recipient: z.union([ID, z.literal(AllPlayers)]),
|
||||
emoji: EmojiSchema,
|
||||
});
|
||||
|
||||
export const EmbargoIntentSchema = BaseIntentSchema.extend({
|
||||
export const EmbargoIntentSchema = z.object({
|
||||
type: z.literal("embargo"),
|
||||
targetID: ID,
|
||||
action: z.union([z.literal("start"), z.literal("stop")]),
|
||||
});
|
||||
|
||||
export const EmbargoAllIntentSchema = BaseIntentSchema.extend({
|
||||
export const EmbargoAllIntentSchema = z.object({
|
||||
type: z.literal("embargo_all"),
|
||||
action: z.union([z.literal("start"), z.literal("stop")]),
|
||||
});
|
||||
|
||||
export const DonateGoldIntentSchema = BaseIntentSchema.extend({
|
||||
export const DonateGoldIntentSchema = z.object({
|
||||
type: z.literal("donate_gold"),
|
||||
recipient: ID,
|
||||
gold: z.number().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
export const DonateTroopIntentSchema = BaseIntentSchema.extend({
|
||||
export const DonateTroopIntentSchema = z.object({
|
||||
type: z.literal("donate_troops"),
|
||||
recipient: ID,
|
||||
troops: z.number().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
export const BuildUnitIntentSchema = BaseIntentSchema.extend({
|
||||
export const BuildUnitIntentSchema = z.object({
|
||||
type: z.literal("build_unit"),
|
||||
unit: z.enum(UnitType),
|
||||
tile: z.number(),
|
||||
rocketDirectionUp: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
|
||||
export const UpgradeStructureIntentSchema = z.object({
|
||||
type: z.literal("upgrade_structure"),
|
||||
unit: z.enum(UnitType),
|
||||
unitId: z.number(),
|
||||
});
|
||||
|
||||
export const CancelAttackIntentSchema = BaseIntentSchema.extend({
|
||||
export const CancelAttackIntentSchema = z.object({
|
||||
type: z.literal("cancel_attack"),
|
||||
attackID: z.string(),
|
||||
});
|
||||
|
||||
export const CancelBoatIntentSchema = BaseIntentSchema.extend({
|
||||
export const CancelBoatIntentSchema = z.object({
|
||||
type: z.literal("cancel_boat"),
|
||||
unitID: z.number(),
|
||||
});
|
||||
|
||||
export const MoveWarshipIntentSchema = BaseIntentSchema.extend({
|
||||
export const MoveWarshipIntentSchema = z.object({
|
||||
type: z.literal("move_warship"),
|
||||
unitId: z.number(),
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
export const DeleteUnitIntentSchema = BaseIntentSchema.extend({
|
||||
export const DeleteUnitIntentSchema = z.object({
|
||||
type: z.literal("delete_unit"),
|
||||
unitId: z.number(),
|
||||
});
|
||||
|
||||
export const QuickChatIntentSchema = BaseIntentSchema.extend({
|
||||
export const QuickChatIntentSchema = z.object({
|
||||
type: z.literal("quick_chat"),
|
||||
recipient: ID,
|
||||
quickChatKey: QuickChatKeySchema,
|
||||
target: ID.optional(),
|
||||
});
|
||||
|
||||
export const MarkDisconnectedIntentSchema = BaseIntentSchema.extend({
|
||||
export const MarkDisconnectedIntentSchema = z.object({
|
||||
type: z.literal("mark_disconnected"),
|
||||
clientID: ID,
|
||||
isDisconnected: z.boolean(),
|
||||
});
|
||||
|
||||
export const KickPlayerIntentSchema = BaseIntentSchema.extend({
|
||||
export const KickPlayerIntentSchema = z.object({
|
||||
type: z.literal("kick_player"),
|
||||
target: ID,
|
||||
});
|
||||
|
||||
export const TogglePauseIntentSchema = BaseIntentSchema.extend({
|
||||
export const TogglePauseIntentSchema = z.object({
|
||||
type: z.literal("toggle_pause"),
|
||||
paused: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const UpdateGameConfigIntentSchema = BaseIntentSchema.extend({
|
||||
export const UpdateGameConfigIntentSchema = z.object({
|
||||
type: z.literal("update_game_config"),
|
||||
config: GameConfigSchema.partial(),
|
||||
});
|
||||
@@ -445,13 +446,17 @@ const IntentSchema = z.discriminatedUnion("type", [
|
||||
UpdateGameConfigIntentSchema,
|
||||
]);
|
||||
|
||||
// StampedIntent = Intent with server-stamped clientID (used in turns and execution)
|
||||
export const StampedIntentSchema = IntentSchema.and(z.object({ clientID: ID }));
|
||||
export type StampedIntent = Intent & { clientID: ClientID };
|
||||
|
||||
//
|
||||
// Server utility types
|
||||
//
|
||||
|
||||
export const TurnSchema = z.object({
|
||||
turnNumber: z.number(),
|
||||
intents: IntentSchema.array(),
|
||||
intents: StampedIntentSchema.array(),
|
||||
// The hash of the game state at the end of the turn.
|
||||
hash: z.number().nullable().optional(),
|
||||
});
|
||||
@@ -540,6 +545,8 @@ export const ServerStartGameMessageSchema = z.object({
|
||||
turns: TurnSchema.array(),
|
||||
gameStartInfo: GameStartInfoSchema,
|
||||
lobbyCreatedAt: z.number(),
|
||||
// The clientID assigned to this connection by the server
|
||||
myClientID: ID,
|
||||
});
|
||||
|
||||
export const ServerDesyncSchema = z.object({
|
||||
@@ -560,6 +567,8 @@ export const ServerErrorSchema = z.object({
|
||||
export const ServerLobbyInfoMessageSchema = z.object({
|
||||
type: z.literal("lobby_info"),
|
||||
lobby: GameInfoSchema,
|
||||
// The clientID assigned to this connection by the server
|
||||
myClientID: ID,
|
||||
});
|
||||
|
||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
@@ -604,10 +613,10 @@ export const ClientIntentMessageSchema = z.object({
|
||||
});
|
||||
|
||||
// WARNING: never send this message to clients.
|
||||
// Note: clientID is NOT included - server assigns it based on persistentID from token
|
||||
export const ClientJoinMessageSchema = z.object({
|
||||
type: z.literal("join"),
|
||||
clientID: ID,
|
||||
token: TokenSchema, // WARNING: PII
|
||||
token: TokenSchema, // WARNING: PII - server extracts persistentID from this
|
||||
gameID: ID,
|
||||
username: UsernameSchema,
|
||||
// Server replaces the refs with the actual cosmetic data.
|
||||
@@ -618,7 +627,7 @@ export const ClientJoinMessageSchema = z.object({
|
||||
export const ClientRejoinMessageSchema = z.object({
|
||||
type: z.literal("rejoin"),
|
||||
gameID: ID,
|
||||
clientID: ID,
|
||||
// Note: clientID is NOT sent - server looks it up from persistentID in token
|
||||
lastTurn: z.number(),
|
||||
token: TokenSchema,
|
||||
});
|
||||
|
||||
@@ -24,20 +24,27 @@ export const greenTeamColors: Colord[] = generateTeamColors(green);
|
||||
export const botTeamColors: Colord[] = [botColor];
|
||||
|
||||
function generateTeamColors(baseColor: Colord): Colord[] {
|
||||
const hsl = baseColor.toHsl();
|
||||
const lch = baseColor.toLch();
|
||||
const colorCount = 64;
|
||||
const goldenAngle = 137.508;
|
||||
|
||||
return Array.from({ length: colorCount }, (_, index) => {
|
||||
const progression = index / (colorCount - 1);
|
||||
if (index === 0) return baseColor;
|
||||
|
||||
const saturation = hsl.s * (1.0 - 0.3 * progression);
|
||||
const lightness = Math.min(100, hsl.l + progression * 30);
|
||||
// Spread hues evenly across ±12° band using golden angle within that range
|
||||
const hueShift = ((index * goldenAngle) % 24) - 12;
|
||||
const h = (lch.h + hueShift + 360) % 360;
|
||||
|
||||
return colord({
|
||||
h: hsl.h,
|
||||
s: saturation,
|
||||
l: lightness,
|
||||
});
|
||||
// Chroma oscillates ±10% around the base to add variety without washing out
|
||||
const chromaFactor = 1.0 + 0.1 * Math.sin(index * 0.7);
|
||||
const c = Math.max(10, Math.min(130, lch.c * chromaFactor));
|
||||
|
||||
// Lightness alternates above/below the base using golden angle spacing
|
||||
// Tighter range (±18) keeps teammates recognizable as the same team
|
||||
const lightOffset = 18 * Math.sin(index * goldenAngle * (Math.PI / 180));
|
||||
const l = Math.max(25, Math.min(80, lch.l + lightOffset));
|
||||
|
||||
return colord({ l, c, h });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Execution, Game } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { ClientID, GameID, Intent, Turn } from "../Schemas";
|
||||
import { ClientID, GameID, StampedIntent, Turn } from "../Schemas";
|
||||
import { simpleHash } from "../Util";
|
||||
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
|
||||
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
|
||||
@@ -46,7 +46,7 @@ export class Executor {
|
||||
return turn.intents.map((i) => this.createExec(i));
|
||||
}
|
||||
|
||||
createExec(intent: Intent): Execution {
|
||||
createExec(intent: StampedIntent): Execution {
|
||||
const player = this.mg.playerByClientID(intent.clientID);
|
||||
if (!player) {
|
||||
console.warn(`player with clientID ${intent.clientID} not found`);
|
||||
|
||||
@@ -2,39 +2,36 @@ import {
|
||||
Difficulty,
|
||||
Execution,
|
||||
Game,
|
||||
GameMode,
|
||||
Gold,
|
||||
Nation,
|
||||
Player,
|
||||
PlayerID,
|
||||
Relation,
|
||||
TerrainType,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { assertNever, simpleHash } from "../Util";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { NationAllianceBehavior } from "./nation/NationAllianceBehavior";
|
||||
import { NationEmojiBehavior } from "./nation/NationEmojiBehavior";
|
||||
import { NationMIRVBehavior } from "./nation/NationMIRVBehavior";
|
||||
import { NationNukeBehavior } from "./nation/NationNukeBehavior";
|
||||
import { randTerritoryTileArray } from "./nation/NationUtils";
|
||||
import { NationStructureBehavior } from "./nation/NationStructureBehavior";
|
||||
import { NationWarshipBehavior } from "./nation/NationWarshipBehavior";
|
||||
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { AiAttackBehavior } from "./utils/AiAttackBehavior";
|
||||
|
||||
export class NationExecution implements Execution {
|
||||
private active = true;
|
||||
private random: PseudoRandom;
|
||||
private emojiBehavior: NationEmojiBehavior | null = null;
|
||||
private mirvBehavior: NationMIRVBehavior | null = null;
|
||||
private attackBehavior: AiAttackBehavior | null = null;
|
||||
private allianceBehavior: NationAllianceBehavior | null = null;
|
||||
private warshipBehavior: NationWarshipBehavior | null = null;
|
||||
private nukeBehavior: NationNukeBehavior | null = null;
|
||||
private behaviorsInitialized = false;
|
||||
private emojiBehavior!: NationEmojiBehavior;
|
||||
private mirvBehavior!: NationMIRVBehavior;
|
||||
private attackBehavior!: AiAttackBehavior;
|
||||
private allianceBehavior!: NationAllianceBehavior;
|
||||
private warshipBehavior!: NationWarshipBehavior;
|
||||
private nukeBehavior!: NationNukeBehavior;
|
||||
private structureBehavior!: NationStructureBehavior;
|
||||
private mg: Game;
|
||||
private player: Player | null = null;
|
||||
|
||||
@@ -89,7 +86,7 @@ export class NationExecution implements Execution {
|
||||
tick(ticks: number) {
|
||||
// Ship tracking
|
||||
if (
|
||||
this.warshipBehavior !== null &&
|
||||
this.behaviorsInitialized &&
|
||||
this.player !== null &&
|
||||
this.player.isAlive() &&
|
||||
this.mg.config().gameConfig().difficulty !== Difficulty.Easy
|
||||
@@ -98,6 +95,24 @@ export class NationExecution implements Execution {
|
||||
}
|
||||
|
||||
if (ticks % this.attackRate !== this.attackTick) {
|
||||
// Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval)
|
||||
// Otherwise it is possible that we earn more gold than we can spend
|
||||
// The alternative is placing multiple structures in handleStructures, but that causes problems
|
||||
if (
|
||||
this.behaviorsInitialized &&
|
||||
this.player !== null &&
|
||||
this.player.isAlive()
|
||||
) {
|
||||
const offset = ticks % this.attackRate;
|
||||
const oneThird =
|
||||
(this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate;
|
||||
const twoThirds =
|
||||
(this.attackTick + Math.floor((this.attackRate * 2) / 3)) %
|
||||
this.attackRate;
|
||||
if (offset === oneThird || offset === twoThirds) {
|
||||
this.structureBehavior.handleStructures();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,56 +148,8 @@ export class NationExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.emojiBehavior === null ||
|
||||
this.mirvBehavior === null ||
|
||||
this.attackBehavior === null ||
|
||||
this.allianceBehavior === null ||
|
||||
this.warshipBehavior === null ||
|
||||
this.nukeBehavior === null
|
||||
) {
|
||||
this.emojiBehavior = new NationEmojiBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
this.mirvBehavior = new NationMIRVBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.allianceBehavior = new NationAllianceBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.warshipBehavior = new NationWarshipBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.attackBehavior = new AiAttackBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
this.expandRatio,
|
||||
this.allianceBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.nukeBehavior = new NationNukeBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.attackBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
|
||||
// Send an attack on the first tick
|
||||
if (!this.behaviorsInitialized) {
|
||||
this.initializeBehaviors();
|
||||
this.attackBehavior.forceSendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
@@ -192,13 +159,65 @@ export class NationExecution implements Execution {
|
||||
this.allianceBehavior.handleAllianceRequests();
|
||||
this.allianceBehavior.handleAllianceExtensionRequests();
|
||||
this.mirvBehavior.considerMIRV();
|
||||
this.handleUnits();
|
||||
this.structureBehavior.handleStructures();
|
||||
this.warshipBehavior.maybeSpawnWarship();
|
||||
this.handleEmbargoesToHostileNations();
|
||||
this.attackBehavior.maybeAttack();
|
||||
this.warshipBehavior.counterWarshipInfestation();
|
||||
this.nukeBehavior.maybeSendNuke();
|
||||
}
|
||||
|
||||
private initializeBehaviors(): void {
|
||||
if (this.player === null) throw new Error("Player not initialized");
|
||||
|
||||
this.emojiBehavior = new NationEmojiBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
this.mirvBehavior = new NationMIRVBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.allianceBehavior = new NationAllianceBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.warshipBehavior = new NationWarshipBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.attackBehavior = new AiAttackBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.triggerRatio,
|
||||
this.reserveRatio,
|
||||
this.expandRatio,
|
||||
this.allianceBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.nukeBehavior = new NationNukeBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
this.attackBehavior,
|
||||
this.emojiBehavior,
|
||||
);
|
||||
this.structureBehavior = new NationStructureBehavior(
|
||||
this.random,
|
||||
this.mg,
|
||||
this.player,
|
||||
);
|
||||
this.behaviorsInitialized = true;
|
||||
}
|
||||
|
||||
private randomSpawnLand(): TileRef | null {
|
||||
if (this.nation.spawnCell === undefined) throw new Error("not initialized");
|
||||
|
||||
@@ -249,102 +268,6 @@ export class NationExecution implements Execution {
|
||||
});
|
||||
}
|
||||
|
||||
private handleUnits() {
|
||||
if (this.warshipBehavior === null) throw new Error("not initialized");
|
||||
const hasCoastalTiles = this.hasCoastalTiles();
|
||||
const isTeamGame = this.mg.config().gameConfig().gameMode === GameMode.Team;
|
||||
return (
|
||||
this.maybeSpawnStructure(UnitType.City, (num) => num) ||
|
||||
this.maybeSpawnStructure(UnitType.Port, (num) => num) ||
|
||||
this.warshipBehavior.maybeSpawnWarship() ||
|
||||
this.maybeSpawnStructure(UnitType.Factory, (num) =>
|
||||
hasCoastalTiles ? num * 3 : num,
|
||||
) ||
|
||||
this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) ||
|
||||
this.maybeSpawnStructure(UnitType.SAMLauncher, (num) =>
|
||||
isTeamGame ? num : num ** 2,
|
||||
) ||
|
||||
this.maybeSpawnStructure(UnitType.MissileSilo, (num) => num ** 2)
|
||||
);
|
||||
}
|
||||
|
||||
private hasCoastalTiles(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
for (const tile of this.player.borderTiles()) {
|
||||
if (this.mg.isOceanShore(tile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private maybeSpawnStructure(
|
||||
type: UnitType,
|
||||
multiplier: (num: number) => number,
|
||||
) {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
const owned = this.player.unitsOwned(type);
|
||||
const perceivedCostMultiplier = multiplier(owned + 1);
|
||||
const realCost = this.cost(type);
|
||||
const perceivedCost = realCost * BigInt(perceivedCostMultiplier);
|
||||
if (this.player.gold() < perceivedCost) {
|
||||
return false;
|
||||
}
|
||||
const tile = this.structureSpawnTile(type);
|
||||
if (tile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(type, tile);
|
||||
if (canBuild === false) {
|
||||
return false;
|
||||
}
|
||||
this.mg.addExecution(new ConstructionExecution(this.player, type, tile));
|
||||
return true;
|
||||
}
|
||||
|
||||
private structureSpawnTile(type: UnitType): TileRef | null {
|
||||
if (this.mg === undefined) throw new Error("Not initialized");
|
||||
if (this.player === null) throw new Error("Not initialized");
|
||||
const tiles =
|
||||
type === UnitType.Port
|
||||
? this.randCoastalTileArray(25)
|
||||
: randTerritoryTileArray(this.random, this.mg, this.player, 25);
|
||||
if (tiles.length === 0) return null;
|
||||
const valueFunction = structureSpawnTileValue(this.mg, this.player, type);
|
||||
if (valueFunction === null) return null;
|
||||
let bestTile: TileRef | null = null;
|
||||
let bestValue = 0;
|
||||
for (const t of tiles) {
|
||||
const v = valueFunction(t);
|
||||
if (v <= bestValue && bestTile !== null) continue;
|
||||
if (!this.player.canBuild(type, t)) continue;
|
||||
// Found a better tile
|
||||
bestTile = t;
|
||||
bestValue = v;
|
||||
}
|
||||
return bestTile;
|
||||
}
|
||||
|
||||
private randCoastalTileArray(numTiles: number): TileRef[] {
|
||||
const tiles = Array.from(this.player!.borderTiles()).filter((t) =>
|
||||
this.mg.isOceanShore(t),
|
||||
);
|
||||
return Array.from(this.arraySampler(tiles, numTiles));
|
||||
}
|
||||
|
||||
private *arraySampler<T>(a: T[], sampleSize: number): Generator<T> {
|
||||
if (a.length <= sampleSize) {
|
||||
// Return all elements
|
||||
yield* a;
|
||||
} else {
|
||||
// Sample `sampleSize` elements
|
||||
const remaining = new Set<T>(a);
|
||||
while (sampleSize--) {
|
||||
const t = this.random.randFromSet(remaining);
|
||||
remaining.delete(t);
|
||||
yield t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEmbargoesToHostileNations() {
|
||||
const player = this.player;
|
||||
if (player === null) return;
|
||||
@@ -375,11 +298,6 @@ export class NationExecution implements Execution {
|
||||
});
|
||||
}
|
||||
|
||||
private cost(type: UnitType): Gold {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
return this.mg.unitInfo(type).cost(this.mg, this.player);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
@@ -310,7 +310,8 @@ export class NukeExecution implements Execution {
|
||||
unit.type() !== UnitType.AtomBomb &&
|
||||
unit.type() !== UnitType.HydrogenBomb &&
|
||||
unit.type() !== UnitType.MIRVWarhead &&
|
||||
unit.type() !== UnitType.MIRV
|
||||
unit.type() !== UnitType.MIRV &&
|
||||
unit.type() !== UnitType.SAMMissile
|
||||
) {
|
||||
if (this.mg.euclideanDistSquared(this.dst, unit.tile()) < outer2) {
|
||||
unit.delete(true, this.player);
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Execution, Game } from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "../game/GameUpdates";
|
||||
import { Railroad } from "../game/Railroad";
|
||||
|
||||
export class RailroadExecution implements Execution {
|
||||
private mg: Game;
|
||||
private active: boolean = true;
|
||||
private headIndex: number = 0;
|
||||
private tailIndex: number = 0;
|
||||
private increment: number = 3;
|
||||
private railTiles: RailTile[] = [];
|
||||
constructor(private railRoad: Railroad) {
|
||||
this.tailIndex = railRoad.tiles.length;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
const tiles = this.railRoad.tiles;
|
||||
// Inverse direction computation for the first tile
|
||||
this.railTiles.push({
|
||||
tile: tiles[0],
|
||||
railType:
|
||||
tiles.length > 0
|
||||
? this.computeExtremityDirection(tiles[0], tiles[1])
|
||||
: RailType.VERTICAL,
|
||||
});
|
||||
for (let i = 1; i < tiles.length - 1; i++) {
|
||||
const direction = this.computeDirection(
|
||||
tiles[i - 1],
|
||||
tiles[i],
|
||||
tiles[i + 1],
|
||||
);
|
||||
this.railTiles.push({ tile: tiles[i], railType: direction });
|
||||
}
|
||||
this.railTiles.push({
|
||||
tile: tiles[tiles.length - 1],
|
||||
railType:
|
||||
tiles.length > 0
|
||||
? this.computeExtremityDirection(
|
||||
tiles[tiles.length - 1],
|
||||
tiles[tiles.length - 2],
|
||||
)
|
||||
: RailType.VERTICAL,
|
||||
});
|
||||
}
|
||||
|
||||
private computeExtremityDirection(tile: TileRef, next: TileRef): RailType {
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.y(tile);
|
||||
const nextX = this.mg.x(next);
|
||||
const nextY = this.mg.y(next);
|
||||
|
||||
const dx = nextX - x;
|
||||
const dy = nextY - y;
|
||||
|
||||
if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement
|
||||
|
||||
if (dx === 0) {
|
||||
return RailType.VERTICAL;
|
||||
} else if (dy === 0) {
|
||||
return RailType.HORIZONTAL;
|
||||
}
|
||||
return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
private computeDirection(
|
||||
prev: TileRef,
|
||||
current: TileRef,
|
||||
next: TileRef,
|
||||
): RailType {
|
||||
if (this.mg === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
const x1 = this.mg.x(prev);
|
||||
const y1 = this.mg.y(prev);
|
||||
const x2 = this.mg.x(current);
|
||||
const y2 = this.mg.y(current);
|
||||
const x3 = this.mg.x(next);
|
||||
const y3 = this.mg.y(next);
|
||||
|
||||
const dx1 = x2 - x1;
|
||||
const dy1 = y2 - y1;
|
||||
const dx2 = x3 - x2;
|
||||
const dy2 = y3 - y2;
|
||||
|
||||
// Straight line
|
||||
if (dx1 === dx2 && dy1 === dy2) {
|
||||
if (dx1 !== 0) return RailType.HORIZONTAL;
|
||||
if (dy1 !== 0) return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
// Turn (corner) cases
|
||||
if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) {
|
||||
// Now figure out which type of corner
|
||||
if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT;
|
||||
if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT;
|
||||
if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT;
|
||||
if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT;
|
||||
|
||||
if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT;
|
||||
if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT;
|
||||
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
|
||||
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
|
||||
}
|
||||
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
|
||||
return RailType.VERTICAL;
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.mg === null) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
if (!this.activeSourceOrDestination()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
if (this.headIndex > this.tailIndex) {
|
||||
// Construction complete
|
||||
this.constructionComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedRailTiles: RailTile[];
|
||||
// Check if remaining tiles can be done all at once
|
||||
if (this.tailIndex - this.headIndex <= 2 * this.increment) {
|
||||
updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex);
|
||||
this.constructionComplete();
|
||||
} else {
|
||||
updatedRailTiles = this.railTiles.slice(
|
||||
this.headIndex,
|
||||
this.headIndex + this.increment,
|
||||
);
|
||||
updatedRailTiles = updatedRailTiles.concat(
|
||||
this.railTiles.slice(this.tailIndex - this.increment, this.tailIndex),
|
||||
);
|
||||
this.headIndex += this.increment;
|
||||
this.tailIndex -= this.increment;
|
||||
}
|
||||
if (updatedRailTiles) {
|
||||
this.mg.addUpdate({
|
||||
type: GameUpdateType.RailroadEvent,
|
||||
isActive: true,
|
||||
railTiles: updatedRailTiles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
private activeSourceOrDestination(): boolean {
|
||||
return this.railRoad.from.isActive() && this.railRoad.to.isActive();
|
||||
}
|
||||
|
||||
private constructionComplete() {
|
||||
this.redrawBuildings();
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
private redrawBuildings() {
|
||||
if (this.railRoad.from.unit.isActive()) this.railRoad.from.unit.touch();
|
||||
if (this.railRoad.to.unit.isActive()) this.railRoad.to.unit.touch();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
Gold,
|
||||
Player,
|
||||
PlayerType,
|
||||
Relation,
|
||||
StructureTypes,
|
||||
Unit,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { assertNever } from "../../Util";
|
||||
import { ConstructionExecution } from "../ConstructionExecution";
|
||||
import { UpgradeStructureExecution } from "../UpgradeStructureExecution";
|
||||
import { closestTile, closestTwoTiles } from "../Util";
|
||||
import { randTerritoryTileArray } from "./NationUtils";
|
||||
|
||||
/**
|
||||
* Configuration for how many structures of each type a nation should build
|
||||
* relative to the number of cities it owns.
|
||||
*/
|
||||
interface StructureRatioConfig {
|
||||
/** How many of this structure per city (e.g., 0.75 means 3 ports for every 4 cities) */
|
||||
ratioPerCity: number;
|
||||
/** Perceived cost increase percentage per owned structure (e.g., 0.1 = 10% more expensive per owned) */
|
||||
perceivedCostIncreasePerOwned: number;
|
||||
}
|
||||
|
||||
/** SAM launcher ratio per city, keyed by difficulty */
|
||||
const SAM_RATIO_BY_DIFFICULTY: Record<Difficulty, number> = {
|
||||
[Difficulty.Easy]: 0.15,
|
||||
[Difficulty.Medium]: 0.2,
|
||||
[Difficulty.Hard]: 0.25,
|
||||
[Difficulty.Impossible]: 0.3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns structure ratios relative to city count, adjusted by difficulty.
|
||||
* Cities are always prioritized and built first.
|
||||
* When cities are disabled, we use TILES_PER_CITY_EQUIVALENT. That's not ideal, nations won't properly upgrade structures, but it's better than nothing. Probably 99.9% of players won't disable cities anyway.
|
||||
*/
|
||||
function getStructureRatios(
|
||||
difficulty: Difficulty,
|
||||
): Partial<Record<UnitType, StructureRatioConfig>> {
|
||||
return {
|
||||
[UnitType.Port]: { ratioPerCity: 0.75, perceivedCostIncreasePerOwned: 1 },
|
||||
[UnitType.Factory]: {
|
||||
ratioPerCity: 0.75,
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
[UnitType.DefensePost]: {
|
||||
ratioPerCity: 0.25,
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
[UnitType.SAMLauncher]: {
|
||||
ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty],
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
[UnitType.MissileSilo]: {
|
||||
ratioPerCity: 0.2,
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Perceived cost increase percentage per city owned */
|
||||
const CITY_PERCEIVED_COST_INCREASE_PER_OWNED = 1;
|
||||
|
||||
/** Factory ratio multiplier when the nation has coastal tiles */
|
||||
const FACTORY_COASTAL_RATIO_MULTIPLIER = 0.33;
|
||||
|
||||
/** Maximum number of missile silos a nation will build */
|
||||
const MAX_MISSILE_SILOS = 3;
|
||||
|
||||
/** If we have more than this many structures per tiles, prefer upgrading over building */
|
||||
const UPGRADE_DENSITY_THRESHOLD = 1 / 1500;
|
||||
|
||||
/** Maximum density of defense posts (per tile owned) before no more can be built */
|
||||
const DEFENSE_POST_DENSITY_THRESHOLD = 1 / 5000;
|
||||
|
||||
/** Estimated number of tiles per city equivalent, used when cities are disabled */
|
||||
const TILES_PER_CITY_EQUIVALENT = 2000;
|
||||
|
||||
export class NationStructureBehavior {
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
private player: Player,
|
||||
) {}
|
||||
|
||||
handleStructures(): boolean {
|
||||
const config = this.game.config();
|
||||
const citiesDisabled = config.isUnitDisabled(UnitType.City);
|
||||
const cityCount = citiesDisabled
|
||||
? Math.max(
|
||||
1,
|
||||
Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT),
|
||||
)
|
||||
: this.player.unitsOwned(UnitType.City);
|
||||
const hasCoastalTiles = this.hasCoastalTiles();
|
||||
|
||||
// Build order for non-city structures (priority order)
|
||||
const buildOrder: UnitType[] = [
|
||||
UnitType.DefensePost,
|
||||
UnitType.Port,
|
||||
UnitType.Factory,
|
||||
UnitType.SAMLauncher,
|
||||
UnitType.MissileSilo,
|
||||
];
|
||||
|
||||
const nukesEnabled =
|
||||
!config.isUnitDisabled(UnitType.AtomBomb) ||
|
||||
!config.isUnitDisabled(UnitType.HydrogenBomb) ||
|
||||
!config.isUnitDisabled(UnitType.MIRV);
|
||||
const missileSilosEnabled = !config.isUnitDisabled(UnitType.MissileSilo);
|
||||
|
||||
for (const structureType of buildOrder) {
|
||||
// Skip disabled structure types
|
||||
if (config.isUnitDisabled(structureType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip ports if no coastal tiles
|
||||
if (structureType === UnitType.Port && !hasCoastalTiles) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip missile silos and SAM launchers if all nukes are disabled
|
||||
if (
|
||||
!nukesEnabled &&
|
||||
(structureType === UnitType.MissileSilo ||
|
||||
structureType === UnitType.SAMLauncher)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SAM launchers if missile silos are disabled
|
||||
if (!missileSilosEnabled && structureType === UnitType.SAMLauncher) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
this.shouldBuildStructure(structureType, cityCount, hasCoastalTiles)
|
||||
) {
|
||||
if (this.maybeSpawnStructure(structureType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!citiesDisabled && this.maybeSpawnStructure(UnitType.City)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private hasCoastalTiles(): boolean {
|
||||
for (const tile of this.player.borderTiles()) {
|
||||
if (this.game.isOceanShore(tile)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if we should build more of this structure type based on
|
||||
* the current city count and the configured ratio.
|
||||
*/
|
||||
private shouldBuildStructure(
|
||||
type: UnitType,
|
||||
cityCount: number,
|
||||
hasCoastalTiles: boolean,
|
||||
): boolean {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
const ratios = getStructureRatios(difficulty);
|
||||
const config = ratios[type];
|
||||
if (config === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ratio = config.ratioPerCity;
|
||||
|
||||
// Heavily reduce factory spawning if we have coastal tiles
|
||||
if (
|
||||
type === UnitType.Factory &&
|
||||
hasCoastalTiles &&
|
||||
!this.game.config().isUnitDisabled(UnitType.Port)
|
||||
) {
|
||||
ratio *= FACTORY_COASTAL_RATIO_MULTIPLIER;
|
||||
}
|
||||
|
||||
const owned = this.player.unitsOwned(type);
|
||||
|
||||
// Hard cap on missile silos
|
||||
if (type === UnitType.MissileSilo && owned >= MAX_MISSILE_SILOS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Density cap on defense posts (can't be upgraded so a new one would be built - problematic if it's a game with high starting gold)
|
||||
if (type === UnitType.DefensePost) {
|
||||
const tilesOwned = this.player.numTilesOwned();
|
||||
if (
|
||||
tilesOwned > 0 &&
|
||||
owned / tilesOwned >= DEFENSE_POST_DENSITY_THRESHOLD
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const targetCount = Math.floor(cityCount * ratio);
|
||||
|
||||
return owned < targetCount;
|
||||
}
|
||||
|
||||
private cost(type: UnitType): Gold {
|
||||
return this.game.unitInfo(type).cost(this.game, this.player);
|
||||
}
|
||||
|
||||
private maybeSpawnStructure(type: UnitType): boolean {
|
||||
const perceivedCost = this.getPerceivedCost(type);
|
||||
if (this.player.gold() < perceivedCost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we should upgrade instead of building new
|
||||
const structures = this.player.units(type);
|
||||
if (
|
||||
this.getTotalStructureDensity() > UPGRADE_DENSITY_THRESHOLD &&
|
||||
type !== UnitType.DefensePost
|
||||
) {
|
||||
if (this.maybeUpgradeStructure(structures)) {
|
||||
return true;
|
||||
}
|
||||
// Density too high but couldn't upgrade (e.g. all under construction) — don't build new, wait for construction (most relevant for SAMs)
|
||||
if (structures.length > 0) {
|
||||
return false;
|
||||
}
|
||||
// No structures of this type exist yet — fall through to build the first one
|
||||
// (even if density is high - the nation is probably on a tiny island and we need to use all building spots we can find)
|
||||
}
|
||||
|
||||
const tile = this.structureSpawnTile(type);
|
||||
if (tile === null) {
|
||||
return false;
|
||||
}
|
||||
const canBuild = this.player.canBuild(type, tile);
|
||||
if (canBuild === false) {
|
||||
return false;
|
||||
}
|
||||
this.game.addExecution(new ConstructionExecution(this.player, type, tile));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the perceived cost for a structure type.
|
||||
* The perceived cost increases by a percentage for each structure of that type already owned.
|
||||
* This makes nations save up gold for nukes.
|
||||
* Once the nation can afford its target stockpile, stop inflating costs.
|
||||
*/
|
||||
private getPerceivedCost(type: UnitType): Gold {
|
||||
const realCost = this.cost(type);
|
||||
|
||||
const saveUpTarget = this.getSaveUpTarget();
|
||||
if (saveUpTarget === 0n || this.player.gold() >= saveUpTarget) {
|
||||
return realCost;
|
||||
}
|
||||
|
||||
const owned = this.player.unitsOwned(type);
|
||||
|
||||
let increasePerOwned: number;
|
||||
if (type === UnitType.City) {
|
||||
increasePerOwned = CITY_PERCEIVED_COST_INCREASE_PER_OWNED;
|
||||
} else {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
const ratios = getStructureRatios(difficulty);
|
||||
const config = ratios[type];
|
||||
increasePerOwned = config?.perceivedCostIncreasePerOwned ?? 0.1;
|
||||
}
|
||||
|
||||
// Each owned structure makes the next one feel more expensive
|
||||
// Formula: realCost * (1 + increasePerOwned * owned)
|
||||
const multiplier = 1 + increasePerOwned * owned;
|
||||
return BigInt(Math.ceil(Number(realCost) * multiplier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the gold target we want to save up for based on which nukes are enabled.
|
||||
* Returns 0 if no saving is needed.
|
||||
*/
|
||||
private getSaveUpTarget(): Gold {
|
||||
const config = this.game.config();
|
||||
|
||||
// No need to save up if missile silos are disabled
|
||||
if (config.isUnitDisabled(UnitType.MissileSilo)) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
const mirvEnabled = !config.isUnitDisabled(UnitType.MIRV);
|
||||
const hydroEnabled = !config.isUnitDisabled(UnitType.HydrogenBomb);
|
||||
const atomEnabled = !config.isUnitDisabled(UnitType.AtomBomb);
|
||||
|
||||
if (mirvEnabled) {
|
||||
// Save up for MIRV + Hydrogen Bomb
|
||||
return this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb);
|
||||
}
|
||||
if (hydroEnabled) {
|
||||
// Save up for 5 hydrogen bombs
|
||||
return this.cost(UnitType.HydrogenBomb) * 5n;
|
||||
}
|
||||
if (atomEnabled) {
|
||||
// Save up for 20 atom bombs
|
||||
return this.cost(UnitType.AtomBomb) * 20n;
|
||||
}
|
||||
// No nukes enabled, no need to save up
|
||||
return 0n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to upgrade an existing structure if density threshold is exceeded.
|
||||
* @param structures The pool of structures to consider for upgrading
|
||||
* @returns true if an upgrade was initiated, false otherwise
|
||||
*/
|
||||
private maybeUpgradeStructure(structures: Unit[]): boolean {
|
||||
if (this.getTotalStructureDensity() <= UPGRADE_DENSITY_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
if (structures.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const structureToUpgrade = this.findBestStructureToUpgrade(structures);
|
||||
if (
|
||||
structureToUpgrade !== null &&
|
||||
this.player.canUpgradeUnit(structureToUpgrade)
|
||||
) {
|
||||
this.game.addExecution(
|
||||
new UpgradeStructureExecution(this.player, structureToUpgrade.id()),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total structure density across player's territory.
|
||||
*/
|
||||
private getTotalStructureDensity(): number {
|
||||
let totalStructures = 0;
|
||||
for (const type of StructureTypes) {
|
||||
totalStructures += this.player.units(type).length; // ignoring levels
|
||||
}
|
||||
const tilesOwned = this.player.numTilesOwned();
|
||||
return tilesOwned > 0 ? totalStructures / tilesOwned : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best structure to upgrade, preferring structures protected by a SAM.
|
||||
* In 50% of cases, picks the second or third best to add variety.
|
||||
*/
|
||||
private findBestStructureToUpgrade(structures: Unit[]): Unit | null {
|
||||
if (structures.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter to only upgradable structures
|
||||
const upgradable = structures.filter((s) => this.player.canUpgradeUnit(s));
|
||||
if (upgradable.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Based on difficulty, chance to just pick a random structure
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
let randomChance: number;
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
randomChance = 70;
|
||||
break;
|
||||
case Difficulty.Medium:
|
||||
randomChance = 40;
|
||||
break;
|
||||
case Difficulty.Hard:
|
||||
randomChance = 25;
|
||||
break;
|
||||
case Difficulty.Impossible:
|
||||
randomChance = 10;
|
||||
break;
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
|
||||
if (this.random.nextInt(0, 100) < randomChance) {
|
||||
return this.random.randElement(upgradable);
|
||||
}
|
||||
|
||||
const samLaunchers = this.player.units(UnitType.SAMLauncher);
|
||||
|
||||
// Score each structure based on SAM protection
|
||||
const scored: { structure: Unit; score: number }[] = [];
|
||||
|
||||
for (const structure of upgradable) {
|
||||
let score = 0;
|
||||
|
||||
// Check if protected by any SAM, using per-SAM level-based range
|
||||
for (const sam of samLaunchers) {
|
||||
const samRange = this.game.config().samRange(sam.level());
|
||||
const samRangeSquared = samRange * samRange;
|
||||
const distSquared = this.game.euclideanDistSquared(
|
||||
structure.tile(),
|
||||
sam.tile(),
|
||||
);
|
||||
if (distSquared <= samRangeSquared) {
|
||||
// Protected by this SAM, add score based on SAM level
|
||||
score += 10;
|
||||
if (sam.level() > 1) {
|
||||
score += (sam.level() - 1) * 7.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add small random factor to break ties
|
||||
score += this.random.nextInt(0, 5);
|
||||
|
||||
scored.push({ structure, score });
|
||||
}
|
||||
|
||||
if (scored.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort descending by score
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
// 50% of the time, pick the second or third best for variety
|
||||
if (scored.length >= 2 && this.random.chance(2)) {
|
||||
const pickIndex =
|
||||
scored.length >= 3
|
||||
? this.random.nextInt(1, 3) // pick index 1 or 2
|
||||
: 1; // only index 1 available
|
||||
return scored[pickIndex].structure;
|
||||
}
|
||||
|
||||
return scored[0].structure;
|
||||
}
|
||||
|
||||
private structureSpawnTile(type: UnitType): TileRef | null {
|
||||
const tiles =
|
||||
type === UnitType.Port
|
||||
? this.randCoastalTileArray(25)
|
||||
: randTerritoryTileArray(this.random, this.game, this.player, 25);
|
||||
if (tiles.length === 0) return null;
|
||||
const valueFunction = this.structureSpawnTileValue(type);
|
||||
if (valueFunction === null) return null;
|
||||
let bestTile: TileRef | null = null;
|
||||
let bestValue = 0;
|
||||
for (const t of tiles) {
|
||||
const v = valueFunction(t);
|
||||
if (v <= bestValue && bestTile !== null) continue;
|
||||
if (!this.player.canBuild(type, t)) continue;
|
||||
// Found a better tile
|
||||
bestTile = t;
|
||||
bestValue = v;
|
||||
}
|
||||
return bestTile;
|
||||
}
|
||||
|
||||
private randCoastalTileArray(numTiles: number): TileRef[] {
|
||||
const tiles = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.game.isOceanShore(t),
|
||||
);
|
||||
return Array.from(this.arraySampler(tiles, numTiles));
|
||||
}
|
||||
|
||||
private *arraySampler<T>(a: T[], sampleSize: number): Generator<T> {
|
||||
if (a.length <= sampleSize) {
|
||||
// Return all elements
|
||||
yield* a;
|
||||
} else {
|
||||
// Sample `sampleSize` elements
|
||||
const remaining = new Set<T>(a);
|
||||
while (sampleSize--) {
|
||||
const t = this.random.randFromSet(remaining);
|
||||
remaining.delete(t);
|
||||
yield t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private structureSpawnTileValue(
|
||||
type: UnitType,
|
||||
): ((tile: TileRef) => number) | null {
|
||||
switch (type) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo:
|
||||
return this.interiorStructureValue(type);
|
||||
case UnitType.Port:
|
||||
return this.portValue();
|
||||
case UnitType.DefensePost:
|
||||
return this.defensePostValue();
|
||||
case UnitType.SAMLauncher:
|
||||
return this.samLauncherValue();
|
||||
default:
|
||||
throw new Error(`Value function not implemented for ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for interior structures (City, Factory, MissileSilo).
|
||||
* Prefers high elevation, distance from border, and spacing from same-type structures.
|
||||
*/
|
||||
private interiorStructureValue(type: UnitType): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const borderTiles = this.player.borderTiles();
|
||||
const otherUnits = this.player.units(type);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const [, closestBorderDist] = closestTile(game, borderTiles, tile);
|
||||
w += Math.min(closestBorderDist, borderSpacing);
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = game.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for ports.
|
||||
* Prefers spacing from other ports.
|
||||
*/
|
||||
private portValue(): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const otherUnits = this.player.units(UnitType.Port);
|
||||
const { structureSpacing } = this.spacingConstants();
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const [, closestOtherDist] = closestTile(game, otherTiles, tile);
|
||||
w += Math.min(closestOtherDist, structureSpacing);
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for defense posts.
|
||||
* Returns null if there are no hostile non-bot neighbors.
|
||||
* Prefers elevation, proximity to border with hostile neighbors, and spacing.
|
||||
*/
|
||||
private defensePostValue(): ((tile: TileRef) => number) | null {
|
||||
const game = this.game;
|
||||
const player = this.player;
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(UnitType.DefensePost);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
// Check if we have any non-friendly non-bot neighbors with more troops
|
||||
const hasHostileNeighbor =
|
||||
player
|
||||
.neighbors()
|
||||
.filter(
|
||||
(n): n is Player =>
|
||||
n.isPlayer() &&
|
||||
player.isFriendly(n) === false &&
|
||||
n.type() !== PlayerType.Bot &&
|
||||
n.troops() > player.troops(),
|
||||
).length > 0;
|
||||
|
||||
// Don't build defense posts if there is no danger
|
||||
if (!hasHostileNeighbor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
const [closest, closestBorderDist] = closestTile(game, borderTiles, tile);
|
||||
if (closest !== null) {
|
||||
// Prefer to be borderSpacing tiles from the border
|
||||
w += Math.max(
|
||||
0,
|
||||
borderSpacing - Math.abs(borderSpacing - closestBorderDist),
|
||||
);
|
||||
|
||||
// Prefer adjacent players who are hostile and have more troops
|
||||
const neighbors: Set<Player> = new Set();
|
||||
for (const neighborTile of game.neighbors(closest)) {
|
||||
if (!game.isLand(neighborTile)) continue;
|
||||
const id = game.ownerID(neighborTile);
|
||||
if (id === player.smallID()) continue;
|
||||
const neighbor = game.playerBySmallID(id);
|
||||
if (!neighbor.isPlayer()) continue;
|
||||
if (neighbor.type() === PlayerType.Bot) continue;
|
||||
if (neighbor.troops() <= player.troops()) continue;
|
||||
neighbors.add(neighbor);
|
||||
}
|
||||
for (const neighbor of neighbors) {
|
||||
w += borderSpacing * (Relation.Friendly - player.relation(neighbor));
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = game.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for SAM launchers.
|
||||
* Prefers elevation, distance from border, spacing, and proximity to protectable structures.
|
||||
* On harder difficulties, weights by structure level and considers existing SAM coverage.
|
||||
*/
|
||||
private samLauncherValue(): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const player = this.player;
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(UnitType.SAMLauncher);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
const { difficulty } = game.config().gameConfig();
|
||||
const weightByLevel =
|
||||
difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible;
|
||||
|
||||
const protectEntries: { tile: TileRef; weight: number }[] = [];
|
||||
for (const unit of player.units()) {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.Port:
|
||||
protectEntries.push({
|
||||
tile: unit.tile(),
|
||||
weight: weightByLevel ? unit.level() : 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
const range = game.config().defaultSamRange();
|
||||
const rangeSquared = range * range;
|
||||
|
||||
const useCoverageWeighting =
|
||||
difficulty !== Difficulty.Easy && this.random.nextInt(0, 100) < 25;
|
||||
|
||||
// Pre-compute existing SAM coverage for each protectable structure
|
||||
let structureCoverage: Map<TileRef, number> | null = null;
|
||||
if (useCoverageWeighting) {
|
||||
structureCoverage = new Map<TileRef, number>();
|
||||
const existingSams = player.units(UnitType.SAMLauncher);
|
||||
for (const entry of protectEntries) {
|
||||
let coverageScore = 0;
|
||||
for (const sam of existingSams) {
|
||||
const samRange = game.config().samRange(sam.level());
|
||||
const dist = game.euclideanDistSquared(entry.tile, sam.tile());
|
||||
if (dist <= samRange * samRange) {
|
||||
coverageScore += sam.level();
|
||||
}
|
||||
}
|
||||
structureCoverage.set(entry.tile, coverageScore);
|
||||
}
|
||||
}
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const closestBorder = closestTwoTiles(game, borderTiles, [tile]);
|
||||
if (closestBorder !== null) {
|
||||
const d = game.manhattanDist(closestBorder.x, tile);
|
||||
w += Math.min(d, borderSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = game.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be in range of other structures (skip on easy difficulty)
|
||||
if (difficulty !== Difficulty.Easy) {
|
||||
for (const entry of protectEntries) {
|
||||
const distanceSquared = game.euclideanDistSquared(tile, entry.tile);
|
||||
if (distanceSquared > rangeSquared) continue;
|
||||
if (useCoverageWeighting && structureCoverage !== null) {
|
||||
const coverage = structureCoverage.get(entry.tile) ?? 0;
|
||||
const coverageWeight = 1 / (1 + coverage);
|
||||
w += structureSpacing * entry.weight * coverageWeight;
|
||||
} else {
|
||||
w += structureSpacing * entry.weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared spacing constants derived from atom bomb range. */
|
||||
private spacingConstants(): {
|
||||
borderSpacing: number;
|
||||
structureSpacing: number;
|
||||
} {
|
||||
const borderSpacing = this.game
|
||||
.config()
|
||||
.nukeMagnitudes(UnitType.AtomBomb).outer;
|
||||
return { borderSpacing, structureSpacing: borderSpacing * 2 };
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { Game, Player, PlayerType, Relation, UnitType } from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { closestTile, closestTwoTiles } from "../Util";
|
||||
|
||||
export function structureSpawnTileValue(
|
||||
mg: Game,
|
||||
player: Player,
|
||||
type: UnitType,
|
||||
): ((tile: TileRef) => number) | null {
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(type);
|
||||
// Prefer spacing structures out of atom bomb range
|
||||
const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer;
|
||||
const structureSpacing = borderSpacing * 2;
|
||||
switch (type) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo: {
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += mg.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const [, closestBorderDist] = closestTile(mg, borderTiles, tile);
|
||||
w += Math.min(closestBorderDist, borderSpacing);
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = mg.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
// TODO: Cities and factories should consider train range limits
|
||||
return w;
|
||||
};
|
||||
}
|
||||
case UnitType.Port: {
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const [, closestOtherDist] = closestTile(mg, otherTiles, tile);
|
||||
w += Math.min(closestOtherDist, structureSpacing);
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
case UnitType.DefensePost: {
|
||||
// Check if we have any non-friendly non-bot neighbors
|
||||
const hasHostileNeighbor =
|
||||
player
|
||||
.neighbors()
|
||||
.filter(
|
||||
(n): n is Player =>
|
||||
n.isPlayer() &&
|
||||
player.isFriendly(n) === false &&
|
||||
n.type() !== PlayerType.Bot,
|
||||
).length > 0;
|
||||
|
||||
// Don't build defense posts if there is no danger
|
||||
if (!hasHostileNeighbor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += mg.magnitude(tile);
|
||||
|
||||
const [closest, closestBorderDist] = closestTile(mg, borderTiles, tile);
|
||||
if (closest !== null) {
|
||||
// Prefer to be borderSpacing tiles from the border
|
||||
w += Math.max(
|
||||
0,
|
||||
borderSpacing - Math.abs(borderSpacing - closestBorderDist),
|
||||
);
|
||||
|
||||
// Prefer adjacent players who are hostile
|
||||
const neighbors: Set<Player> = new Set();
|
||||
for (const tile of mg.neighbors(closest)) {
|
||||
if (!mg.isLand(tile)) continue;
|
||||
const id = mg.ownerID(tile);
|
||||
if (id === player.smallID()) continue;
|
||||
const neighbor = mg.playerBySmallID(id);
|
||||
if (!neighbor.isPlayer()) continue;
|
||||
if (neighbor.type() === PlayerType.Bot) continue;
|
||||
neighbors.add(neighbor);
|
||||
}
|
||||
for (const neighbor of neighbors) {
|
||||
w +=
|
||||
borderSpacing * (Relation.Friendly - player.relation(neighbor));
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = mg.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
case UnitType.SAMLauncher: {
|
||||
const protectTiles: Set<TileRef> = new Set();
|
||||
for (const unit of player.units()) {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
case UnitType.MissileSilo:
|
||||
case UnitType.Port:
|
||||
protectTiles.add(unit.tile());
|
||||
}
|
||||
}
|
||||
const range = mg.config().defaultSamRange();
|
||||
const rangeSquared = range * range;
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += mg.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const closestBorder = closestTwoTiles(mg, borderTiles, [tile]);
|
||||
if (closestBorder !== null) {
|
||||
const d = mg.manhattanDist(closestBorder.x, tile);
|
||||
w += Math.min(d, borderSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be away from other structures of the same type
|
||||
const otherTiles: Set<TileRef> = new Set(
|
||||
otherUnits.map((u) => u.tile()),
|
||||
);
|
||||
otherTiles.delete(tile);
|
||||
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
|
||||
if (closestOther !== null) {
|
||||
const d = mg.manhattanDist(closestOther.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
// Prefer to be in range of other structures
|
||||
for (const maybeProtected of protectTiles) {
|
||||
const distanceSquared = mg.euclideanDistSquared(tile, maybeProtected);
|
||||
if (distanceSquared > rangeSquared) continue;
|
||||
w += structureSpacing;
|
||||
}
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Value function not implemented for ${type}`);
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ export enum GameMapType {
|
||||
DidierFrance = "Didier France",
|
||||
AmazonRiver = "Amazon River",
|
||||
Yenisei = "Yenisei",
|
||||
TradersDream = "Traders Dream",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -178,6 +179,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.FourIslands,
|
||||
GameMapType.Svalmel,
|
||||
GameMapType.Surrounded,
|
||||
GameMapType.TradersDream,
|
||||
],
|
||||
arcade: [
|
||||
GameMapType.TheBox,
|
||||
@@ -845,6 +847,7 @@ export interface BuildableUnit {
|
||||
canUpgrade: number | false;
|
||||
type: UnitType;
|
||||
cost: Gold;
|
||||
overlappingRailroads: number[];
|
||||
}
|
||||
|
||||
export interface PlayerProfile {
|
||||
|
||||
@@ -44,7 +44,9 @@ export enum GameUpdateType {
|
||||
Hash,
|
||||
UnitIncoming,
|
||||
BonusEvent,
|
||||
RailroadEvent,
|
||||
RailroadDestructionEvent,
|
||||
RailroadConstructionEvent,
|
||||
RailroadSnapEvent,
|
||||
ConquestEvent,
|
||||
EmbargoEvent,
|
||||
GamePaused,
|
||||
@@ -67,7 +69,9 @@ export type GameUpdate =
|
||||
| UnitIncomingUpdate
|
||||
| AllianceExtensionUpdate
|
||||
| BonusEventUpdate
|
||||
| RailroadUpdate
|
||||
| RailroadConstructionUpdate
|
||||
| RailroadDestructionUpdate
|
||||
| RailroadSnapUpdate
|
||||
| ConquestUpdate
|
||||
| EmbargoUpdate
|
||||
| GamePausedUpdate;
|
||||
@@ -80,24 +84,24 @@ export interface BonusEventUpdate {
|
||||
troops: number;
|
||||
}
|
||||
|
||||
export enum RailType {
|
||||
VERTICAL,
|
||||
HORIZONTAL,
|
||||
TOP_LEFT,
|
||||
TOP_RIGHT,
|
||||
BOTTOM_LEFT,
|
||||
BOTTOM_RIGHT,
|
||||
export interface RailroadConstructionUpdate {
|
||||
type: GameUpdateType.RailroadConstructionEvent;
|
||||
id: number;
|
||||
tiles: TileRef[];
|
||||
}
|
||||
|
||||
export interface RailTile {
|
||||
tile: TileRef;
|
||||
railType: RailType;
|
||||
export interface RailroadDestructionUpdate {
|
||||
type: GameUpdateType.RailroadDestructionEvent;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface RailroadUpdate {
|
||||
type: GameUpdateType.RailroadEvent;
|
||||
isActive: boolean;
|
||||
railTiles: RailTile[];
|
||||
export interface RailroadSnapUpdate {
|
||||
type: GameUpdateType.RailroadSnapEvent;
|
||||
originalId: number;
|
||||
newId1: number;
|
||||
newId2: number;
|
||||
tiles1: TileRef[];
|
||||
tiles2: TileRef[];
|
||||
}
|
||||
|
||||
export interface ConquestUpdate {
|
||||
|
||||
@@ -603,12 +603,20 @@ export class GameView implements GameMap {
|
||||
private _config: Config,
|
||||
private _mapData: TerrainMapData,
|
||||
private _myClientID: ClientID,
|
||||
private _myUsername: string,
|
||||
private _gameID: GameID,
|
||||
private humans: Player[],
|
||||
) {
|
||||
this._map = this._mapData.gameMap;
|
||||
this.lastUpdate = null;
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
// Replace the local player's username with their own stored username.
|
||||
// This way the user does not know they are being censored.
|
||||
for (const h of this.humans) {
|
||||
if (h.clientID === this._myClientID) {
|
||||
h.username = this._myUsername;
|
||||
}
|
||||
}
|
||||
this._cosmetics = new Map(
|
||||
this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
|
||||
);
|
||||
|
||||
@@ -960,20 +960,25 @@ export class PlayerImpl implements Player {
|
||||
const validTiles = tile !== null ? this.validStructureSpawnTiles(tile) : [];
|
||||
return Object.values(UnitType).map((u) => {
|
||||
let canUpgrade: number | false = false;
|
||||
let canBuild: TileRef | false = false;
|
||||
if (!this.mg.inSpawnPhase()) {
|
||||
const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile);
|
||||
if (existingUnit !== false) {
|
||||
canUpgrade = existingUnit.id();
|
||||
}
|
||||
if (tile !== null) {
|
||||
canBuild = this.canBuild(u, tile, validTiles);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: u,
|
||||
canBuild:
|
||||
this.mg.inSpawnPhase() || tile === null
|
||||
? false
|
||||
: this.canBuild(u, tile, validTiles),
|
||||
canUpgrade: canUpgrade,
|
||||
canBuild,
|
||||
canUpgrade,
|
||||
cost: this.mg.config().unitInfo(u).cost(this.mg, this),
|
||||
overlappingRailroads:
|
||||
canBuild !== false
|
||||
? this.mg.railNetwork().overlappingRailroads(canBuild)
|
||||
: [],
|
||||
} as BuildableUnit;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Unit } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { StationManager } from "./RailNetworkImpl";
|
||||
import { TrainStation } from "./TrainStation";
|
||||
|
||||
@@ -7,4 +8,5 @@ export interface RailNetwork {
|
||||
removeStation(unit: Unit): void;
|
||||
findStationsPath(from: TrainStation, to: TrainStation): TrainStation[];
|
||||
stationManager(): StationManager;
|
||||
overlappingRailroads(tile: TileRef): number[];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RailroadExecution } from "../execution/RailroadExecution";
|
||||
import { PathFinding } from "../pathfinding/PathFinder";
|
||||
import { Game, Unit, UnitType } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType } from "./GameUpdates";
|
||||
import { RailNetwork } from "./RailNetwork";
|
||||
import { Railroad } from "./Railroad";
|
||||
import { RailSpatialGrid } from "./RailroadSpatialGrid";
|
||||
@@ -85,6 +85,7 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
private stationRadius: number = 3;
|
||||
private gridCellSize: number = 4;
|
||||
private railGrid: RailSpatialGrid;
|
||||
private nextId: number = 0;
|
||||
|
||||
constructor(
|
||||
private game: Game,
|
||||
@@ -141,6 +142,7 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
for (const rail of rails) {
|
||||
const from = rail.from;
|
||||
const to = rail.to;
|
||||
const originalId = rail.id;
|
||||
const closestRailIndex = rail.getClosestTileIndex(
|
||||
this.game,
|
||||
station.tile(),
|
||||
@@ -158,11 +160,13 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
from,
|
||||
station,
|
||||
rail.tiles.slice(0, closestRailIndex),
|
||||
this.nextId++,
|
||||
);
|
||||
const newRailTo = new Railroad(
|
||||
station,
|
||||
to,
|
||||
rail.tiles.slice(closestRailIndex),
|
||||
this.nextId++,
|
||||
);
|
||||
|
||||
// New station is connected to both new rails
|
||||
@@ -179,6 +183,14 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
cluster.addStation(station);
|
||||
editedClusters.add(cluster);
|
||||
}
|
||||
this.game.addUpdate({
|
||||
type: GameUpdateType.RailroadSnapEvent,
|
||||
originalId,
|
||||
newId1: newRailFrom.id,
|
||||
newId2: newRailTo.id,
|
||||
tiles1: newRailFrom.tiles,
|
||||
tiles2: newRailTo.tiles,
|
||||
});
|
||||
}
|
||||
// If multiple clusters own the new station, merge them into a single cluster
|
||||
if (editedClusters.size > 1) {
|
||||
@@ -187,6 +199,12 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
return editedClusters.size !== 0;
|
||||
}
|
||||
|
||||
overlappingRailroads(tile: TileRef): number[] {
|
||||
return [...this.railGrid.query(tile, this.stationRadius)].map(
|
||||
(railroad: Railroad) => railroad.id,
|
||||
);
|
||||
}
|
||||
|
||||
private connectToNearbyStations(station: TrainStation) {
|
||||
const neighbors = this.game.nearbyUnits(
|
||||
station.tile(),
|
||||
@@ -256,11 +274,15 @@ export class RailNetworkImpl implements RailNetwork {
|
||||
private connect(from: TrainStation, to: TrainStation) {
|
||||
const path = this.pathService.findTilePath(from.tile(), to.tile());
|
||||
if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) {
|
||||
const railRoad = new Railroad(from, to, path);
|
||||
this.game.addExecution(new RailroadExecution(railRoad));
|
||||
from.addRailroad(railRoad);
|
||||
to.addRailroad(railRoad);
|
||||
this.railGrid.register(railRoad);
|
||||
const railroad = new Railroad(from, to, path, this.nextId++);
|
||||
this.game.addUpdate({
|
||||
type: GameUpdateType.RailroadConstructionEvent,
|
||||
id: railroad.id,
|
||||
tiles: railroad.tiles,
|
||||
});
|
||||
from.addRailroad(railroad);
|
||||
to.addRailroad(railroad);
|
||||
this.railGrid.register(railroad);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Game } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
|
||||
import { GameUpdateType } from "./GameUpdates";
|
||||
import { TrainStation } from "./TrainStation";
|
||||
|
||||
export class Railroad {
|
||||
@@ -8,17 +8,13 @@ export class Railroad {
|
||||
public from: TrainStation,
|
||||
public to: TrainStation,
|
||||
public tiles: TileRef[],
|
||||
public id: number,
|
||||
) {}
|
||||
|
||||
delete(game: Game) {
|
||||
const railTiles: RailTile[] = this.tiles.map((tile) => ({
|
||||
tile,
|
||||
railType: RailType.VERTICAL,
|
||||
}));
|
||||
game.addUpdate({
|
||||
type: GameUpdateType.RailroadEvent,
|
||||
isActive: false,
|
||||
railTiles,
|
||||
type: GameUpdateType.RailroadDestructionEvent,
|
||||
id: this.id,
|
||||
});
|
||||
this.from.removeRailroad(this);
|
||||
this.to.removeRailroad(this);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TrainExecution } from "../execution/TrainExecution";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { Game, Player, Unit, UnitType } from "./Game";
|
||||
import { TileRef } from "./GameMap";
|
||||
import { GameUpdateType, RailTile, RailType } from "./GameUpdates";
|
||||
import { GameUpdateType } from "./GameUpdates";
|
||||
import { Railroad } from "./Railroad";
|
||||
|
||||
/**
|
||||
@@ -92,14 +92,9 @@ export class TrainStation {
|
||||
(r) => r.from === station || r.to === station,
|
||||
);
|
||||
if (toRemove) {
|
||||
const railTiles: RailTile[] = toRemove.tiles.map((tile) => ({
|
||||
tile,
|
||||
railType: RailType.VERTICAL,
|
||||
}));
|
||||
this.mg.addUpdate({
|
||||
type: GameUpdateType.RailroadEvent,
|
||||
isActive: false,
|
||||
railTiles,
|
||||
type: GameUpdateType.RailroadDestructionEvent,
|
||||
id: toRemove.id,
|
||||
});
|
||||
this.removeRailroad(toRemove);
|
||||
}
|
||||
|
||||
@@ -1,92 +1,9 @@
|
||||
import {
|
||||
RegExpMatcher,
|
||||
collapseDuplicatesTransformer,
|
||||
englishDataset,
|
||||
englishRecommendedTransformers,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
} from "obscenity";
|
||||
import { translateText } from "../../client/Utils";
|
||||
import { UsernameSchema } from "../Schemas";
|
||||
import { getClanTagOriginalCase, simpleHash } from "../Util";
|
||||
|
||||
const matcher = new RegExpMatcher({
|
||||
...englishDataset.build(),
|
||||
...englishRecommendedTransformers,
|
||||
...resolveConfusablesTransformer(),
|
||||
...skipNonAlphabeticTransformer(),
|
||||
...collapseDuplicatesTransformer(),
|
||||
...resolveLeetSpeakTransformer(),
|
||||
});
|
||||
|
||||
export const MIN_USERNAME_LENGTH = 3;
|
||||
export const MAX_USERNAME_LENGTH = 27;
|
||||
|
||||
const shadowNames = [
|
||||
"NicePeopleOnly",
|
||||
"BeKindPlz",
|
||||
"LearningManners",
|
||||
"StayClassy",
|
||||
"BeNicer",
|
||||
"NeedHugs",
|
||||
"MakeFriends",
|
||||
];
|
||||
|
||||
export function fixProfaneUsername(username: string): string {
|
||||
if (isProfaneUsername(username)) {
|
||||
return shadowNames[simpleHash(username) % shadowNames.length];
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
export function isProfaneUsername(username: string): boolean {
|
||||
return matcher.hasMatch(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and censors profane usernames and clan tags.
|
||||
* Profane username is overwritten, profane clan tag is removed.
|
||||
*
|
||||
* Preserves non-profane clan tag:
|
||||
* prevents desync after clan team assignment because local player's own clan tag and name aren't overwritten
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
* - full name including clan tag was overwritten in the past, if any part of name was bad
|
||||
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
* - "BadName" -> "Censored"
|
||||
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
|
||||
* - "[CLaN]BadName" -> "[CLaN] Censored"
|
||||
* - "[BAD]GoodName" -> "GoodName"
|
||||
* - "[BAD]BadName" -> "Censored"
|
||||
*/
|
||||
export function censorNameWithClanTag(username: string): string {
|
||||
// Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match
|
||||
const clanTag = getClanTagOriginalCase(username);
|
||||
|
||||
const nameWithoutClan = clanTag
|
||||
? username.replace(`[${clanTag}]`, "").trim()
|
||||
: username;
|
||||
|
||||
const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false;
|
||||
const usernameIsProfane = isProfaneUsername(nameWithoutClan);
|
||||
|
||||
const censoredNameWithoutClan = usernameIsProfane
|
||||
? fixProfaneUsername(nameWithoutClan)
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag if it existed and is not profane
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
return `[${clanTag.toUpperCase()}] ${censoredNameWithoutClan}`;
|
||||
}
|
||||
|
||||
// Don't restore profane or nonexistent clan tag
|
||||
return censoredNameWithoutClan;
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
|
||||
Reference in New Issue
Block a user