AI_Diplomacy/diplomacy/engine/map.ts

2512 lines
120 KiB
TypeScript

// diplomacy/engine/map.ts
import * as fs from 'fs';
import * as path from 'path';
import { Buffer } from 'buffer';
// --- Logger ---
const logger = {
debug: (message: string, ...args: any[]) => console.debug('[Map]', message, ...args),
info: (message: string, ...args: any[]) => console.info('[Map]', message, ...args),
warn: (message: string, ...args: any[]) => console.warn('[Map]', message, ...args),
error: (message: string, ...args: any[]) => console.error('[Map]', message, ...args),
};
// --- Placeholders for imported constants and modules ---
const settings = {
PACKAGE_DIR: path.join(__dirname, '..', '..'),
};
// Constants for vet()
const UNDETERMINED = 0;
const POWER = 1;
const UNIT = 2;
const LOCATION = 3;
const COAST = 4;
const ORDER = 5;
const MOVE_SEP = 6;
const OTHER = 7;
const KEYWORDS: Record<string, string> = {
"ARMY": "A", "FLEET": "F",
"SUPPORTS": "S", "SUPPORT": "S", "SUPS": "S", "SUP": "S",
"CONVOYS": "C", "CONVOY": "C", "CON": "C",
"HOLDS": "H", "HOLD": "H",
"MOVES": "-", "MOVE": "-", "M": "-", "TO": "-", // "TO" can be ambiguous, but often implies move
"RETREATS": "R", "RETREAT": "R", "RET": "R",
"DISBANDS": "D", "DISBAND": "D", "DIS": "D",
"BUILDS": "B", "BUILD": "B",
"REMOVE": "D", // REMOVE maps to D for Disband
"VIA": "VIA",
"WAIVE": "WAIVE", "WAIVES": "WAIVE",
// Seasons and years are not keywords for order parsing here
// Coasts - these might be better handled by specific parsing logic if they appear in orders
"NC": "/NC", "NORTH_COAST": "/NC", "NCS": "/NC",
"SC": "/SC", "SOUTH_COAST": "/SC", "SCS": "/SC", // Note: SCS also conflicts with Supply Centers abbreviation
"EC": "/EC", "EAST_COAST": "/EC", "ECS": "/EC",
"WC": "/WC", "WEST_COAST": "/WC", "WCS": "/WC",
// Order types - some already above
"ORDER": "ORDER", // Placeholder if needed
// Power names - will be dynamically added to aliases
};
// Standard aliases from a typical 'standard' map, can be augmented by map files
const ALIASES: Record<string, string> = {
// Seas
"ENGLISH_CHANNEL": "ECH", "ENG": "ECH",
"IRISH_SEA": "IRI", "IRS": "IRI",
"NORTH_SEA": "NTH", "NTS": "NTH",
"NORWEGIAN_SEA": "NWG", "NWS": "NWG",
"HELGOLAND_BIGHT": "HEL", "HEB": "HEL",
"SKAGERRAK": "SKA",
"BALTIC_SEA": "BAL",
"GULF_OF_BOTHNIA": "GOB", "BOT": "GOB",
"BARRENTS_SEA": "BAR",
"MID-ATLANTIC_OCEAN": "MAO", "MID": "MAO", "MAT": "MAO",
"WESTERN_MEDITERRANEAN": "WME", "WES": "WME",
"GULF_OF_LYONS": "GOL", "LYO": "GOL",
"TYRRHENIAN_SEA": "TYS", "TYN": "TYS",
"IONIAN_SEA": "ION", "IOS": "ION",
"ADRIATIC_SEA": "ADR", "ADS": "ADR",
"AEGEAN_SEA": "AEG", "AES": "AEG",
"EASTERN_MEDITERRANEAN": "EME", "EAS": "EME",
"BLACK_SEA": "BLA", "BLS": "BLA",
// इंग्लैंड
"CLYDE": "CLY", "LONDON": "LON", "LIVERPOOL": "LVP", "WALES": "WAL", "YORKSHIRE": "YOR", "EDI": "EDI", "EDINBURGH": "EDI",
// France
"PICARDY": "PIC", "PARIS": "PAR", "BURGUNDY": "BUR", "BREST": "BRE", "GASCONY": "GAS", "MARSEILLES": "MAR",
// Germany
"KIEL": "KIE", "BERLIN": "BER", "PRUSSIA": "PRU", "SILESIA": "SIL", "MUNICH": "MUN", "RUHR": "RUH", "ALSACE": "ALS",
// Italy
"PIEDMONT": "PIE", "VENICE": "VEN", "TUSCANY": "TUS", "ROME": "ROM", "NAPLES": "NAP", "APULIA": "APU",
// Austria-Hungary
"TYROLIA": "TYR", "BOHEMIA": "BOH", "VIENNA": "VIE", "GALICIA": "GAL", "BUDAPEST": "BUD", "TRIESTE": "TRI",
// Turkey
"CONSTANTINOPLE": "CON", "ANKARA": "ANK", "SMYRNA": "SMY", "ARMENIA": "ARM", "SYRIA": "SYR",
// Russia
"ST_PETERSBURG": "STP", "STPETERSBURG": "STP", "STPETE": "STP", "ST_PETE": "STP",
"FINLAND": "FIN", "LIVONIA": "LIV", "MOSCOW": "MOS", "UKRAINE": "UKR", "WARSAW": "WAR", "SEVASTOPOL": "SEV",
// Neutral Supply Centers
"NORWAY": "NWY", "NOR": "NWY", "SWEDEN": "SWE", "DENMARK": "DEN", "HOLLAND": "HOL", "BELGIUM": "BEL",
"PORTUGAL": "POR", "SPAIN": "SPA", "TUNIS": "TUN", "GREECE": "GRE", "RUMANIA": "RUM", "SERBIA": "SER", "BULGARIA": "BUL",
// Other Land Provinces
"ALBANIA": "ALB", "NORTH_AFRICA": "NAF",
};
const err = {
MAP_FILE_NOT_FOUND: "MAP_FILE_NOT_FOUND: Map file %s not found.",
MAP_LEAST_TWO_POWERS: "MAP_LEAST_TWO_POWERS: Map must define at least two powers.",
MAP_LOC_NOT_FOUND: "MAP_LOC_NOT_FOUND: Location %s referenced but not defined.",
MAP_SITE_ABUTS_TWICE: "MAP_SITE_ABUTS_TWICE: Location %s abuts %s more than once.",
MAP_NO_FULL_NAME: "MAP_NO_FULL_NAME: Location %s has no full name defined.",
MAP_ONE_WAY_ADJ: "MAP_ONE_WAY_ADJ: Location %s lists %s as an adjacency, but not vice-versa.",
MAP_MISSING_ADJ: "MAP_MISSING_ADJ: Missing adjacency between %s and %s.",
MAP_BAD_HOME: "MAP_BAD_HOME: Power %s has an invalid home center: %s.",
MAP_BAD_INITIAL_OWN_CENTER: "MAP_BAD_INITIAL_OWN_CENTER: Power %s has an invalid initially owned center: %s.",
MAP_BAD_INITIAL_UNITS: "MAP_BAD_INITIAL_UNITS: Power %s has an invalid initial unit: %s.",
MAP_CENTER_MULT_OWNED: "MAP_CENTER_MULT_OWNED: Center %s is owned by multiple powers or listed multiple times.",
MAP_BAD_PHASE: "MAP_BAD_PHASE: Initial phase '%s' is invalid.",
MAP_BAD_VICTORY_LINE: "MAP_BAD_VICTORY_LINE: Victory condition line is malformed.",
MAP_BAD_ROOT_MAP_LINE: "MAP_BAD_ROOT_MAP_LINE: MAP directive is malformed.",
MAP_TWO_ROOT_MAPS: "MAP_TWO_ROOT_MAPS: Multiple MAP directives found (root map already defined).",
MAP_FILE_MULT_USED: "MAP_FILE_MULT_USED: Map file %s included multiple times via USE directive.",
MAP_BAD_ALIASES_IN_FILE: "MAP_BAD_ALIASES_IN_FILE: Alias definition line for '%s' is malformed.",
MAP_BAD_RENAME_DIRECTIVE: "MAP_BAD_RENAME_DIRECTIVE: Rename directive '%s' is malformed.",
MAP_INVALID_LOC_ABBREV: "MAP_INVALID_LOC_ABBREV: Location abbreviation '%s' is invalid.",
MAP_RENAME_NOT_SUPPORTED: "MAP_RENAME_NOT_SUPPORTED: Renaming locations or powers via 'old -> new' is not supported in this version.",
MAP_LOC_RESERVED_KEYWORD: "MAP_LOC_RESERVED_KEYWORD: Location name '%s' is a reserved keyword.",
MAP_DUP_LOC_OR_POWER: "MAP_DUP_LOC_OR_POWER: Duplicate location or power name, or alias conflict: %s.",
MAP_DUP_ALIAS_OR_POWER: "MAP_DUP_ALIAS_OR_POWER: Duplicate alias or power name conflict: %s.",
MAP_OWNS_BEFORE_POWER: "MAP_OWNS_BEFORE_POWER: %s directive found before a POWER directive. Current line: %s",
MAP_INHABITS_BEFORE_POWER: "MAP_INHABITS_BEFORE_POWER: INHABITS directive found before a POWER directive. Current line: %s",
MAP_HOME_BEFORE_POWER: "MAP_HOME_BEFORE_POWER: %s directive found before a POWER directive. Current line: %s",
MAP_UNITS_BEFORE_POWER: "MAP_UNITS_BEFORE_POWER: UNITS directive found before a POWER directive.",
MAP_UNIT_BEFORE_POWER: "MAP_UNIT_BEFORE_POWER: Unit definition found before a POWER directive.",
MAP_INVALID_UNIT: "MAP_INVALID_UNIT: Unit definition '%s' is invalid.",
MAP_DUMMY_REQ_LIST_POWERS: "MAP_DUMMY_REQ_LIST_POWERS: DUMMIES directive requires a list of powers or 'ALL'.",
MAP_DUMMY_BEFORE_POWER: "MAP_DUMMY_BEFORE_POWER: DUMMY directive for a single power found without a preceding POWER directive.",
MAP_NO_EXCEPT_AFTER_DUMMY_ALL: "MAP_NO_EXCEPT_AFTER_DUMMY_ALL: %s ALL must be followed by EXCEPT or end of line.",
MAP_NO_POWER_AFTER_DUMMY_ALL_EXCEPT: "MAP_NO_POWER_AFTER_DUMMY_ALL_EXCEPT: %s ALL EXCEPT must be followed by power names.",
MAP_NO_DATA_TO_AMEND_FOR: "MAP_NO_DATA_TO_AMEND_FOR: AMEND directive for '%s' found, but no existing data to amend.",
MAP_NO_ABUTS_FOR: "MAP_NO_ABUTS_FOR: Terrain definition for '%s' is missing ABUTS keyword or has malformed adjacencies.",
MAP_UNPLAYED_BEFORE_POWER: "MAP_UNPLAYED_BEFORE_POWER: UNPLAYED directive for a single power found without a preceding POWER directive.",
MAP_NO_EXCEPT_AFTER_UNPLAYED_ALL: "MAP_NO_EXCEPT_AFTER_UNPLAYED_ALL: UNPLAYED ALL must be followed by EXCEPT or end of line.",
MAP_NO_POWER_AFTER_UNPLAYED_ALL_EXCEPT: "MAP_NO_POWER_AFTER_UNPLAYED_ALL_EXCEPT: UNPLAYED ALL EXCEPT must be followed by power names.",
MAP_RENAMING_UNOWNED_DIR_NOT_ALLOWED: "MAP_RENAMING_UNOWNED_DIR_NOT_ALLOWED: Renaming UNOWNED or NEUTRAL is not allowed.",
MAP_RENAMING_UNDEF_POWER: "MAP_RENAMING_UNDEF_POWER: Attempting to rename undefined power: %s.",
MAP_POWER_NAME_EMPTY_KEYWORD: "MAP_POWER_NAME_EMPTY_KEYWORD: Power name '%s' normalizes to an empty string or keyword.",
MAP_POWER_NAME_CAN_BE_CONFUSED: "MAP_POWER_NAME_CAN_BE_CONFUSED: Power name '%s' (1 or 3 chars) can be confused with location or unit type.",
MAP_ILLEGAL_POWER_ABBREV: "MAP_ILLEGAL_POWER_ABBREV: Power abbreviation is invalid (e.g., 'M' or '?').",
MAP_NO_SUCH_POWER_TO_REMOVE: "MAP_NO_SUCH_POWER_TO_REMOVE: Attempting to remove non-existent power: %s.",
MAP_INVALID_VARIANT_BLOCK: "MAP_INVALID_VARIANT_BLOCK: VARIANT block '%s' is malformed or not closed.",
MAP_UNDEFINED_VARIANT: "MAP_UNDEFINED_VARIANT: VARIANT '%s' is used but not defined.",
MAP_INVALID_FLOW: "MAP_INVALID_FLOW: FLOW directive '%s' is malformed.",
MAP_INVALID_SEQ: "MAP_INVALID_SEQ: SEQ directive '%s' is malformed.",
MAP_INVALID_FIRSTYEAR: "MAP_INVALID_FIRSTYEAR: FIRSTYEAR directive '%s' requires a number.",
};
// Define ConvoyPathData structure based on its usage in game.ts
// Game.ts expects: this.map.convoy_paths: Map<number_of_fleets, {start: string, fleets: Set<string>, dests: Set<string>}[]>
// This structure is complex and implies pre-computation.
// For now, ConvoyPathData will be this complex type.
export interface ConvoyPathInfo {
start: string;
fleets: Set<string>; // Set of fleet LOCATIONS (base names)
dests: Set<string>; // Set of destination LOCATIONS (base names)
}
export type ConvoyPathData = Map<number, ConvoyPathInfo[]>;
const CONVOYS_PATH_CACHE: Record<string, ConvoyPathData> = {};
const get_convoy_paths_cache = (): Record<string, ConvoyPathData> => CONVOYS_PATH_CACHE;
// This function is a placeholder for a complex convoy path generation algorithm
// or for loading pre-computed convoy paths.
const add_to_cache = (name: string): ConvoyPathData => {
logger.warn(`Convoy path generation for map '${name}' is currently a STUB. No convoy paths will be available.`);
return new Map<number, ConvoyPathInfo[]>();
};
const MAP_CACHE: Record<string, DiplomacyMap> = {};
export class DiplomacyMap {
name: string;
first_year: number = 1901;
victory: number[] = [18]; // Default for standard map
phase: string = 'SPRING 1901 MOVEMENT'; // Default initial phase
validated: number | null = null;
flow_sign: number = 1; // 1 for normal flow, -1 for reverse (not standard)
root_map: string | null = null;
abuts_cache: Record<string, number> = {}; // unit_type,loc1,order_type,loc2 -> 0 or 1
homes: Record<string, string[]> = {}; // power_name -> [loc_base_uc, ...]
loc_name: Record<string, string> = {}; // loc_full_uc_or_with_coast -> loc_base_uc
loc_type: Record<string, string> = {}; // loc_base_uc -> type (LAND, COAST, WATER, PORT) (SHUT special)
loc_abut: Record<string, string[]> = {}; // loc_full_uc_or_with_coast -> [adj_loc_full_uc_or_with_coast, ...]
loc_coasts: Record<string, string[]> = {}; // loc_base_uc -> [loc_base_uc/NC, loc_base_uc/SC, ...] OR [loc_base_uc] if no coasts
own_word: Record<string, string> = {}; // power_name_norm -> display_name
abbrev: Record<string, string> = {}; // power_name_norm -> single_char_abbrev
centers: Record<string, string[]> = {};// power_name_norm -> [loc_base_uc, ...] (initially owned SCs)
units: Record<string, string[]> = {}; // power_name_norm -> ["A PAR", "F BRE/NC", ...] (initial units)
pow_name: Record<string, string> = {}; // power_name_norm -> original_case_from_map_file
rules: string[] = []; // List of active rules from map file
files: string[] = []; // List of map files loaded (to prevent cycles)
powers: string[] = []; // List of power_name_norm
scs: string[] = []; // List of loc_base_uc that are supply centers
// Properties from Python's Map that were less clear or might be superseded:
// owns: string[] = []; // List of powers that can own SCs? Seems redundant with keys of this.centers.
// inhabits: string[] = []; // List of powers that have home SCs? Redundant with keys of this.homes.
flow: string[] = ['SPRING:MOVEMENT,RETREATS', 'FALL:MOVEMENT,RETREATS', 'WINTER:ADJUSTMENTS']; // Default game flow
dummies: string[] = []; // List of power_name_norm that are dummies
unplayed: string[] = []; // List of power_name_norm that are unplayed
// locs: string[] = []; // List of original case location names. More useful to store canonical forms.
// Instead, map_data.nodes.keys() can provide all canonical location representations.
map_data: {
nodes: Map<string, { type: string, sc: boolean, coasts?: Set<string> }>, // loc_full_uc_or_with_coast -> details
adj: Map<string, Set<string>> // loc_full_uc_or_with_coast -> Set of adjacent loc_full_uc_or_with_coast
} = { nodes: new Map(), adj: new Map() };
error: string[] = []; // Errors encountered during loading/validation
seq: string[] = ['NEWYEAR', 'SPRING MOVEMENT', 'SPRING RETREATS', 'FALL MOVEMENT', 'FALL RETREATS', 'FALL ADJUSTMENTS']; // Default phase sequence
// WINTER ADJUSTMENTS is also common, map file can override via FLOW/SEQ.
// Standard sequence (from DATC test cases) often implies Fall Adjustments.
phase_abbrev: Record<string, string> = {'M': 'MOVEMENT', 'R': 'RETREATS', 'A': 'ADJUSTMENTS'};
unclear: Record<string, string> = {}; // alias_norm_uc -> loc_base_uc (for ambiguous aliases)
unit_names: Record<string, string> = {'A': 'ARMY', 'F': 'FLEET'};
keywords: Record<string, string>; // Loaded from constants, can be augmented by map file
aliases: Record<string, string>; // Loaded from constants, can be augmented by map file
convoy_paths: ConvoyPathData = new Map(); // Stores pre-calculated convoy paths.
dest_with_coasts: Record<string, string[]> = {}; // loc_full_uc_or_with_coast -> [all_coastal_variants_of_adj_locs]
constructor(name: string = 'standard', use_cache: boolean = true) {
if (use_cache && MAP_CACHE[name]) {
Object.assign(this, MAP_CACHE[name]); // Re-assign all properties from cached instance
return;
}
this.name = name;
// Initialize with copies of the default constants, these can be augmented by map file directives if any.
this.keywords = { ...DEFAULT_KEYWORDS };
this.aliases = { ...DEFAULT_ALIASES };
// this.load(); // Will be called explicitly after constructor setup if needed, or implicitly by constructor
// Initialize map_data based on loaded locs, loc_type, loc_abut, loc_coasts, scs
this._initialize_map_data();
this.build_cache();
this.validate();
// Convoy paths are complex; using stubbed version for now.
if (use_cache && CONVOYS_PATH_CACHE[name]) {
this.convoy_paths = CONVOYS_PATH_CACHE[name];
} else {
this.convoy_paths = add_to_cache(name); // This is currently a stub
if (use_cache) {
CONVOYS_PATH_CACHE[name] = this.convoy_paths;
}
}
if (use_cache) {
MAP_CACHE[name] = this;
}
}
private _initialize_map_data(): void {
this.map_data.nodes.clear();
this.map_data.adj.clear();
// After `load()` and `finalizeLoadedData()`, `this.locs` contains all canonical location names (e.g., PAR, STP, STP/NC).
// `this.loc_type` has base names as keys (e.g., STP).
// `this.scs` has base names of SCs.
// `this.loc_coasts` has base names as keys and lists all its canonical variants (e.g., STP -> [STP, STP/NC, STP/SC]).
// `this.loc_abut` has canonical names as keys and lists of canonical adjacent names.
// Populate nodes
for (const loc_canon of this.locs) { // loc_canon is already a canonical name like PAR, STP, STP/NC
const base_name = loc_canon.substring(0, 3); // e.g., PAR from PAR, STP from STP/NC
const type = this.loc_type[base_name] || 'UNKNOWN';
const is_sc = this.scs.includes(base_name);
let coastsSet: Set<string> | undefined = undefined;
if (type === 'COAST' || type === 'PORT') {
// All variants for this base_name are in this.loc_coasts[base_name]
// We need to extract just the coast part (e.g., NC, SC) for the 'coasts' property of the node.
const all_variants_for_base = this.loc_coasts[base_name];
if (all_variants_for_base && all_variants_for_base.some(v => v.includes('/'))) {
coastsSet = new Set();
all_variants_for_base.forEach(variant => {
if (variant.includes('/')) {
coastsSet!.add(variant.split('/')[1]);
}
});
}
}
this.map_data.nodes.set(loc_canon, {
type: type,
sc: is_sc,
...(coastsSet && coastsSet.size > 0 && { coasts: coastsSet }),
});
// Ensure an entry for adjacencies is created for all nodes
if (!this.map_data.adj.has(loc_canon)) {
this.map_data.adj.set(loc_canon, new Set());
}
}
// Populate adjacencies
// this.loc_abut should now have canonical keys and values after finalizeLoadedData()
for (const canon_loc_source in this.loc_abut) {
if (this.map_data.nodes.has(canon_loc_source)) { // Ensure source location is a valid node
const adj_set = this.map_data.adj.get(canon_loc_source) || new Set<string>();
const adj_loc_list = this.loc_abut[canon_loc_source];
adj_loc_list.forEach(canon_loc_target => {
if (this.map_data.nodes.has(canon_loc_target)) { // Ensure target location is a valid node
adj_set.add(canon_loc_target);
} else {
logger.warn(`_initialize_map_data: Adjacency target '${canon_loc_target}' for source '${canon_loc_source}' not found in map_data.nodes. Skipping.`);
this.error.push(`Adjacency target '${canon_loc_target}' for source '${canon_loc_source}' not found in map_data.nodes.`);
}
});
this.map_data.adj.set(canon_loc_source, adj_set);
} else {
logger.warn(`_initialize_map_data: Adjacency source '${canon_loc_source}' not found in map_data.nodes. Skipping its adjacencies.`);
this.error.push(`Adjacency source '${canon_loc_source}' not found in map_data.nodes.`);
}
}
}
// Helper to find the canonical representation (UPPERCASE_BASE or UPPERCASE_BASE/COAST)
// This is primarily used during the construction of map_data.nodes if needed,
// but loc_str should ideally be canonical by the time it's used here.
private find_canonical_location_representation(loc_str: string): string | null {
const uc_loc = loc_str.toUpperCase();
// Check if uc_loc (e.g. "SPA/NC", "PAR") is directly a node
if (this.map_data.nodes.has(uc_loc)) return uc_loc;
// Check if its base (e.g. "SPA" from "SPA/NC") is a node
// This is useful if loc_str was like "spa" and map_data has "SPA"
const base_loc = uc_loc.substring(0,3);
if (this.map_data.nodes.has(base_loc)) return base_loc;
// Fallback: check this.locs (which is the definitive list of canonical names after load)
if (this.locs.includes(uc_loc)) return uc_loc;
if (this.locs.includes(base_loc)) return base_loc;
logger.warn(`find_canonical_location_representation: Could not find canonical form for '${loc_str}'.`);
return null;
}
this.name = name;
// Initialize with copies of the default constants, these can be augmented by map file directives if any.
this.keywords = { ...DEFAULT_KEYWORDS };
this.aliases = { ...DEFAULT_ALIASES };
this.load(); // This will populate this.locs, this.powers, this.scs, etc.
// Initialize map_data based on loaded locs, loc_type, loc_abut, loc_coasts, scs
this._initialize_map_data();
this.build_cache();
this.validate();
// Convoy paths are complex; using stubbed version for now.
if (use_cache && CONVOYS_PATH_CACHE[name]) {
this.convoy_paths = CONVOYS_PATH_CACHE[name];
} else {
this.convoy_paths = add_to_cache(name); // This is currently a stub
if (use_cache) {
CONVOYS_PATH_CACHE[name] = this.convoy_paths;
}
}
if (use_cache) {
MAP_CACHE[name] = this;
}
}
public norm(phrase: string): string {
// Handle coasts like "SPA/SC" -> "SPA /SC" then tokenized to "SPA", "/SC"
// Or "SPA / SC" -> "SPA", "/SC"
let result = phrase.toUpperCase();
// Space out slashes for coast processing, but ensure "word/coast" isn't spaced if already correct.
result = result.replace(/([A-Z0-9]{3})\s*\/\s*((?:N|S|E|W)C)/g, '$1 /$2'); // e.g. "SPA / SC" -> "SPA /SC"
result = result.replace(/([A-Z0-9]{3})\/((?:N|S|E|W)C)/g, '$1 /$2'); // e.g. "SPA/SC" -> "SPA /SC"
// Replace punctuation (except internal slashes in coasts like /NC) with spaces
const tokensToRemoveOrReplaceWithSpace = /[\.:\-\+,()\[\]]/g;
result = result.replace(tokensToRemoveOrReplaceWithSpace, ' ');
// Space out other special characters if they are not part of a word
const tokensToSpaceAround = /([\|\*\?!~=_^])/g;
result = result.replace(tokensToSpaceAround, ' $1 ');
const tokens = result.trim().split(/\s+/);
const finalTokens: string[] = [];
for (const token of tokens) {
if (!token) continue;
const ucToken = token.toUpperCase();
// 1. Keyword replacement
let currentToken = this.keywords[ucToken] || ucToken;
// 2. Alias replacement (primarily for locations, could also include powers if defined in aliases)
// Ensure aliases themselves are not keywords that were already replaced.
// e.g. if "ENG" is an alias for "ECH" but also a keyword for "ENGLISH CHANNEL"
// The order of these operations (keywords vs aliases) can matter.
// Standard approach: specific aliases first, then general keywords.
// However, our KEYWORDS also include things like "ARMY" -> "A".
// Let's assume for now: if it became a single letter keyword, it's likely final.
// Otherwise, try alias.
if (currentToken.length > 1 || currentToken.startsWith("/")) { // Don't re-alias single-letter results like 'A', 'F', or coasts
currentToken = this.aliases[currentToken] || currentToken;
}
finalTokens.push(currentToken);
}
return finalTokens.join(' ');
}
public norm_power(power: string): string {
// Normalize for keywords/aliases, then remove spaces to get a single token power name
const normed = this.norm(power);
return normed.replace(/\s+/g, '').toUpperCase();
}
public area_type(loc: string, no_coast_ok: boolean = false): string | undefined {
const upperLoc = loc.toUpperCase();
let shortLoc = upperLoc.substring(0,3);
if (no_coast_ok && upperLoc.includes('/')) {
shortLoc = upperLoc.split('/')[0].substring(0,3);
} else if (upperLoc.includes('/')) { // Specific coast, type must be COAST or PORT
const base = upperLoc.split('/')[0].substring(0,3);
const type = this.loc_type[base];
return (type === 'COAST' || type === 'PORT') ? type : undefined;
}
return this.loc_type[shortLoc];
}
public is_coastal(province_base_uc: string): boolean {
return this.loc_coasts[province_base_uc] && this.loc_coasts[province_base_uc].some(loc => loc.includes('/'));
}
public get_all_sea_provinces(): string[] {
const seaProvinces: string[] = [];
this.map_data.nodes.forEach((node, loc) => {
if (node.type === 'WATER' || node.type === 'SEA') { // Assuming 'WATER' and 'SEA' are synonymous from map files
seaProvinces.push(loc);
}
});
return seaProvinces;
}
// Interface for map_data.nodes values
public get_location_node(name: string): { type: string, sc: boolean, coasts?: Set<string> } | null {
const ucName = name.toUpperCase();
// Try direct match (e.g., "SPA/NC" or "PAR")
if (this.map_data.nodes.has(ucName)) {
return this.map_data.nodes.get(ucName)!;
}
// Try base name if a specific coast was requested but not found directly
if (ucName.includes("/")) {
const baseName = ucName.substring(0,3);
if (this.map_data.nodes.has(baseName)) {
return this.map_data.nodes.get(baseName)!;
}
}
// Try looking up via alias to get a base name
const normedName = this.norm(name).toUpperCase(); // Norm to handle aliases
if (this.map_data.nodes.has(normedName)) { // Normed might be an abbrev
return this.map_data.nodes.get(normedName)!;
}
if (normedName.includes('/')) {
const baseNormed = normedName.substring(0,3);
if (this.map_data.nodes.has(baseNormed)) {
return this.map_data.nodes.get(baseNormed)!;
}
}
// Final attempt: check original loc_name mapping for full names to abbrevs
const shortNameFromLocName = this.loc_name[name.toUpperCase()];
if (shortNameFromLocName && this.map_data.nodes.has(shortNameFromLocName)) {
return this.map_data.nodes.get(shortNameFromLocName)!;
}
return null;
}
public load(fileName?: string): void {
const filePathToLoad = fileName || (this.name.endsWith('.map') ? this.name : `${this.name}.map`);
let actualFilePath: string;
if (fs.existsSync(filePathToLoad)) {
actualFilePath = filePathToLoad;
} else {
actualFilePath = path.join(settings.PACKAGE_DIR, 'maps', filePathToLoad);
}
if (!fs.existsSync(actualFilePath)) {
this.error.push(err.MAP_FILE_NOT_FOUND.replace('%s', filePathToLoad));
logger.error(err.MAP_FILE_NOT_FOUND.replace('%s', filePathToLoad));
return;
}
if (this.files.includes(actualFilePath) && this.files[0] !== actualFilePath ) { // Allow re-loading the root_map once if it's the first in files list
// This check is to prevent true recursion where USE A -> B -> A
// But root map might be USEd by a sub-map, which is fine.
// A simple cycle check: if we are trying to load a file that's already in this.files *and* it's not the very first file (root_map initial load)
if (this.files.indexOf(actualFilePath) < this.files.length -1 ) { // if it's not the last added thing, it's a true cycle.
this.error.push(err.MAP_FILE_MULT_USED.replace('%s', actualFilePath) + " (Cycle detected)");
logger.warn(err.MAP_FILE_MULT_USED.replace('%s', actualFilePath) + " (Cycle detected)");
return;
}
}
if (!this.files.includes(actualFilePath)) { // Add only if not present
this.files.push(actualFilePath);
}
if (!this.root_map) {
this.root_map = path.basename(actualFilePath, '.map');
}
logger.info(`Loading map file: ${actualFilePath}`);
const fileContent = fs.readFileSync(actualFilePath, 'utf-8');
const lines = fileContent.split(/\r?\n/);
let currentPowerContext: string | null = null;
let variantCondition: string | null = null;
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) {
continue;
}
let words = trimmedLine.split(/\s+/);
const originalDirective = words[0]; // Keep original case for some checks if needed
const directive = originalDirective.toUpperCase();
if (variantCondition && variantCondition !== 'ALL' && directive !== 'VARIANT' && words[words.length -1].toUpperCase() !== variantCondition) {
continue;
}
if (variantCondition && directive !== 'VARIANT' && words.length > 0 && words[words.length-1].toUpperCase() === variantCondition) {
words = words.slice(0, -1);
if (words.length === 0) continue;
}
switch (directive) {
case 'VARIANT':
if (words.length > 1) {
variantCondition = words[1].toUpperCase();
if (variantCondition === 'END') variantCondition = null;
} else {
this.error.push(err.MAP_INVALID_VARIANT_BLOCK.replace('%s', trimmedLine));
}
break;
case 'VICTORY':
if (words.length > 1 && !isNaN(parseInt(words[1], 10))) {
this.victory = words.slice(1).map(Number);
} else {
this.error.push(err.MAP_BAD_VICTORY_LINE + ` Line: ${trimmedLine}`);
}
break;
case 'USE':
case 'USES':
case 'MAP':
if (directive === 'MAP') {
if (words.length === 2) {
const mapName = words[1].endsWith('.map') ? words[1] : `${words[1]}.map`;
const newRoot = words[1].split('.')[0];
// Root map is the first map loaded. If MAP directive encountered later, it's an error if it changes root_map.
if (this.root_map && this.root_map !== newRoot && this.files[0] !== actualFilePath) {
this.error.push(err.MAP_TWO_ROOT_MAPS + ` Trying to set to ${newRoot}, already ${this.root_map}`);
} else if (!this.root_map || (this.files.length === 1 && this.files[0] === actualFilePath)){
this.root_map = newRoot;
}
this.load(mapName);
} else {
this.error.push(err.MAP_BAD_ROOT_MAP_LINE + ` Line: ${trimmedLine}`);
}
} else {
words.slice(1).forEach(fileToUse => {
const mapNameToUse = fileToUse.endsWith('.map') ? fileToUse : `${fileToUse}.map`;
this.load(mapNameToUse);
});
}
break;
case 'BEGIN':
this.phase = words.slice(1).join(' ').toUpperCase();
break;
case 'FIRSTYEAR':
if (words.length === 2 && !isNaN(parseInt(words[1]))) {
this.first_year = parseInt(words[1]);
} else {
this.error.push(err.MAP_INVALID_FIRSTYEAR.replace('%s', trimmedLine));
}
break;
case 'FLOW':
if (words.length > 1) {
// FLOW SPRING:MOVEMENT,RETREATS FALL:MOVEMENT,RETREATS WINTER:ADJUSTMENTS
// Python code stores this as a list of strings, split by space after FLOW
this.flow = trimmedLine.substring(words[0].length).trim().split(/\s+/).map(s => s.toUpperCase());
} else {
this.error.push(err.MAP_INVALID_FLOW.replace('%s', trimmedLine));
}
break;
case 'SEQ':
if (words.length > 1) {
this.seq = [];
let currentSeasonEntry = "";
for(let i=1; i<words.length; ++i) {
const wordUC = words[i].toUpperCase();
// Seasons start a new entry. Phase types are appended.
if (['NEWYEAR', 'IFYEARDIV', 'SPRING', 'SUMMER', 'FALL', 'WINTER', 'AUTUMN'].includes(wordUC)) {
if (currentSeasonEntry) this.seq.push(currentSeasonEntry.trim());
currentSeasonEntry = wordUC;
} else { // Append phase type like MOVEMENT, RETREATS, ADJUSTMENTS
currentSeasonEntry += " " + wordUC;
}
}
if (currentSeasonEntry) this.seq.push(currentSeasonEntry.trim());
} else {
this.error.push(err.MAP_INVALID_SEQ.replace('%s', trimmedLine));
}
break;
case 'RULE':
case 'RULES':
this.rules.push(...words.slice(1).map(r => r.toUpperCase()));
break;
default: // Could be Location/Alias definition, Terrain definition, or Power definition
if (trimmedLine.includes('=')) {
const parts = trimmedLine.split('=', 2);
const fullNameAndOld = parts[0].trim();
const abbrevAndAliasesRaw = parts[1].trim();
const abbrevAndAliases = abbrevAndAliasesRaw.split(/\s+/);
const abbrev = abbrevAndAliases[0].toUpperCase();
let fullName = fullNameAndOld;
if (fullNameAndOld.includes('->')) {
this.error.push(err.MAP_RENAME_NOT_SUPPORTED + ` Line: ${trimmedLine}`);
const renameParts = fullNameAndOld.split('->', 2);
fullName = renameParts[1].trim();
}
const fullNameUC = fullName.toUpperCase();
if (this.keywords[fullNameUC] || (this.aliases[this.norm(fullNameUC)] && this.aliases[this.norm(fullNameUC)] !== abbrev)) {
// Allow re-defining an alias to the same abbreviation, but error if it changes or conflicts with a keyword.
if (this.aliases[this.norm(fullNameUC)] !== abbrev) {
this.error.push(err.MAP_DUP_LOC_OR_POWER.replace('%s', fullNameUC) + ` Line: ${trimmedLine}`);
}
}
this.loc_name[fullNameUC] = abbrev;
this.aliases[this.norm(fullNameUC)] = abbrev;
this.aliases[abbrev] = abbrev; // Abbrev maps to itself
abbrevAndAliases.slice(1).forEach(alias => {
let actualAlias = alias;
let isUnclear = false;
if (alias.endsWith('?')) {
isUnclear = true;
actualAlias = alias.slice(0, -1);
}
const normedAlias = this.norm(actualAlias).toUpperCase();
if (isUnclear) {
this.unclear[normedAlias] = abbrev;
} else {
if (this.aliases[normedAlias] && this.aliases[normedAlias] !== abbrev) {
this.error.push(err.MAP_DUP_ALIAS_OR_POWER.replace('%s', actualAlias) + ` Line: ${trimmedLine}`);
}
this.aliases[normedAlias] = abbrev;
}
});
if (!this.locs.includes(abbrev)) this.locs.push(abbrev);
} else if (['LAND', 'WATER', 'COAST', 'PORT', 'SHUT', 'AMEND'].includes(directive)) {
if (words.length >= 2) {
const locOriginalCaseFromFile = words[1];
const locCanonical = locOriginalCaseFromFile.toUpperCase(); // Store locs as UC e.g. PAR, SPA/SC
if (directive !== 'AMEND') {
this.loc_type[locCanonical.substring(0,3)] = directive;
} else if (!this.loc_type[locCanonical.substring(0,3)]) {
this.error.push(err.MAP_NO_DATA_TO_AMEND_FOR.replace('%s', locCanonical) + ` Line: ${trimmedLine}`);
}
if (!this.locs.includes(locCanonical)) this.locs.push(locCanonical);
const baseLoc = locCanonical.substring(0, 3);
if (locCanonical.includes('/')) { // e.g. SPA/SC
this.loc_coasts[baseLoc] = this.loc_coasts[baseLoc] || [];
if (!this.loc_coasts[baseLoc].includes(locCanonical)) {
this.loc_coasts[baseLoc].push(locCanonical);
}
} else { // e.g. PAR, or SPA (if SPA can be accessed w/o specific coast)
// If SPA has specific coasts like SPA/NC, then SPA itself might also be a valid general coastal prov.
this.loc_coasts[locCanonical] = this.loc_coasts[locCanonical] || [locCanonical];
}
// Store adjacencies with the original case from file as key for now, will normalize later
this.loc_abut[locOriginalCaseFromFile] = this.loc_abut[locOriginalCaseFromFile] || [];
const abutsIndex = words.findIndex(w => w.toUpperCase() === 'ABUTS');
if (abutsIndex !== -1) {
const abuts = words.slice(abutsIndex + 1);
abuts.forEach(abutFromFile => { // abutFromFile is original case
if (abutFromFile.startsWith('-')) {
const abutToRemove = abutFromFile.substring(1);
this.loc_abut[locOriginalCaseFromFile] = this.loc_abut[locOriginalCaseFromFile].filter(
existingAbut => !existingAbut.toUpperCase().startsWith(abutToRemove.toUpperCase())
);
} else {
if (!this.loc_abut[locOriginalCaseFromFile].map(a => a.toUpperCase()).includes(abutFromFile.toUpperCase())) {
this.loc_abut[locOriginalCaseFromFile].push(abutFromFile);
}
}
});
} else if (words.length > 2 && directive !== "AMEND" && directive !== "SHUT") {
// SHUT might not have ABUTS. AMEND might only change type.
this.error.push(err.MAP_NO_ABUTS_FOR.replace('%s', locCanonical) + ` Line: ${trimmedLine}`);
}
} else {
this.error.push(`Malformed terrain directive: ${trimmedLine}`);
}
} else { // Power definition: e.g. ENGLAND (ENGLISH:E) LON LVP EDI ...
let powerNameInDirective = words[0];
let powerSpecificsIndex = 1;
let homeCenterStartIndex = 1;
if (words.length > 2 && words[1] === '->') { // oldName -> newName
this.error.push(err.MAP_RENAME_NOT_SUPPORTED + ` Line: ${trimmedLine}`);
powerNameInDirective = words[2];
powerSpecificsIndex = 3;
homeCenterStartIndex = 3;
}
currentPowerContext = this.norm_power(powerNameInDirective);
if (powerNameInDirective.toUpperCase() === 'UNOWNED' || powerNameInDirective.toUpperCase() === 'NEUTRAL') {
currentPowerContext = 'UNOWNED';
} else {
if (!this.powers.includes(currentPowerContext)) {
this.powers.push(currentPowerContext);
}
this.pow_name[currentPowerContext] = powerNameInDirective;
this.own_word[currentPowerContext] = currentPowerContext;
this.abbrev[currentPowerContext] = currentPowerContext.substring(0, 1).toUpperCase();
}
// Check for (OwnWord:Abbrev)
if (words.length > powerSpecificsIndex && words[powerSpecificsIndex].startsWith('(') && words[powerSpecificsIndex].endsWith(')')) {
const specifics = words[powerSpecificsIndex].slice(1, -1);
homeCenterStartIndex = powerSpecificsIndex + 1;
const parts = specifics.split(':',2); // Split only on first colon
this.own_word[currentPowerContext] = parts[0] || currentPowerContext;
if (parts.length > 1 && parts[1]) {
this.abbrev[currentPowerContext] = parts[1].toUpperCase();
if (this.abbrev[currentPowerContext].length !== 1 || ['M', '?'].includes(this.abbrev[currentPowerContext])) {
this.error.push(err.MAP_ILLEGAL_POWER_ABBREV + ` For ${currentPowerContext} in ${trimmedLine}`);
}
}
}
// Remainder are home centers (original case from file)
const homeCentersOriginalCase = words.slice(homeCenterStartIndex);
this.add_homes(currentPowerContext, homeCentersOriginalCase, true); // true for reinit
// Homes are SCs
homeCentersOriginalCase.forEach(hcRaw => {
const hcBase = hcRaw.replace(/^-+/,'').toUpperCase().substring(0,3); // Remove leading '-' for SC list
if (hcBase && !this.scs.includes(hcBase)) this.scs.push(hcBase);
});
}
break;
case 'OWNS': // OWNS center...
case 'CENTERS': // CENTERS [center...]
if (!currentPowerContext) {
this.error.push(err.MAP_OWNS_BEFORE_POWER.replace('%s', directive).replace('%s', trimmedLine));
} else {
const centersToAdd = words.slice(1).map(c => c.toUpperCase().substring(0,3));
if (directive === 'CENTERS' || !this.centers[currentPowerContext]) { // CENTERS reinitializes
this.centers[currentPowerContext] = [];
}
centersToAdd.forEach(c => {
if (c && !this.centers[currentPowerContext].includes(c)) this.centers[currentPowerContext].push(c);
if (c && !this.scs.includes(c)) this.scs.push(c);
});
}
break;
case 'INHABITS': // Appends homes
case 'HOME': // Reinitializes homes
case 'HOMES': // Reinitializes homes
if (!currentPowerContext) {
this.error.push(err.MAP_HOME_BEFORE_POWER.replace('%s', directive).replace('%s', trimmedLine));
} else {
const homesToAddOriginalCase = words.slice(1);
this.add_homes(currentPowerContext, homesToAddOriginalCase, directive !== 'INHABITS');
homesToAddOriginalCase.forEach(hRaw => {
if (!hRaw.startsWith('-')) {
const hcBase = hRaw.toUpperCase().substring(0,3);
if (hcBase && !this.scs.includes(hcBase)) this.scs.push(hcBase);
}
});
}
break;
case 'UNITS':
if (!currentPowerContext) this.error.push(err.MAP_UNITS_BEFORE_POWER + ` Line: ${trimmedLine}`);
else this.units[currentPowerContext] = [];
break;
case 'A':
case 'F':
if (!currentPowerContext) {
this.error.push(err.MAP_UNIT_BEFORE_POWER + ` Line: ${trimmedLine}`);
} else if (words.length === 2) {
const unitLocRawFromFile = words[1]; // e.g. PAR, Bre, spa/sc
const unitStringCanonical = `${directive} ${unitLocRawFromFile.toUpperCase()}`;
const unitLocShortForClear = unitLocRawFromFile.substring(0,3).toUpperCase();
for (const pwr in this.units) { // Clear any unit in same base loc for any power
this.units[pwr] = this.units[pwr].filter(u => u.substring(2,5) !== unitLocShortForClear);
}
this.units[currentPowerContext] = this.units[currentPowerContext] || [];
this.units[currentPowerContext].push(unitStringCanonical);
} else {
this.error.push(err.MAP_INVALID_UNIT.replace('%s', trimmedLine));
}
break;
case 'DUMMY':
case 'DUMMIES':
const isPlural = directive === 'DUMMIES';
if (words.length === 1) {
if (isPlural) this.error.push(err.MAP_DUMMY_REQ_LIST_POWERS + ` Line: ${trimmedLine}`);
else if (!currentPowerContext) this.error.push(err.MAP_DUMMY_BEFORE_POWER + ` Line: ${trimmedLine}`);
else if (currentPowerContext !== 'UNOWNED' && !this.dummies.includes(currentPowerContext)) this.dummies.push(currentPowerContext);
} else {
const oldPowerContext = currentPowerContext;
currentPowerContext = null; // DUMMY with list resets power context
if (words[1].toUpperCase() === 'ALL') {
let exceptions: string[] = [];
if (words.length > 3 && words[2].toUpperCase() === 'EXCEPT') {
exceptions = words.slice(3).map(p => this.norm_power(p));
} else if (words.length === 2) { // DUMMY ALL
// No exceptions
} else if (words.length > 2 && words[2].toUpperCase() !== 'EXCEPT') {
this.error.push(err.MAP_NO_EXCEPT_AFTER_DUMMY_ALL.replace('%s', directive) + ` Line: ${trimmedLine}`);
} else if (words.length === 3 && words[2].toUpperCase() === 'EXCEPT') { // DUMMY ALL EXCEPT (no powers listed)
this.error.push(err.MAP_NO_POWER_AFTER_DUMMY_ALL_EXCEPT.replace('%s', directive) + ` Line: ${trimmedLine}`);
}
// Use this.powers as the source of "ALL"
this.dummies.push(...this.powers.filter(p => p !== 'UNOWNED' && !exceptions.includes(p) && !this.dummies.includes(p)));
} else {
this.dummies.push(...words.slice(1).map(p => this.norm_power(p)).filter(p => p !== 'UNOWNED' && !this.dummies.includes(p)));
}
currentPowerContext = oldPowerContext; // Restore power context if it was set
}
break;
case 'UNPLAYED':
const gonerPowers: string[] = [];
if (words.length === 1) {
if (!currentPowerContext) this.error.push(err.MAP_UNPLAYED_BEFORE_POWER + ` Line: ${trimmedLine}`);
else if (currentPowerContext !== 'UNOWNED') gonerPowers.push(currentPowerContext);
} else {
const oldPowerContext = currentPowerContext;
currentPowerContext = null;
if (words[1].toUpperCase() === 'ALL') {
let exceptions: string[] = [];
if (words.length > 3 && words[2].toUpperCase() === 'EXCEPT') {
exceptions = words.slice(3).map(p => this.norm_power(p));
} else if (words.length === 2) { /* ALL */ }
else if (words.length > 2 && words[2].toUpperCase() !== 'EXCEPT') this.error.push(err.MAP_NO_EXCEPT_AFTER_UNPLAYED_ALL + ` Line: ${trimmedLine}`);
else if (words.length === 3 && words[2].toUpperCase() === 'EXCEPT') this.error.push(err.MAP_NO_POWER_AFTER_UNPLAYED_ALL_EXCEPT + ` Line: ${trimmedLine}`);
gonerPowers.push(...this.powers.filter(p => p !== 'UNOWNED' && !exceptions.includes(p)));
} else {
gonerPowers.push(...words.slice(1).map(p => this.norm_power(p)).filter(p => p !== 'UNOWNED'));
}
currentPowerContext = oldPowerContext;
}
gonerPowers.forEach(goner => {
if (this.pow_name[goner]) delete this.pow_name[goner];
if (this.own_word[goner]) delete this.own_word[goner];
if (this.homes[goner]) delete this.homes[goner];
if (this.centers[goner]) delete this.centers[goner];
if (this.units[goner]) delete this.units[goner];
if (this.abbrev[goner]) delete this.abbrev[goner];
this.powers = this.powers.filter(p => p !== goner);
this.dummies = this.dummies.filter(p => p !== goner);
});
break;
case 'DROP':
words.slice(1).forEach(placeToDropRaw => {
const placeToDrop = placeToDropRaw.toUpperCase(); // DROP uses UC prefix matching
this.drop(placeToDrop);
});
break;
}
}
// Post-load normalization and finalization
this.finalizeLoadedData();
logger.info(`Finished loading map file: ${actualFilePath}`);
if (this.error.length > 0) {
const uniqueErrors = Array.from(new Set(this.error));
logger.warn(`Errors during map load for ${actualFilePath}: \n${uniqueErrors.join('\n')}`);
this.error = uniqueErrors;
}
}
private finalizeLoadedData(): void {
// Ensure all loc_coasts have base names if they only have specific coasts and base is a valid loc
Object.keys(this.loc_coasts).forEach(baseLoc => { // baseLoc is UC e.g. SPA, PAR
const coasts = this.loc_coasts[baseLoc]; // coasts are UC e.g. [SPA/NC, SPA/SC] or [PAR]
// If 'SPA' is a defined location type (this.loc_type[SPA] exists) and it has specific coasts (SPA/NC),
// but 'SPA' itself is not in its list of coasts, add it.
// This means 'SPA' can be referred to generally.
if (this.loc_type[baseLoc] && coasts.some(c => c.includes('/')) && !coasts.includes(baseLoc)) {
coasts.push(baseLoc);
}
// Ensure all listed coastal variants are also present in the main this.locs list
coasts.forEach(c_variant => { if (!this.locs.includes(c_variant)) this.locs.push(c_variant);});
});
// Consolidate and normalize this.locs to unique, uppercase names
const finalLocs = new Set<string>();
this.locs.forEach(loc => finalLocs.add(loc.toUpperCase()));
Object.values(this.loc_name).forEach(abbrev => finalLocs.add(abbrev.toUpperCase()));
Object.keys(this.loc_type).forEach(abbrev => finalLocs.add(abbrev.toUpperCase()));
this.locs = Array.from(finalLocs).sort();
// Normalize keys and values in loc_abut. Keys were original case from file. Values also.
const normalized_loc_abut: Record<string, string[]> = {};
for(const loc_key_original_case in this.loc_abut) {
const ucKeyCanonical = this.find_canonical_location_representation_from_mixed_case(loc_key_original_case);
if (!ucKeyCanonical) {
this.error.push(`Unknown location key ${loc_key_original_case} in loc_abut.`);
continue;
}
const abutValuesCanonical = this.loc_abut[loc_key_original_case].map(val_original_case => {
const ucVal = this.find_canonical_location_representation_from_mixed_case(val_original_case);
if (!ucVal) this.error.push(`Unknown location value ${val_original_case} in loc_abut for ${loc_key_original_case}.`);
return ucVal;
}).filter(Boolean) as string[];
normalized_loc_abut[ucKeyCanonical] = normalized_loc_abut[ucKeyCanonical] || [];
abutValuesCanonical.forEach(val => {
if(!normalized_loc_abut[ucKeyCanonical].includes(val)) normalized_loc_abut[ucKeyCanonical].push(val);
});
}
this.loc_abut = normalized_loc_abut;
// Ensure all SCs are uppercase base names and sorted.
this.scs = Array.from(new Set(this.scs.map(sc => sc.toUpperCase().substring(0,3)))).sort();
// Ensure all powers (from homes, centers, units) are in main this.powers list and sorted.
const allPowerKeys = new Set<string>(this.powers.map(p => this.norm_power(p)));
[this.homes, this.centers, this.units].forEach(collection => {
Object.keys(collection).forEach(p_raw => {
const normedPower = this.norm_power(p_raw);
if (normedPower !== 'UNOWNED') allPowerKeys.add(normedPower);
});
});
this.powers = Array.from(allPowerKeys).filter(p => p !== 'UNOWNED').sort();
// Ensure default records for all official powers if not fully defined by map
this.powers.forEach(p => {
if (!this.pow_name[p]) this.pow_name[p] = p; // Default to normalized name if no original case
if (!this.own_word[p]) this.own_word[p] = p;
if (!this.abbrev[p]) this.abbrev[p] = p.substring(0,1);
if (!this.homes[p]) this.homes[p] = [];
if (!this.centers[p]) this.centers[p] = [];
if (!this.units[p]) this.units[p] = [];
});
if (!this.homes['UNOWNED']) this.homes['UNOWNED'] = [];
}
// Helper to resolve potentially mixed-case location strings from map file to canonical UC form
private find_canonical_location_representation_from_mixed_case(loc_str_mixed_case: string): string | null {
const uc_loc = loc_str_mixed_case.toUpperCase(); // e.g. "spa/sc" -> "SPA/SC", "StP" -> "STP"
// 1. Check if the UC string is already a known canonical location (e.g. "SPA/SC" or "PAR")
if (this.locs.includes(uc_loc)) return uc_loc;
// 2. Check if the base of the UC string (e.g. "SPA" from "SPA/SC") is a known canonical location
const base_loc_uc = uc_loc.substring(0,3);
if (this.locs.includes(base_loc_uc)) return base_loc_uc;
// 3. Check loc_name which maps full names (original or UC) to abbreviations (UC)
// e.g. this.loc_name["St Petersburg"] = "STP" or this.loc_name["ST PETERSBURG"] = "STP"
const abbrevFromOriginalFullName = this.loc_name[loc_str_mixed_case]; // Key is as in file
if (abbrevFromOriginalFullName && this.locs.includes(abbrevFromOriginalFullName)) return abbrevFromOriginalFullName;
const abbrevFromUCFullName = this.loc_name[uc_loc]; // Key is UC version of file string
if (abbrevFromUCFullName && this.locs.includes(abbrevFromUCFullName)) return abbrevFromUCFullName;
// 4. If all else fails, and it looks like a base name (3 chars), assume it's canonical if in locs
if (uc_loc.length === 3 && this.locs.includes(uc_loc)) return uc_loc;
// Could not find a direct canonical mapping. This might be an error or an undefined loc.
return null;
}
private drop(place_to_drop_raw: string): void {
// place_to_drop_raw could be "SPA", "spa/sc", "St Petersburg"
// We need to determine the set of canonical location names this refers to.
// For "SPA", it means "SPA", "SPA/NC", "SPA/SC". For "spa/sc", just "SPA/SC".
// For "St Petersburg", it means "STP", "STP/NC", "STP/SC".
const uc_prefix_or_full = place_to_drop_raw.toUpperCase();
const related_canonical_locs_to_drop = new Set<string>();
if (uc_prefix_or_full.includes('/')) { // Specific coast like SPA/SC
related_canonical_locs_to_drop.add(uc_prefix_or_full);
} else {
const base_to_drop = uc_prefix_or_full.substring(0,3);
this.locs.forEach(l_canon => {
if (l_canon.startsWith(base_to_drop)) {
related_canonical_locs_to_drop.add(l_canon);
}
});
// Also consider if place_to_drop_raw was a full name like "Paris"
const abbrev = this.loc_name[place_to_drop_raw] || this.loc_name[uc_prefix_or_full];
if (abbrev) {
this.locs.forEach(l_canon => {
if (l_canon.startsWith(abbrev.toUpperCase())) {
related_canonical_locs_to_drop.add(l_canon);
}
});
}
}
if (related_canonical_locs_to_drop.size === 0 && this.locs.includes(uc_prefix_or_full)) {
// If it was a simple abbrev like "PAR" and no coasts, add it.
related_canonical_locs_to_drop.add(uc_prefix_or_full);
}
logger.info(`Dropping locations: ${Array.from(related_canonical_locs_to_drop).join(', ')}`);
this.locs = this.locs.filter(l => !related_canonical_locs_to_drop.has(l));
for (const fullName in this.loc_name) {
if (related_canonical_locs_to_drop.has(this.loc_name[fullName])) {
delete this.loc_name[fullName];
}
}
for (const alias in this.aliases) {
if (related_canonical_locs_to_drop.has(this.aliases[alias])) {
delete this.aliases[alias];
}
}
for (const unclearAlias in this.unclear) {
if (related_canonical_locs_to_drop.has(this.unclear[unclearAlias])) {
delete this.unclear[unclearAlias];
}
}
for (const power in this.homes) {
this.homes[power] = this.homes[power].filter(home_base => !related_canonical_locs_to_drop.has(home_base)); // homes are base
}
for (const power in this.units) { // units store canonical full loc "A PAR", "F SPA/SC"
this.units[power] = this.units[power].filter(unit_str => {
const unit_loc_full = unit_str.substring(2);
return !related_canonical_locs_to_drop.has(unit_loc_full);
});
}
this.scs = this.scs.filter(sc_base => !related_canonical_locs_to_drop.has(sc_base)); // scs are base
for (const power in this.centers) {
this.centers[power] = this.centers[power].filter(center_base => !related_canonical_locs_to_drop.has(center_base)); // centers are base
}
for (const loc_canon_key in this.loc_abut) { // keys are canonical
if (related_canonical_locs_to_drop.has(loc_canon_key)) {
delete this.loc_abut[loc_canon_key];
} else {
this.loc_abut[loc_canon_key] = this.loc_abut[loc_canon_key].filter(adj_canon => !related_canonical_locs_to_drop.has(adj_canon));
}
}
related_canonical_locs_to_drop.forEach(loc_to_drop_canon => {
const base_loc_to_drop = loc_to_drop_canon.substring(0,3);
if (this.loc_type[base_loc_to_drop]) { // Type is by base name
delete this.loc_type[base_loc_to_drop];
}
if (this.loc_coasts[base_loc_to_drop]) { // Coasts are by base name
this.loc_coasts[base_loc_to_drop] = this.loc_coasts[base_loc_to_drop].filter(
c_variant => !related_canonical_locs_to_drop.has(c_variant)
);
if(this.loc_coasts[base_loc_to_drop].length === 0) delete this.loc_coasts[base_loc_to_drop];
}
// If dropping a specific coast like SPA/SC, and SPA (base) still has other coasts (SPA/NC)
// then loc_type[SPA] and loc_coasts[SPA] should remain but without SPA/SC.
// If dropping SPA (base), then loc_type[SPA] and loc_coasts[SPA] are fully removed.
// The current logic handles this by iterating all related_canonical_locs_to_drop.
});
// This makes map_data inconsistent. Call _initialize_map_data() again after all drops if needed.
}
public add_homes(power: string, homes_to_add: string[], reinit: boolean): void {
const powerKey = this.norm_power(power).toUpperCase();
if (reinit || !this.homes[powerKey]) {
this.homes[powerKey] = [];
}
// Ensure UNOWNED is initialized, critical for logic below
this.homes['UNOWNED'] = this.homes['UNOWNED'] || [];
for (let home_directive of homes_to_add) { // e.g. "MOS", "-PAR", "STP/SC" (though add_homes should get base names)
let remove = false;
while (home_directive.startsWith('-')) {
remove = !remove;
home_directive = home_directive.substring(1);
}
// Homes are always base province names (e.g. STP from STP/SC)
const homeBase = home_directive.toUpperCase().substring(0,3);
if (!homeBase) continue;
// Manage power's homes
const currentIdxInPowerHomes = this.homes[powerKey].indexOf(homeBase);
if (currentIdxInPowerHomes > -1) { // Exists in power's homes
if (remove) this.homes[powerKey].splice(currentIdxInPowerHomes, 1);
} else { // Not in power's homes
if (!remove) this.homes[powerKey].push(homeBase);
}
// Manage UNOWNED list:
// An SC becomes UNOWNED if no non-dummy power claims it as a home.
// Python: if power_name != 'UNOWNED': self.homes['UNOWNED'].append(home) ... if not remove: self.homes[power].append(home)
// This implies UNOWNED initially gets all homes, then they are removed if a power claims them.
// Let's refine: if adding to a specific power, remove from UNOWNED.
// If removing from a specific power, add to UNOWNED IFF it's an SC and no other power has it as home.
if (this.scs.includes(homeBase)) { // Only SCs can be homes and thus matter for UNOWNED homes list
const unownedIdx = this.homes['UNOWNED'].indexOf(homeBase);
if (powerKey !== 'UNOWNED') {
if (!remove) { // Adding home to a power
if (unownedIdx > -1) this.homes['UNOWNED'].splice(unownedIdx, 1); // Remove from UNOWNED
} else { // Removing home from a power
// Add to UNOWNED only if no other *active* power holds it as a home.
let isHeldByOtherPower = false;
for (const pwr in this.homes) {
if (pwr !== 'UNOWNED' && pwr !== powerKey && this.homes[pwr].includes(homeBase)) {
isHeldByOtherPower = true;
break;
}
}
if (!isHeldByOtherPower && unownedIdx === -1) {
this.homes['UNOWNED'].push(homeBase);
}
}
} else { // Directly manipulating UNOWNED list (e.g. UNOWNED MOS -PAR)
if (remove && unownedIdx > -1) { // UNOWNED -PAR
this.homes['UNOWNED'].splice(unownedIdx, 1);
}
if (!remove && unownedIdx === -1) { // UNOWNED MOS
this.homes['UNOWNED'].push(homeBase);
}
}
}
}
}
public is_valid_unit(unit_str: string, no_coast_ok: boolean = false, shut_ok: boolean = false): boolean {
const parts = unit_str.toUpperCase().split(" ");
if (parts.length !== 2) return false;
const unit_type = parts[0];
const loc_with_coast_info = parts[1]; // Can be "PAR", "SPA/SC"
const base_loc = loc_with_coast_info.substring(0,3);
const areaType = this.loc_type[base_loc]; // Type is always for the base province
if (areaType === 'SHUT') {
return shut_ok ? true : false;
}
if (!areaType) return false; // Unknown location
if (unit_type === '?') {
return true; // Any known location is fine for '?'
}
if (unit_type === 'A') {
return !loc_with_coast_info.includes('/') &&
(areaType === 'LAND' || areaType === 'COAST' || areaType === 'PORT');
}
if (unit_type === 'F') {
if (!(areaType === 'WATER' || areaType === 'COAST' || areaType === 'PORT')) return false;
if (!loc_with_coast_info.includes('/')) { // e.g. "F SPA"
const possible_coasts = this.loc_coasts[base_loc] || [];
const has_specific_coasts = possible_coasts.some(c => c.includes('/'));
if (has_specific_coasts && !no_coast_ok) {
// If SPA has SPA/NC, SPA/SC, then "F SPA" is only valid if "SPA" itself is listed in loc_coasts[SPA]
// or if no_coast_ok is true.
// Python's map.is_valid_unit has `loc.lower() not in self.loc_abut` check for fleets.
// self.loc_abut keys are original case or lower case non-coastal locs.
// This implies that if 'spa' (lowercase) is a key in loc_abut, then 'F SPA' is invalid.
// This typically means 'spa' is the non-coastal land bridge.
// For TS, let's check if the base_loc itself is a valid coastal choice if specific coasts exist.
if (!possible_coasts.includes(base_loc)) return false;
}
} else { // e.g. "F SPA/SC" - specific coast
const coasts_for_base = this.loc_coasts[base_loc] || [];
if (!coasts_for_base.includes(loc_with_coast_info)) {
return false;
}
}
return true;
}
return false;
}
/**
* Validates the loaded map data for consistency.
* Populates this.error with any issues found.
* Sets this.validated = 1 after running.
* @param force - If true, re-validates even if already validated.
*/
public validate(force: number = 0): void {
if (!force && this.validated) {
return;
}
this.error = []; // Clear previous errors for a fresh validation pass
logger.info("Validating map data...");
const current_errors: string[] = []; // Use a temporary error list for this validation pass
// Check powers
if (this.powers.length < 2) {
current_errors.push(err.MAP_LEAST_TWO_POWERS);
}
// Validate area types and names
const allShortLocNamesCanonical = new Set(Object.keys(this.loc_type));
this.locs.forEach(locOriginalCase => {
const locUpper = locOriginalCase.toUpperCase();
const shortName = locUpper.substring(0,3);
if (!this.loc_type[shortName] && !this.powers.map(p => this.norm_power(p)).includes(shortName)) {
current_errors.push(err.MAP_LOC_NOT_FOUND.replace('%s', locOriginalCase));
}
});
// Validating adjacencies (loc_abut keys should be canonical locs)
for (const placeUpper of Object.keys(this.loc_abut)) {
const placeShortUpper = placeUpper.substring(0,3);
if (!allShortLocNamesCanonical.has(placeShortUpper) && !this.locs.some(l => l.toUpperCase().startsWith(placeShortUpper))) {
current_errors.push(err.MAP_LOC_NOT_FOUND.replace('%s', placeUpper + " (as key in loc_abut)"));
}
const abuts = this.loc_abut[placeUpper];
const up_abuts_short = abuts.map(loc => loc.toUpperCase().substring(0,3));
for (const abutTarget of abuts) { // abutTarget should be canonical UC form
const abutTargetUpperShort = abutTarget.toUpperCase().substring(0,3);
if (!allShortLocNamesCanonical.has(abutTargetUpperShort) && !this.locs.some(l => l.toUpperCase().startsWith(abutTargetUpperShort))) {
current_errors.push(err.MAP_LOC_NOT_FOUND.replace('%s', abutTarget + ` (in ${placeUpper} ABUTS)`));
}
if (up_abuts_short.filter(s => s === abutTargetUpperShort).length > 1) {
const msg = err.MAP_SITE_ABUTS_TWICE.replace('%s', placeUpper).replace('%s', abutTargetUpperShort);
if (!current_errors.includes(msg)) current_errors.push(msg);
}
const targetAbutsBack = this.loc_abut[abutTarget] || [];
if (!targetAbutsBack.includes(placeUpper)) {
// This check is complex due to map specific rules (e.g. one way water adj for coasts)
// Python's _abuts and later validation handles this.
// For now, simple check. If A->B, B must ->A (mostly)
// current_errors.push(err.MAP_ONE_WAY_ADJ.replace('%s', placeUpper).replace('%s', abutTarget));
}
}
}
// Validate SCs
this.scs.forEach(sc => {
if (!this.loc_type[sc.toUpperCase()]) {
current_errors.push(err.MAP_LOC_NOT_FOUND.replace('%s', `${sc} (as SC)`));
}
});
// Validate homes
Object.entries(this.homes).forEach(([powerName, homeScs]) => {
if (powerName === "UNOWNED") return;
homeScs.forEach(homeSc => {
if (!this.scs.includes(homeSc)) {
current_errors.push(err.MAP_BAD_HOME.replace('%s', powerName).replace('%s', homeSc + " (not listed in SCs)"));
}
if (!this.loc_type[homeSc]) {
current_errors.push(err.MAP_BAD_HOME.replace('%s', powerName).replace('%s', `${homeSc} (not a defined loc)`));
}
});
});
// Validate initial centers and units (from this.centers, this.units)
Object.entries(this.centers).forEach(([powerName, centerList]) => {
if (powerName === "UNOWNED") return;
centerList.forEach(sc => {
if (!this.loc_type[sc]) current_errors.push(err.MAP_BAD_INITIAL_OWN_CENTER.replace('%s', powerName).replace('%s', sc));
if (!this.scs.includes(sc)) current_errors.push(err.MAP_BAD_INITIAL_OWN_CENTER.replace('%s', powerName).replace('%s', sc + " (not an SC)"));
});
});
Object.entries(this.units).forEach(([powerName, unitList]) => {
unitList.forEach(unitStr => {
if (!this.is_valid_unit(unitStr)) current_errors.push(err.MAP_BAD_INITIAL_UNITS.replace('%s', powerName).replace('%s', unitStr));
});
});
const ownedScCounts: Record<string, string[]> = {};
Object.entries(this.centers).forEach(([powerName, scList]) => {
if (powerName === "UNOWNED") return;
scList.forEach(sc => {
ownedScCounts[sc] = ownedScCounts[sc] || [];
ownedScCounts[sc].push(powerName);
});
});
for (const sc in ownedScCounts) {
if (ownedScCounts[sc].length > 1) {
current_errors.push(err.MAP_CENTER_MULT_OWNED.replace('%s', sc + ` (owned by ${ownedScCounts[sc].join(', ')})`));
}
}
if (this.homes['UNOWNED']) {
this.homes['UNOWNED'].forEach(unownedHome => {
Object.entries(this.homes).forEach(([pwr, ownedHomes]) => {
if (pwr !== 'UNOWNED' && ownedHomes.includes(unownedHome)) {
current_errors.push(err.MAP_CENTER_MULT_OWNED.replace('%s', unownedHome + ` (listed as UNOWNED home and ${pwr}'s home)`));
}
});
});
}
if (this.phase) {
const phaseParts = this.phase.split(' ');
if (phaseParts.length !== 3) {
current_errors.push(err.MAP_BAD_PHASE.replace('%s', this.phase));
} else {
try {
const year = parseInt(phaseParts[1], 10);
if (isNaN(year)) { // Check if first_year was set if phase is just e.g. "SPRING MOVEMENT"
if (this.first_year === undefined || this.first_year === null) {
current_errors.push(err.MAP_BAD_PHASE.replace('%s', this.phase + " (year invalid/missing and no FIRSTYEAR)"));
}
// If first_year is set, we can assume phase uses it.
} else {
this.first_year = year; // Phase specific year overrides FIRSTYEAR directive.
}
} catch (e) {
current_errors.push(err.MAP_BAD_PHASE.replace('%s', this.phase + " (year parsing failed)"));
}
const phaseType = phaseParts[2].toUpperCase();
if (!Object.values(this.phase_abbrev).includes(phaseType)){
current_errors.push(err.MAP_BAD_PHASE.replace('%s', this.phase + ` (unknown phase type ${phaseType})`));
}
}
} else {
current_errors.push(err.MAP_BAD_PHASE.replace('%s', "(No phase defined)"));
}
this.error.push(...current_errors);
this.validated = 1;
if (this.error.length > 0) {
const uniqueErrors = Array.from(new Set(this.error));
logger.error("Map validation found errors:", uniqueErrors.join("\n"));
this.error = uniqueErrors;
} else {
logger.info("Map validation passed.");
}
}
/**
* Get the type of a province (e.g., 'LAND', 'WATER', 'COAST', 'PORT').
* This is a simplified version of area_type, usually taking a base province name.
* @param province_base_name The base name of the province (e.g. PAR, MAR).
* @returns Province type string or undefined if not found.
*/
get_province_type(province_base_name: string): string | undefined { // Corresponds to Python's area_type
const uc_base_name = province_base_name.substring(0,3).toUpperCase();
return this.loc_type[uc_base_name];
}
/**
if (this.powers.length < 2) {
current_errors.push(err.MAP_LEAST_TWO_POWERS);
}
// Validate area types and names
const allShortLocNames = new Set(Object.values(this.loc_name));
this.locs.forEach(locOriginalCase => {
const locUpper = locOriginalCase.toUpperCase();
const shortName = locUpper.substring(0,3);
if (!this.loc_type[shortName] && !this.powers.includes(shortName) /* Power names can be locs */) {
current_errors.push(err.MAP_LOC_NOT_FOUND.replace('%s', locOriginalCase));
}
if(this.loc_name[locUpper] !== shortName && !locUpper.includes('/')) { // Full name maps to its short form
// This condition might be too strict if loc_name can map e.g. "English Channel" to "ECH"
// Python code: if place.upper() not in self.loc_name.values(): error
// This means all short names (values in loc_name) must be derivable from a full name (key in loc_name)
// The current structure in TS: loc_name maps FULL_UPPER -> SHORT_UPPER
// locs contains original case.
// This check seems to be about ensuring all locs (from list) have a full name definition if they are abbreviations
// or that their full name is registered if they are full names.
// For now, let's ensure every short name in loc_type and loc_abut keys (after normalization) is in allShortLocNames.
}
});
// Validating adjacencies (loc_abut)
for (const placeOriginalCase of Object.keys(this.loc_abut)) {
const placeUpper = placeOriginalCase.toUpperCase();
const placeShortUpper = placeUpper.substring(0,3);
if (!allShortLocNames.has(placeShortUpper) && !this.locs.some(l => l.toUpperCase().startsWith(placeShortUpper))) {
current_errors.push(err.MAP_LOC_NOT_FOUND.replace('%s', placeOriginalCase + " (as key in loc_abut)"));
}
if (!this.loc_name[placeUpper] && !placeUpper.includes('/')) { // Check if full name for this abut key is registered
// This error is from python: if place.upper() not in self.loc_name.values():
// which means the key of loc_abut should be a value in loc_name (a short name)
// Our loc_abut keys are original case from file. Let's assume they should be normalizable to a defined loc.
if (!allShortLocNames.has(placeShortUpper)) {
// This check is tricky with current structure. Python's map has more normalized internal keys.
// current_errors.push(err.MAP_NO_FULL_NAME.replace('%s', placeOriginalCase));
}
}
const abuts = this.loc_abut[placeOriginalCase];
const up_abuts_short = abuts.map(loc => loc.toUpperCase().substring(0,3));
for (const abutTarget of abuts) {
const abutTargetUpperShort = abutTarget.toUpperCase().substring(0,3);
if (!allShortLocNames.has(abutTargetUpperShort) && !this.locs.some(l => l.toUpperCase().startsWith(abutTargetUpperShort))) {
current_errors.push(err.MAP_LOC_NOT_FOUND.replace('%s', abutTarget + ` (in ${placeOriginalCase} ABUTS)`));
}
if (up_abuts_short.filter(s => s === abutTargetUpperShort).length > 1) {
const msg = err.MAP_SITE_ABUTS_TWICE.replace('%s', placeOriginalCase).replace('%s', abutTargetUpperShort);
if (!current_errors.includes(msg)) current_errors.push(msg);
}
// One-way adjacency check (complex, requires iterating all other loc_abut entries)
}
}
// Validate SCs
this.scs.forEach(sc => {
if (!this.loc_type[sc.toUpperCase()]) { // SCs are short names
current_errors.push(err.MAP_LOC_NOT_FOUND.replace('%s', `${sc} (as SC)`));
}
});
// Validate homes
Object.entries(this.homes).forEach(([powerName, homeScs]) => {
if (powerName === "UNOWNED") return;
homeScs.forEach(homeSc => {
if (!this.scs.includes(homeSc)) {
current_errors.push(err.MAP_BAD_HOME.replace('%s', powerName).replace('%s', homeSc));
}
if (!this.loc_type[homeSc]) {
current_errors.push(err.MAP_BAD_HOME.replace('%s', powerName).replace('%s', `${homeSc} (not a defined loc)`));
}
});
});
// Validate initial centers and units (from this.centers, this.units)
Object.entries(this.centers).forEach(([powerName, centerList]) => {
if (powerName === "UNOWNED") return;
centerList.forEach(sc => {
if (!this.loc_type[sc]) current_errors.push(err.MAP_BAD_INITIAL_OWN_CENTER.replace('%s', powerName).replace('%s', sc));
});
});
Object.entries(this.units).forEach(([powerName, unitList]) => {
unitList.forEach(unitStr => {
if (!this.is_valid_unit(unitStr)) current_errors.push(err.MAP_BAD_INITIAL_UNITS.replace('%s', powerName).replace('%s', unitStr));
});
});
this.error.push(...current_errors); // Add new errors found in this pass
this.validated = 1;
if (this.error.length > 0) {
logger.error("Map validation found errors:", Array.from(new Set(this.error)).join("\n")); // Show unique errors
} else {
logger.info("Map validation passed.");
}
}
public norm(phrase: string): string {
let result = phrase.toUpperCase();
// Normalize slashes for coasts: "SPA/SC" -> "SPA /SC", "SPA / SC" -> "SPA /SC"
// This helps tokenize coasts correctly, e.g. "/SC" becomes a token.
result = result.replace(/([A-Z0-9]{3})\s*\/\s*((?:N|S|E|W)C)/g, '$1 /$2'); // e.g. "SPA / SC" -> "SPA /SC"
result = result.replace(/([A-Z0-9]{3})\/((?:N|S|E|W)C)/g, '$1 /$2'); // e.g. "SPA/SC" -> "SPA /SC"
// Replace punctuation (except internal slashes in coasts like /NC) with spaces
const tokensToRemoveOrReplaceWithSpace = /[\.:\-\+,()\[\]]/g;
result = result.replace(tokensToRemoveOrReplaceWithSpace, ' ');
// Space out other special characters if they are not part of a word
const tokensToSpaceAround = /([\|\*\?!~=_^])/g;
result = result.replace(tokensToSpaceAround, ' $1 ');
const tokens = result.trim().split(/\s+/);
const finalTokens: string[] = [];
for (const token of tokens) {
if (!token) continue;
const ucToken = token.toUpperCase();
// 1. Keyword replacement
let currentToken = this.keywords[ucToken] || ucToken;
// 2. Alias replacement (primarily for locations, could also include powers if defined in aliases)
// Ensure aliases themselves are not keywords that were already replaced.
// e.g. if "ENG" is an alias for "ECH" but also a keyword for "ENGLISH CHANNEL"
// The order of these operations (keywords vs aliases) can matter.
// Standard approach: specific aliases first, then general keywords.
// However, our KEYWORDS also include things like "ARMY" -> "A".
// Let's assume for now: if it became a single letter keyword, it's likely final.
// Otherwise, try alias.
if (currentToken.length > 1 || currentToken.startsWith("/")) { // Don't re-alias single-letter results like 'A', 'F', or coasts
currentToken = this.aliases[currentToken] || currentToken;
}
finalTokens.push(currentToken);
}
return finalTokens.join(' ');
}
public norm_power(power: string): string {
// Normalize for keywords/aliases, then remove spaces to get a single token power name
const normed = this.norm(power);
return normed.replace(/\s+/g, '').toUpperCase();
}
public area_type(loc: string, no_coast_ok: boolean = false): string | undefined {
const upperLoc = loc.toUpperCase();
let shortLoc = upperLoc.substring(0,3);
if (no_coast_ok && upperLoc.includes('/')) {
shortLoc = upperLoc.split('/')[0].substring(0,3);
} else if (upperLoc.includes('/')) { // Specific coast, type must be COAST or PORT
const base = upperLoc.split('/')[0].substring(0,3);
const type = this.loc_type[base];
return (type === 'COAST' || type === 'PORT') ? type : undefined;
}
return this.loc_type[shortLoc];
}
public is_coastal(province_base_uc: string): boolean {
return this.loc_coasts[province_base_uc] && this.loc_coasts[province_base_uc].some(loc => loc.includes('/'));
}
public get_all_sea_provinces(): string[] {
const seaProvinces: string[] = [];
this.map_data.nodes.forEach((node, loc) => {
if (node.type === 'WATER' || node.type === 'SEA') { // Assuming 'WATER' and 'SEA' are synonymous from map files
seaProvinces.push(loc);
}
});
return seaProvinces;
}
// Interface for map_data.nodes values
public get_location_node(name: string): { type: string, sc: boolean, coasts?: Set<string> } | null {
const ucName = name.toUpperCase();
// Try direct match (e.g., "SPA/NC" or "PAR")
if (this.map_data.nodes.has(ucName)) {
return this.map_data.nodes.get(ucName)!;
}
// Try base name if a specific coast was requested but not found directly
if (ucName.includes("/")) {
const baseName = ucName.substring(0,3);
if (this.map_data.nodes.has(baseName)) {
return this.map_data.nodes.get(baseName)!;
}
}
// Try looking up via alias to get a base name
const normed = this.norm(name).toUpperCase(); // Norm to handle aliases
if (this.map_data.nodes.has(normed)) { // Normed might be an abbrev
return this.map_data.nodes.get(normed)!;
}
if (normed.includes('/')) {
const baseNormed = normed.substring(0,3);
if (this.map_data.nodes.has(baseNormed)) {
return this.map_data.nodes.get(baseNormed)!;
}
}
return null;
}
public add_homes(power: string, homes_to_add: string[], reinit: boolean): void {
const powerKey = this.norm_power(power).toUpperCase();
if (reinit || !this.homes[powerKey]) {
this.homes[powerKey] = [];
}
this.homes['UNOWNED'] = this.homes['UNOWNED'] || [];
for (let home of homes_to_add) {
let remove = false;
while (home.startsWith('-')) {
remove = !remove;
home = home.substring(1);
}
home = home.toUpperCase().substring(0,3);
if (!home) continue;
const currentIdx = this.homes[powerKey].indexOf(home);
if (currentIdx > -1) {
this.homes[powerKey].splice(currentIdx, 1);
}
if (powerKey !== 'UNOWNED') {
const unownedIdx = this.homes['UNOWNED'].indexOf(home);
if (unownedIdx > -1) {
this.homes['UNOWNED'].splice(unownedIdx, 1);
}
}
if (!remove) {
if (!this.homes[powerKey].includes(home)) {
this.homes[powerKey].push(home);
}
} else {
if (powerKey !== 'UNOWNED' && this.scs.includes(home) && !this.homes['UNOWNED'].includes(home)) {
this.homes['UNOWNED'].push(home);
}
}
}
}
public is_valid_unit(unit_str: string, no_coast_ok: boolean = false, shut_ok: boolean = false): boolean {
const parts = unit_str.toUpperCase().split(" ");
if (parts.length !== 2) return false;
const unit_type = parts[0];
const loc = parts[1];
const shortLocForTypeLookup = loc.substring(0,3);
const areaType = this.area_type(shortLocForTypeLookup);
if (areaType === 'SHUT') {
return shut_ok ? true : false;
}
if (unit_type === '?') {
return areaType !== undefined && areaType !== null;
}
if (unit_type === 'A') {
return !loc.includes('/') && (areaType === 'LAND' || areaType === 'COAST' || areaType === 'PORT');
}
if (unit_type === 'F') {
const isNonCoastedVersionOfCoastedProv = !loc.includes('/') && (this.loc_coasts[loc]?.length || 0) > 1 && this.loc_coasts[loc][0] !== loc;
if (!no_coast_ok && isNonCoastedVersionOfCoastedProv) {
return false;
}
return areaType === 'WATER' || areaType === 'COAST' || areaType === 'PORT';
}
return false;
}
/**
* Get the type of a province (e.g., 'LAND', 'SEA', 'COAST').
* @param province_name The name of the province (normalized, base name e.g. PAR, MAR).
* @returns Province type string or null if not found.
*/
get_province_type(province_name: string): 'LAND' | 'SEA' | 'COAST' | null {
const loc_base = province_name.substring(0, 3).toUpperCase();
const area = this.locs[loc_base]; // this.locs should store AreaDefinition like objects
// Assuming AreaDefinition has properties like 'sea' and 'coast' based on prior map parsing logic
// This might need adjustment based on the actual structure of this.locs[loc_base] objects
if (!area) { // If loc_base is not directly in this.locs, it might be an alias or full name
const shortName = this.loc_name[loc_base] || loc_base; // Convert potential full name to abbrev
const areaDef = this.locs.find(l => (this.loc_name[l.toUpperCase()] || l.toUpperCase().substring(0,3)) === shortName);
if(areaDef && (areaDef as any).sea) return 'SEA';
if(areaDef && (areaDef as any).coast) return 'COAST';
if(areaDef) return 'LAND';
return null;
}
if ((area as any).sea) return 'SEA'; // Type assertion if 'sea'/'coast' not directly on string value
if ((area as any).coast) return 'COAST';
return 'LAND';
}
/**
* Check if a unit type can move to/occupy a given province type.
* @param unit_type 'A' or 'F'.
* @param target_province_type 'LAND', 'SEA', or 'COAST'.
* @returns True if valid, false otherwise.
*/
is_valid_move_for_unit_type(unit_type: 'A' | 'F', target_province_type: 'LAND' | 'SEA' | 'COAST'): boolean {
if (unit_type === 'A') {
return target_province_type === 'LAND' || target_province_type === 'COAST';
} else if (unit_type === 'F') {
return target_province_type === 'SEA' || target_province_type === 'COAST';
}
return false;
}
/**
* Finds all coasts for a given location (base name or specific coast).
* Returns a list of canonical coastal variants (e.g., ["BUL/EC", "BUL/SC"])
* or the location itself if it has no distinct coasts (e.g., ["PAR"]).
* @param loc_str - The name of a location (e.g., 'BUL', 'SPA/NC').
* @returns Returns the list of all coasts, including the location itself if it's non-coastal or a general coastal ref.
*/
public find_coasts(loc_str: string): string[] {
const loc_uc = loc_str.toUpperCase();
const base_loc = loc_uc.substring(0, 3);
// this.loc_coasts stores: base_loc_uc -> [loc_base_uc/NC, loc_base_uc/SC, ...] OR [loc_base_uc]
// If the input is already a specific coast (e.g. "SPA/NC"), return it directly if valid.
if (loc_uc.includes('/')) {
if (this.loc_coasts[base_loc]?.includes(loc_uc)) {
return [loc_uc];
} else { // Invalid specific coast or base_loc not in loc_coasts
return [];
}
}
// Input is a base name (e.g. "SPA" or "PAR")
return this.loc_coasts[base_loc] || [base_loc]; // Default to [base_loc] if no specific coasts defined
}
public build_cache(): void {
logger.info("Building map caches (loc_coasts, abuts_cache, dest_with_coasts)...");
this.abuts_cache = {};
this.dest_with_coasts = {};
// 1. Finalize loc_coasts (Python does this in build_cache)
// Assuming this.locs contains all canonical location names (e.g. PAR, LON, STP, STP/NC, STP/SC)
// And this.loc_type is populated for all base provinces.
// This step ensures loc_coasts[BASE_PROVINCE] = [ALL_CANONICAL_VARIANTS_OF_BASE_PROVINCE]
const temp_loc_coasts: Record<string, string[]> = {};
for (const loc_canon of this.locs) { // loc_canon is like PAR, STP, STP/NC
const base_loc = loc_canon.substring(0,3);
temp_loc_coasts[base_loc] = temp_loc_coasts[base_loc] || [];
if (!temp_loc_coasts[base_loc].includes(loc_canon)) {
temp_loc_coasts[base_loc].push(loc_canon);
}
}
this.loc_coasts = temp_loc_coasts;
// 2. Building abuts_cache
// Requires this.locs to be the complete list of all canonical locations (PAR, SPA, SPA/NC, SPA/SC)
// Requires _abuts to correctly use these canonical names.
const unitTypes: Array<'A' | 'F'> = ['A', 'F'];
const orderTypes: string[] = ['-', 'S', 'C']; // Move, Support, Convoy
for (const unit_type of unitTypes) {
for (const unit_loc_canon of this.locs) { // unit_loc_canon is PAR, SPA, SPA/NC etc.
for (const other_loc_canon of this.locs) { // other_loc_canon is also PAR, SPA, SPA/NC
for (const order_type of orderTypes) {
const queryTuple = `${unit_type},${unit_loc_canon},${order_type},${other_loc_canon}`;
// _abuts should take canonical forms directly.
// The result of _abuts is boolean, so convert to 0 or 1.
this.abuts_cache[queryTuple] = this._abuts(unit_type, unit_loc_canon, order_type, other_loc_canon) ? 1 : 0;
}
}
}
}
// 3. Building dest_with_coasts
// For each loc in this.locs (canonical form), find all reachable locations (1 hop)
// and then list all their coastal variants.
for (const loc_canon of this.locs) { // e.g., PAR, BUL, BUL/EC, BUL/SC
// abut_list expects a canonical location name.
// incl_no_coast=true ensures that if 'SPA' is given, adjacent 'POR' (if POR has no coasts) and 'MAO' are returned.
// If 'SPA/NC' is given, it should return adjacencies specific to SPA/NC.
// The Python `abut_list` uses `self.loc_abut` which can have mixed-case keys based on file.
// Our this.loc_abut should have canonical UC keys after load().
const one_hop_adj_locs_mixed_case = this.abut_list(loc_canon, true);
// true: include non-coastal base if it has coasts e.g. SPA for SPA/NC
const all_coastal_variants_of_dests = new Set<string>();
one_hop_adj_locs_mixed_case.forEach(adj_loc_mixed_case => {
// adj_loc_mixed_case could be 'Par', 'spa', 'Spa/NC'. Need its canonical forms.
const adj_base_canon = adj_loc_mixed_case.substring(0,3).toUpperCase();
const coasts_of_adj = this.find_coasts(adj_base_canon); // find_coasts expects base or specific coast
coasts_of_adj.forEach(variant => all_coastal_variants_of_dests.add(variant));
});
this.dest_with_coasts[loc_canon] = Array.from(all_coastal_variants_of_dests);
}
logger.info("Map caches built.");
}
public abut_list(site: string, incl_no_coast: boolean = false): string[] {
const siteOriginalCase = this.locs.find(l => l.toUpperCase() === site.toUpperCase()) || site;
let abut_list: string[] = this.loc_abut[siteOriginalCase] || [];
if (incl_no_coast) {
const result_with_no_coast = new Set<string>(abut_list);
for (const loc of abut_list) {
if (loc.includes('/')) {
result_with_no_coast.add(loc.substring(0, 3));
}
}
return Array.from(result_with_no_coast);
}
return [...abut_list];
}
private _abuts(unit_type: 'A' | 'F' | '?', unit_loc_full: string, order_type: string, other_loc_full: string): boolean {
unit_loc_full = unit_loc_full.toUpperCase();
other_loc_full = other_loc_full.toUpperCase();
if (!this.is_valid_unit(`${unit_type} ${unit_loc_full}`)) {
return false;
}
let effective_other_loc = other_loc_full;
if (other_loc_full.includes('/')) {
if (order_type === 'S') {
effective_other_loc = other_loc_full.substring(0, 3);
} else if (unit_type === 'A') {
return false;
}
}
const unitLocOriginalCase = this.locs.find(l => l.toUpperCase() === unit_loc_full) || unit_loc_full;
const adjacencies = this.loc_abut[unitLocOriginalCase] || [];
let place_found_in_adj: string | undefined = undefined;
for (const adj_from_list of adjacencies) {
const adj_from_list_upper = adj_from_list.toUpperCase();
const adj_short_upper = adj_from_list_upper.substring(0,3);
if (effective_other_loc === adj_from_list_upper || effective_other_loc === adj_short_upper) {
place_found_in_adj = adj_from_list;
break;
}
}
if (!place_found_in_adj) {
return false;
}
const other_loc_type = this.area_type(effective_other_loc.substring(0,3));
if (other_loc_type === 'SHUT') return false;
if (unit_type === '?') return true;
if (unit_type === 'F') {
if (other_loc_type === 'LAND') return false;
// Python: place[0] != up_loc[0] (original case from abut list vs UC other_loc)
// This means if adjacency in map file was 'par' for 'BAL', F BAL cannot go to PAR.
// My adjacencies in this.loc_abut are now canonical UC.
// This check from python: (place[0] != up_loc[0]) is tricky.
// It means if self.loc_abut['VEN'] = ['tus', 'ADR'], then F VEN to tus (TUS) is not allowed.
// This implies the original case of the abutment in the map file matters for fleets.
// My current this.loc_abut stores UC names. This detail might be lost.
// For now, let's assume this.loc_abut stores what's needed or _abuts gets more complex.
// Python: or order_type != 'S' and other_loc not in self.loc_type
// This means for non-Support orders, the target must be a defined location type.
// My other_loc_type checks this.
if (order_type !== 'S' && !this.loc_type[effective_other_loc.substring(0,3)]) return false;
}
else if (unit_type === 'A') {
if (order_type !== 'C' && other_loc_type === 'WATER') return false;
// Python: place == place.title() -- means mixed case like 'Mar' (Marseilles) not allowed for army.
// This is another case where original map file casing for abuts is used.
// If this.loc_abut now stores only UC, this check is hard.
// For now, assuming this detail is handled by map file correctness and canonicalization.
}
return true;
}
public abuts(unit_type: 'A' | 'F' | '?', unit_loc: string, order_type: string, other_loc: string): boolean {
const queryTuple = `${unit_type},${unit_loc.toUpperCase()},${order_type.toUpperCase()},${other_loc.toUpperCase()}`;
const cachedResult = this.abuts_cache[queryTuple];
if (cachedResult !== undefined) {
return cachedResult === 1;
}
// If it's not in cache, it implies it might be an invalid query or needs calculation.
// Python's _abuts is called by build_cache. A direct call to abuts() implies cache lookup.
// If we reach here, it means the specific combination wasn't pre-cached, which could be an issue
// if self.locs used for cache generation was incomplete, or if locs passed are non-canonical.
// For safety, calculate on the fly, but warn, as this indicates a potential gap in cache generation or query.
logger.warn(`Abuts cache miss for: ${queryTuple}. Calculating on the fly. Ensure locs are canonical.`);
return this._abuts(unit_type, unit_loc, order_type, other_loc);
}
public phase_abbr(phase: string, defaultVal: string = '?????'): string {
} else if (unit_type === 'F') {
return target_province_type === 'SEA' || target_province_type === 'COAST';
}
return false;
}
public abut_list(site: string, incl_no_coast: boolean = false): string[] {
const siteOriginalCase = this.locs.find(l => l.toUpperCase() === site.toUpperCase()) || site;
let abut_list: string[] = this.loc_abut[siteOriginalCase] || [];
if (incl_no_coast) {
const result_with_no_coast = new Set<string>(abut_list);
for (const loc of abut_list) {
if (loc.includes('/')) {
result_with_no_coast.add(loc.substring(0, 3));
}
}
return Array.from(result_with_no_coast);
}
return [...abut_list];
}
private _abuts(unit_type: 'A' | 'F' | '?', unit_loc_full: string, order_type: string, other_loc_full: string): boolean {
unit_loc_full = unit_loc_full.toUpperCase();
other_loc_full = other_loc_full.toUpperCase();
if (!this.is_valid_unit(`${unit_type} ${unit_loc_full}`)) {
return false;
}
let effective_other_loc = other_loc_full;
if (other_loc_full.includes('/')) {
if (order_type === 'S') {
effective_other_loc = other_loc_full.substring(0, 3);
} else if (unit_type === 'A') {
return false;
}
}
const unitLocOriginalCase = this.locs.find(l => l.toUpperCase() === unit_loc_full) || unit_loc_full;
const adjacencies = this.loc_abut[unitLocOriginalCase] || [];
let place_found_in_adj: string | undefined = undefined;
for (const adj_from_list of adjacencies) {
const adj_from_list_upper = adj_from_list.toUpperCase();
const adj_short_upper = adj_from_list_upper.substring(0,3);
if (effective_other_loc === adj_from_list_upper || effective_other_loc === adj_short_upper) {
place_found_in_adj = adj_from_list;
break;
}
}
if (!place_found_in_adj) {
return false;
}
const other_loc_type = this.area_type(effective_other_loc.substring(0,3));
if (other_loc_type === 'SHUT') return false;
if (unit_type === '?') return true;
if (unit_type === 'F') {
if (other_loc_type === 'LAND') return false;
if (place_found_in_adj !== place_found_in_adj.toUpperCase()) return false;
}
else if (unit_type === 'A') {
if (order_type !== 'C' && other_loc_type === 'WATER') return false;
if (place_found_in_adj.length > 0 &&
place_found_in_adj[0] === place_found_in_adj[0].toUpperCase() &&
place_found_in_adj.slice(1) === place_found_in_adj.slice(1).toLowerCase() &&
place_found_in_adj.toUpperCase() !== place_found_in_adj
) {
return false;
}
}
return true;
}
public abuts(unit_type: 'A' | 'F' | '?', unit_loc: string, order_type: string, other_loc: string): boolean {
const queryTuple = `${unit_type},${unit_loc.toUpperCase()},${order_type.toUpperCase()},${other_loc.toUpperCase()}`;
const cachedResult = this.abuts_cache[queryTuple];
if (cachedResult !== undefined) {
return cachedResult === 1;
}
logger.warn(`Abuts cache miss for: ${queryTuple}. Calculating on the fly.`);
return this._abuts(unit_type, unit_loc, order_type, other_loc);
}
public phase_abbr(phase: string, defaultVal: string = '?????'): string {
if (phase === 'FORMING' || phase === 'COMPLETED') {
return phase;
}
const parts = phase.split(' ');
if (parts.length === 3) {
try {
const year = parseInt(parts[1]);
const yearStr = String(year);
const yearAbbr = yearStr.length > 2 ? yearStr.slice(-2) : yearStr.padStart(2,'0');
return (`${parts[0][0]}${yearAbbr}${parts[2][0]}`).toUpperCase();
} catch (e) { /* fall through to default */ }
}
return defaultVal;
}
public phase_long(phase_abbr: string, defaultVal: string = '?????'): string {
if (phase_abbr.length < 4) return defaultVal;
try {
const season_char = phase_abbr[0].toUpperCase();
const year_abbr_str = phase_abbr.substring(1, phase_abbr.length - 1);
const type_char = phase_abbr[phase_abbr.length -1].toUpperCase();
let year = parseInt(year_abbr_str, 10);
if (year < 100) year += 1900;
for (const season_def of this.seq) {
const parts = season_def.split(' ');
if (parts.length === 2 && parts[0][0].toUpperCase() === season_char && parts[1][0].toUpperCase() === type_char) {
return `${parts[0]} ${year} ${parts[1]}`.toUpperCase();
}
}
} catch(e) { /* fall through */ }
return defaultVal;
}
get svg_path(): string | null {
for (const file_name of [`${this.name}.svg`, `${this.root_map || this.name}.svg`]) { // Fallback for root_map
const svg_path = path.join(settings.PACKAGE_DIR, 'maps', 'svg', file_name);
if (fs.existsSync(svg_path)) return svg_path;
}
return null;
}
// --- Start of Parsing Utilities ---
/**
* Compacts a full sentence into a list of short words (canonical forms).
* e.g. 'England: Fleet Western Mediterranean -> Tyrrhenian Sea. (*bounce*)'
* becomes ['ENGLAND', 'F', 'WES', '-', 'TYS', '|'] (example, actual output depends on norm, aliases)
*/
public compact(phrase: string): string[] {
let processedPhrase = phrase;
// Check if first part of phrase (before colon) is a power, and remove it if that's the case.
const colonIndex = phrase.indexOf(':');
if (colonIndex !== -1) {
const firstPart = phrase.substring(0, colonIndex);
// Recursively call compact and vet to check if it's a power
const firstPartResult = this.vet(this.compact(firstPart));
if (firstPartResult.length === 1 && firstPartResult[0][1] === POWER) {
processedPhrase = phrase.substring(colonIndex + 1);
}
}
// Normalize the phrase (this applies keywords and some basic aliases)
const normedWords = this.norm(processedPhrase).split(/\s+/).filter(w => w); // Split and remove empty strings
const result: string[] = [];
let currentWords = normedWords;
while (currentWords.length > 0) {
const [aliasFound, wordsConsumed] = this.alias(currentWords);
if (aliasFound) {
// aliasFound might be multiple words itself if an alias maps to "word1 word2"
result.push(...aliasFound.split(/\s+/).filter(w => w));
} else if (currentWords.length > 0 && wordsConsumed === 0) {
// Safety: if alias consumes 0 words, manually advance to prevent infinite loop
result.push(currentWords[0]);
currentWords = currentWords.slice(1);
continue;
} else if (!aliasFound && wordsConsumed > 0 && currentWords.length > 0) {
// This case means words were consumed (e.g. brackets) but no specific alias string was returned.
// The words are already sliced based on wordsConsumed.
}
if (wordsConsumed > 0) {
currentWords = currentWords.slice(wordsConsumed);
} else if (currentWords.length > 0) {
logger.warn("Stuck in compact, word not consumed:", currentWords[0]);
result.push(currentWords[0]);
currentWords = currentWords.slice(1);
}
}
return result;
}
/**
* Replaces multi-word sequences with their acronyms/aliases.
* Processes one alias at a time from the start of the `word` array.
* @param words - The current list of words (assumed to be normed individually but not yet multi-word aliased).
* @returns [alias, wordsConsumed] - alias is the shortened string (can be multiple words if alias maps to that),
* wordsConsumed is the number of words from the input `words` array that were consumed.
*/
private alias(words: string[]): [string | null, number] {
if (!words || words.length === 0) {
return [null, 0];
}
const firstWord = words[0];
if (firstWord === '(' || firstWord === '[') {
const closingBracket = firstWord === '(' ? ')' : ']';
let j = -1;
for (let k = 1; k < words.length; k++) {
if (words[k] === closingBracket) {
j = k;
break;
}
}
if (j !== -1) {
if (j === 1) return [null, 2];
const contentInsideBrackets = words.slice(1, j);
// Python's alias logic for content within brackets is recursive and complex,
// especially with `word2 = word[2:j - 1]` if `word[1] + word[j - 1] == '**'`
// and `alias2 = self.aliases.get(' '.join(word2) + ' \\', '')`.
// For simplicity, we join the content and recursively call `compact` then `vet`.
// This might not perfectly replicate extremely specific DAIDE sub-dialect parsing within brackets.
const compactedContent = this.compact(contentInsideBrackets.join(' '));
return [compactedContent.join(' '), j + 1]; // Return processed content and consumed length
} else {
return [this._resolve_unclear(firstWord), 1];
}
}
for (let i = words.length; i > 0; i--) {
const key = words.slice(0, i).join(' ');
if (this.aliases[key]) {
let aliasValue = this.aliases[key];
if (i < words.length) {
const nextWord = words[i];
if (nextWord.startsWith('/') && aliasValue.length === 3 && /^[A-Z]{3}$/.test(aliasValue)) {
const combined = aliasValue + nextWord;
const isKnownCombinedLocation = this.locs.includes(combined) ||
Object.values(this.aliases).includes(combined) ||
this.loc_name[combined.toUpperCase()]; // Check if combined is a full name for an abbrev
if (isKnownCombinedLocation) {
return [this._resolve_unclear(combined), i + 1];
}
}
}
return [this._resolve_unclear(aliasValue), i];
}
}
return [this._resolve_unclear(words[0]), 1];
}
/**
* Resolves if an alias might be an unclear power name (e.g. "ENG" could be England or ECH).
* If `alias` is in `this.powers` and also in `this.unclear`, returns the unclear mapping.
* Otherwise, returns the alias unchanged.
*/
private _resolve_unclear(alias: string): string {
const ucAlias = alias.toUpperCase();
if (this.powers.includes(ucAlias) && this.unclear[ucAlias]) {
return this.unclear[ucAlias];
}
return alias;
}
/**
* Determines the type of every word in a compacted order phrase.
* Types: 0-Undetermined, 1-Power, 2-Unit, 3-Location, 4-Coastal loc, 5-Order, 6-Move Op, 7-Other.
* @param word - The list of words to vet (e.g., ['A', 'POR', 'S', 'SPA/NC']).
* @param strict - If true, verifies words exist (not fully implemented here like Python's negative types).
* @returns A list of tuples (e.g., [['A', 2], ['POR', 3], ['S', 5], ['SPA/NC', 4]]).
*/
public vet(word: string[], strict: boolean = false): Array<[string, number]> {
const result: Array<[string, number]> = [];
for (const thing of word) {
let dataType: number;
const ucThing = thing.toUpperCase();
if (ucThing.includes(' ')) {
dataType = UNDETERMINED;
} else if (ucThing.length === 1) {
if (this.unit_names[ucThing]) dataType = UNIT;
else if (/[A-Z0-9]/.test(ucThing)) dataType = ORDER; // S, H, C, R, D, B. M gets converted to '-' by norm usually.
else if ('-=_^'.includes(ucThing)) dataType = MOVE_SEP;
else dataType = OTHER; // | * ? ! ~ + ( ) [ ] handled by alias method usually
} else if (ucThing.includes('/')) {
// Standard coast: 3-letter loc + / + 2-letter coast = 6 chars (e.g., STP/SC)
if (ucThing.indexOf('/') === 3 && ucThing.length === 6 && /^[A-Z]{2}$/.test(ucThing.substring(4))) {
dataType = COAST;
} else {
dataType = POWER; // Or malformed/unrecognized
}
} else if (ucThing === 'VIA') {
dataType = ORDER;
} else if (ucThing.length === 3 && /^[A-Z]{3}$/.test(ucThing)) {
// Could be a location or a 3-letter power name.
// Prioritize known locations. If not a location, might be a power.
if (this.locs.includes(ucThing) || Object.values(this.aliases).includes(ucThing)) { // Check if it's a known loc abbrev
dataType = LOCATION;
} else if (this.powers.includes(this.norm_power(ucThing))) { // Check if it's a known power
dataType = POWER;
} else { // Default to LOCATION for 3-letter words not immediately ID'd as powers (as per Python's bias)
dataType = LOCATION;
}
} else {
const normedPower = this.norm_power(ucThing);
if (this.powers.includes(normedPower)) {
dataType = POWER;
} else if (this.aliases[ucThing] && this.locs.includes(this.aliases[ucThing].toUpperCase())) {
dataType = LOCATION;
} else { // Default to POWER for multi-char unknown tokens (Python's default)
dataType = POWER;
}
}
result.push([thing, dataType]);
}
return result;
}
/**
* This function is used to parse commands by rearranging vetted words.
* @param words The list of *strings* to be vetted and rearranged.
* @return The list of words (strings) in the correct order to be processed.
*/
public rearrange(words: string[]): string[] {
let resultTuples = this.vet(['|', ...words, '|']);
if (resultTuples.length > 0) resultTuples[0] = ['|', UNDETERMINED];
while (resultTuples.length > 2 && resultTuples[resultTuples.length - 2][1] === OTHER) {
resultTuples.splice(resultTuples.length - 2, 1);
}
if (resultTuples.length === 2 && resultTuples[0][0] === '|' && resultTuples[1][0] === '|') return [];
if (resultTuples.length > 0) resultTuples[0] = ['|', OTHER];
while (resultTuples.length > 1 && resultTuples[1][1] === OTHER) {
resultTuples.splice(1, 1);
}
// Simplified reordering:
// 1. Power before Unit
// 2. Order after Unit + Location(s)
// 3. Hyphens between locations
// Power before Unit
for (let i = 1; i < resultTuples.length -1; i++) { // Skip placeholders
if (resultTuples[i][1] === POWER && i > 0 && resultTuples[i-1][1] === UNIT) {
const powerToken = resultTuples.splice(i,1)[0];
resultTuples.splice(i-1, 0, powerToken);
} else if (resultTuples[i][1] === POWER && resultTuples[i][0].toUpperCase() in this.unclear && resultTuples[i-1][1] === UNIT) {
// If power is unclear and follows a unit, it's a location.
resultTuples[i] = [this.unclear[resultTuples[i][0].toUpperCase()], LOCATION];
}
}
// Order after Unit + Location(s)
// This is complex. Python's logic involves multiple passes and specific token handling.
// A simplified approach: find the primary unit-location block, then move the main order token.
let firstUnitIdx = -1, firstLocIdx = -1, lastLocIdx = -1, orderIdx = -1;
for (let i = 1; i < resultTuples.length -1; i++) {
const type = resultTuples[i][1];
if (type === UNIT && firstUnitIdx === -1) firstUnitIdx = i;
else if ((type === LOCATION || type === COAST)) {
if (firstLocIdx === -1) firstLocIdx = i;
lastLocIdx = i;
} else if (type === ORDER && orderIdx === -1) {
orderIdx = i;
}
}
if (orderIdx !== -1 && lastLocIdx !== -1 && orderIdx < lastLocIdx) {
// If order is before the end of the location sequence it applies to.
// e.g. A PAR S BUD -> A PAR BUD S (if S is the main order)
// This needs to distinguish main order (H,S,M,C) from sub-orders like VIA.
// For simplicity, if an ORDER token is before the last location of a unit spec, move it after.
const orderTokenToMove = resultTuples.splice(orderIdx, 1)[0];
resultTuples.splice(lastLocIdx + 1, 0, orderTokenToMove);
}
// Insert hyphens ('-') between subsequent locations if no other operator/order is present
const finalResultWithHyphens: Array<[string, number]> = [];
if (resultTuples.length > 0) finalResultWithHyphens.push(resultTuples[0]);
for (let i = 1; i < resultTuples.length; i++) {
const prevTokenTuple = finalResultWithHyphens[finalResultWithHyphens.length -1];
const currentTokenTuple = resultTuples[i];
const prevType = prevTokenTuple[1];
const currentType = currentTokenTuple[1];
if ((prevType === LOCATION || prevType === COAST) &&
(currentType === LOCATION || currentType === COAST)) {
finalResultWithHyphens.push(['-', MOVE_SEP]);
}
finalResultWithHyphens.push(currentTokenTuple);
}
resultTuples = finalResultWithHyphens;
if (resultTuples.length > 0 && resultTuples[0][0] === '|') resultTuples.shift();
if (resultTuples.length > 0 && resultTuples[resultTuples.length - 1][0] === '|') resultTuples.pop();
return resultTuples.map(item => item[0]);
}
/**
* Returns the default coast for a fleet move order that can only be to a single coast.
* e.g. F GRE - BUL returns F GRE - BUL/SC (if BUL/SC is the only valid fleet move from GRE to BUL area)
* @param word - A list of tokens (e.g. ['F', 'GRE', '-', 'BUL'])
* @returns The updated list of tokens (e.g. ['F', 'GRE', '-', 'BUL/SC'])
*/
public default_coast(word: string[]): string[] {
if (word.length === 4 && word[0].toUpperCase() === 'F' && word[2] === '-' && !word[3].includes('/')) {
const unit_loc_uc = word[1].toUpperCase();
const target_loc_base_uc = word[3].toUpperCase();
let single_matching_coast: string | null = null;
let multiple_options_exist = false;
const adjacencies = this.loc_abut[unit_loc_uc] || [];
for (const adj_loc_canon of adjacencies) {
if (adj_loc_canon.startsWith(target_loc_base_uc)) {
if (this._abuts('F', unit_loc_uc, '-', adj_loc_canon)) {
if (adj_loc_canon.includes('/')) {
if (single_matching_coast && single_matching_coast !== adj_loc_canon) {
multiple_options_exist = true; break;
}
single_matching_coast = adj_loc_canon;
} else {
if (single_matching_coast && single_matching_coast.includes('/')) {
// Already found a specific coast (e.g. BUL/SC), and now found base (BUL). Ambiguous.
multiple_options_exist = true; break;
}
// If current single_matching_coast is null, or is the same base, or is different base (error in logic before)
// This path means base province itself is a valid destination.
if (single_matching_coast && single_matching_coast !== adj_loc_canon) {
multiple_options_exist = true; break;
}
single_matching_coast = adj_loc_canon;
}
}
}
}
if (single_matching_coast && !multiple_options_exist && single_matching_coast.includes('/')) {
word[3] = single_matching_coast;
}
}
return word;
}
// --- End of Parsing Utilities ---
public find_next_phase(phase: string, phase_type: string | null = null, skip: number = 0): string {
const now = phase.split(' ');
if (now.length < 3) return phase; // Cannot determine next for FORMING/COMPLETED or malformed
let year = parseInt(now[1], 10);
const currentPhaseKey = `${now[0].toUpperCase()} ${now[2].toUpperCase()}`;
let currentPhaseIndex = this.seq.findIndex(s => s === currentPhaseKey);
if (currentPhaseIndex === -1) {
logger.warn(`Current phase key '${currentPhaseKey}' not found in sequence: ${this.seq.join(', ')}`);
return ''; // Phase not in sequence
}
let phasesToSkip = skip;
let iterations = 0; // Safety break for very long sequences or weird skips
// eslint-disable-next-line no-constant-condition
while (true) {
iterations++;
if (iterations > this.seq.length * (phasesToSkip + 2) + 5) { // Safety break
logger.error("find_next_phase exceeded max iterations, possible infinite loop with SEQ/skip logic.");
return '';
}
currentPhaseIndex = (currentPhaseIndex + 1) % this.seq.length;
const nextPhaseParts = this.seq[currentPhaseIndex].split(' '); // e.g., ["SPRING", "MOVEMENT"] or ["NEWYEAR"]
if (nextPhaseParts[0] === 'IFYEARDIV') {
// Not fully implemented from Python's complex IFYEARDIV with mod check.
// For now, assume it means skip this seq entry if year doesn't meet a condition.
// This would require parsing new[1] like "2" or "2=0" (div, mod)
// Simple skip for now.
continue;
} else if (nextPhaseParts[0] === 'NEWYEAR') {
year += (nextPhaseParts.length > 1 ? parseInt(nextPhaseParts[1], 10) : 1);
} else { // Regular phase (e.g., SPRING MOVEMENT)
const nextPhaseFullType = nextPhaseParts[1]; // e.g. MOVEMENT
const nextPhaseSeason = nextPhaseParts[0]; // e.g. SPRING
// Check if this phase matches the desired phase_type (M, R, A)
if (phase_type === null || (nextPhaseFullType && nextPhaseFullType[0].toUpperCase() === phase_type.toUpperCase())) {
if (phasesToSkip === 0) {
return `${nextPhaseSeason} ${year} ${nextPhaseFullType}`;
}
phasesToSkip--;
}
}
}
}
public find_previous_phase(phase: string, phase_type: string | null = null, skip: number = 0): string {
const now = phase.split(' ');
if (now.length < 3) return phase;
let year = parseInt(now[1], 10);
const currentPhaseKey = `${now[0].toUpperCase()} ${now[2].toUpperCase()}`;
let currentPhaseIndex = this.seq.findIndex(s => s === currentPhaseKey);
if (currentPhaseIndex === -1) {
logger.warn(`Current phase key '${currentPhaseKey}' not found in sequence: ${this.seq.join(', ')}`);
return '';
}
let phasesToSkip = skip;
let iterations = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
iterations++;
if (iterations > this.seq.length * (phasesToSkip + 2) + 5) {
logger.error("find_previous_phase exceeded max iterations.");
return '';
}
currentPhaseIndex = (currentPhaseIndex - 1 + this.seq.length) % this.seq.length;
const prevPhaseParts = this.seq[currentPhaseIndex].split(' ');
if (prevPhaseParts[0] === 'IFYEARDIV') {
// Similar to find_next_phase, complex IFYEARDIV logic not fully ported.
// This directive affects phase progression based on year divisibility.
// For now, just moving past it. A full implementation would adjust 'year' or loop.
continue;
} else if (prevPhaseParts[0] === 'NEWYEAR') {
year -= (prevPhaseParts.length > 1 ? parseInt(prevPhaseParts[1], 10) : 1);
} else { // Regular phase
const prevPhaseFullType = prevPhaseParts[1];
const prevPhaseSeason = prevPhaseParts[0];
if (phase_type === null || (prevPhaseFullType && prevPhaseFullType[0].toUpperCase() === phase_type.toUpperCase())) {
if (phasesToSkip === 0) {
return `${prevPhaseSeason} ${year} ${prevPhaseFullType}`;
}
phasesToSkip--;
}
}
}
}
public compare_phases(phase1_str: string, phase2_str: string): number {
let p1 = phase1_str;
let p2 = phase2_str;
// Handle 'S1901?' type abbreviations if necessary (Python specific)
// My phase_long and phase_abbr handle standard forms. Assume inputs are full or S1901M style.
if (p1.split(' ').length === 1 && p1 !== 'FORMING' && p1 !== 'COMPLETED') {
p1 = this.phase_long(p1, p1); // Convert S1901M to SPRING 1901 MOVEMENT
}
if (p2.split(' ').length === 1 && p2 !== 'FORMING' && p2 !== 'COMPLETED') {
p2 = this.phase_long(p2, p2);
}
if (p1 === p2) return 0;
const p1_parts = p1.split(' ');
const p2_parts = p2.split(' ');
// Handle FORMING and COMPLETED states
const getPhaseOrderVal = (parts: string[], fullStr: string): number => {
if (parts.length < 3) {
if (fullStr === 'FORMING') return 1;
if (fullStr === 'COMPLETED') return 3;
return 0; // Unknown
}
return 2; // Normal phase
};
const order1 = getPhaseOrderVal(p1_parts, p1);
const order2 = getPhaseOrderVal(p2_parts, p2);
if (order1 !== order2) {
return order1 > order2 ? 1 : -1;
}
if (order1 !== 2) return 0; // Both are FORMING or COMPLETED and equal, or both unknown and equal
// Both are normal phases, compare year then season index
const year1 = parseInt(p1_parts[1], 10);
const year2 = parseInt(p2_parts[1], 10);
if (year1 !== year2) {
return (year1 > year2 ? 1 : -1) * (this.flow_sign || 1);
}
// Years are the same, compare season index based on this.seq
const phaseKey1 = `${p1_parts[0].toUpperCase()} ${p1_parts[2].toUpperCase()}`;
const phaseKey2 = `${p2_parts[0].toUpperCase()} ${p2_parts[2].toUpperCase()}`;
const index1 = this.seq.findIndex(s => s === phaseKey1);
const index2 = this.seq.findIndex(s => s === phaseKey2);
if (index1 === -1 || index2 === -1) {
logger.warn("Phase comparison with phase not in sequence:", phaseKey1, phaseKey2, this.seq);
return 0; // Cannot compare if one is not in sequence
}
// Python's logic for NEWYEAR between seasons is complex:
// if season_ix1 > season_ix2: return -1 if 'NEWYEAR' in [x.split()[0] for x in self.seq[(season_ix2) + (1):season_ix1]] else 1
// This implies if NEWYEAR is crossed, the order inverts for that year.
// This is typically handled by the year increment/decrement in find_next/prev_phase.
// A direct comparison of indices within the same year should be sufficient here.
if (index1 > index2) return 1 * (this.flow_sign || 1);
if (index1 < index2) return -1 * (this.flow_sign || 1);
return 0;
}
public phase_abbr(phase: string, defaultVal: string = '?????'): string {