mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-30 17:40:47 +00:00
453 lines
20 KiB
TypeScript
453 lines
20 KiB
TypeScript
// diplomacy/engine/game.ts
|
|
|
|
import { DiplomacyMap } from './map';
|
|
import { PowerTs } from './power';
|
|
import { DiplomacyMessage, GLOBAL_RECIPIENT, OBSERVER_RECIPIENT, OMNISCIENT_RECIPIENT, SYSTEM_SENDER } from './message';
|
|
import {
|
|
OrderResult,
|
|
PossibleConvoyPathInfo,
|
|
DiplomacyMessageData,
|
|
SupportEntry,
|
|
UnitOrders, // Assuming this is Record<string, string> for { "A PAR": "- MAR" }
|
|
PowerOrderedUnits, // Assuming this is Record<string, string[]> for { "FRANCE": ["A PAR", "F BRE"] }
|
|
ConvoyPathsTable,
|
|
MayConvoyTable,
|
|
ParsedOrder // Make sure this is imported
|
|
} from './interfaces';
|
|
import { GamePhaseData, MESSAGES_TYPE_PLACEHOLDER as MESSAGES_TYPE } from '../utils/game_phase_data';
|
|
import * as diploStrings from '../utils/strings';
|
|
import * as err from '../utils/errors'; // Assuming you'll create a similar error constant file
|
|
import * as commonUtils from '../utils/common'; // Assuming common utilities
|
|
import { OrderSettings, DEFAULT_GAME_RULES } from '../utils/constants';
|
|
|
|
// Logger
|
|
const logger = {
|
|
debug: (message: string, ...args: any[]) => console.debug('[Game]', message, ...args),
|
|
info: (message: string, ...args: any[]) => console.info('[Game]', message, ...args),
|
|
warn: (message: string, ...args: any[]) => console.warn('[Game]', message, ...args),
|
|
error: (message: string, ...args: any[]) => console.error('[Game]', message, ...args),
|
|
};
|
|
|
|
// Simpler SortedDict replacement for now, assuming Map preserves insertion order for iteration.
|
|
type SortedMap<K, V> = Map<K, V>;
|
|
const createSortedMap = <K,V>() : SortedMap<K,V> => new Map<K,V>();
|
|
|
|
// Custom Diplomacy Exception (basic version)
|
|
class DiplomacyException extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "DiplomacyException";
|
|
}
|
|
}
|
|
|
|
|
|
export class DiplomacyGame {
|
|
// Properties
|
|
victory: number[] | null = null;
|
|
no_rules: Set<string> = new Set();
|
|
meta_rules: string[] = [];
|
|
phase: string = '';
|
|
note: string = '';
|
|
map: DiplomacyMap;
|
|
powers: Record<string, PowerTs> = {};
|
|
outcome: string[] = [];
|
|
error: string[] = [];
|
|
popped: string[] = [];
|
|
|
|
messages: SortedMap<number, DiplomacyMessage>;
|
|
order_history: SortedMap<string, Record<string, string[]>>;
|
|
orders: Record<string, UnitOrders> = {};
|
|
ordered_units: PowerOrderedUnits = {};
|
|
|
|
phase_type: string | null = null;
|
|
win: number = 0;
|
|
|
|
combat: Record<string, Record<number, Array<[string, string[]]>>> = {};
|
|
command: Record<string, ParsedOrder> = {}; // Changed from UnitOrders to Record<string, ParsedOrder>
|
|
result: Record<string, OrderResult[]> = {};
|
|
supports: Record<string, SupportEntry> = {};
|
|
dislodged: Record<string, string> = {}; // unit_name -> province_base_attacker_came_from
|
|
lost: Record<string, string> = {};
|
|
|
|
convoy_paths: ConvoyPathsTable = {};
|
|
convoy_paths_possible: PossibleConvoyPathInfo[] | null = null;
|
|
convoy_paths_dest: Map<string, Map<string, Set<string>[]>> = new Map();
|
|
|
|
zobrist_hash: string = "0";
|
|
|
|
game_id: string;
|
|
map_name: string = 'standard';
|
|
role: string;
|
|
rules: string[] = [];
|
|
|
|
message_history: SortedMap<string, SortedMap<number, DiplomacyMessage>>;
|
|
state_history: SortedMap<string, any>;
|
|
result_history: SortedMap<string, Record<string, any[]>>;
|
|
|
|
status: string;
|
|
timestamp_created: number;
|
|
n_controls: number | null = null;
|
|
deadline: number = 300;
|
|
registration_password: string | null = null;
|
|
|
|
observer_level: string | null = null;
|
|
controlled_powers: string[] | null = null;
|
|
daide_port: number | null = null;
|
|
|
|
fixed_state: [string, string] | null = null;
|
|
power_model_map: Record<string, string> = {};
|
|
phase_summaries: Record<string, string> = {};
|
|
|
|
parsed_orders_this_phase: ParsedOrder[] = [];
|
|
|
|
private _unit_owner_cache: Map<string, PowerTs | null> | null = null;
|
|
private _phase_wrapper_type: (phase: string) => string;
|
|
|
|
|
|
constructor(game_id?: string | null, initial_props: Partial<DiplomacyGame> = {}) {
|
|
this.game_id = game_id || `ts_game_${Date.now()}${Math.floor(Math.random()*1000)}`;
|
|
this.map_name = initial_props.map_name || 'standard';
|
|
this.map = new DiplomacyMap(this.map_name);
|
|
|
|
this.role = initial_props.role || diploStrings.SERVER_TYPE;
|
|
this.rules = [...(initial_props.rules || DEFAULT_GAME_RULES)];
|
|
this.no_rules = new Set(initial_props.no_rules || []);
|
|
this.meta_rules = initial_props.meta_rules || [];
|
|
|
|
this.phase = initial_props.phase || '';
|
|
this.note = initial_props.note || '';
|
|
this.outcome = initial_props.outcome || [];
|
|
this.error = initial_props.error || [];
|
|
this.popped = initial_props.popped || [];
|
|
|
|
this.messages = createSortedMap<number, DiplomacyMessage>();
|
|
this.order_history = createSortedMap<string, Record<string, string[]>>();
|
|
this.message_history = createSortedMap<string, SortedMap<number, DiplomacyMessage>>();
|
|
this.state_history = createSortedMap<string, any>();
|
|
this.result_history = createSortedMap<string, Record<string, any[]>>();
|
|
|
|
this.status = initial_props.status || diploStrings.FORMING;
|
|
this.timestamp_created = initial_props.timestamp_created || commonUtils.timestamp_microseconds();
|
|
this.n_controls = initial_props.n_controls !== undefined ? initial_props.n_controls : null;
|
|
this.deadline = initial_props.deadline !== undefined ? initial_props.deadline : 300;
|
|
this.registration_password = initial_props.registration_password || null;
|
|
this.zobrist_hash = initial_props.zobrist_hash || "0";
|
|
|
|
this.orders = initial_props.orders || {};
|
|
|
|
this._phase_wrapper_type = (phaseStr: string) => phaseStr;
|
|
|
|
const initialRules = [...this.rules];
|
|
this.rules = [];
|
|
initialRules.forEach(rule => this.add_rule(rule));
|
|
|
|
if (this.rules.includes('NO_DEADLINE')) this.deadline = 0;
|
|
if (this.rules.includes('SOLITAIRE')) this.n_controls = 0;
|
|
else if (this.n_controls === 0) this.add_rule('SOLITAIRE');
|
|
|
|
this._validate_status(initial_props.powers === undefined);
|
|
|
|
if (initial_props.powers) {
|
|
for (const [pName, pData] of Object.entries(initial_props.powers)) {
|
|
this.powers[pName] = new PowerTs(this, pName, pData as Partial<PowerTs>);
|
|
}
|
|
} else if (this.status !== diploStrings.FORMING) {
|
|
this._begin();
|
|
}
|
|
|
|
if(initial_props.order_history) this.order_history = new Map(Object.entries(initial_props.order_history).map(([k,v]) => [this._phase_wrapper_type(k),v]));
|
|
if(initial_props.message_history) this.message_history = new Map(Object.entries(initial_props.message_history).map(([k,v]) => [this._phase_wrapper_type(k), new Map(Object.entries(v).map(([ts,m])=>[Number(ts), new DiplomacyMessage(m)])) ]));
|
|
if(initial_props.state_history) this.state_history = new Map(Object.entries(initial_props.state_history).map(([k,v]) => [this._phase_wrapper_type(k),v]));
|
|
if(initial_props.result_history) this.result_history = new Map(Object.entries(initial_props.result_history).map(([k,v]) => [this._phase_wrapper_type(k),v]));
|
|
if(initial_props.messages) this.messages = new Map(Object.entries(initial_props.messages).map(([ts,m])=>[Number(ts), new DiplomacyMessage(m)]));
|
|
|
|
if (this.map && this.map.powers) {
|
|
this.map.powers.forEach(pName => {
|
|
if (!this.has_power(pName)) {
|
|
logger.error(`Map power ${pName} not found in game powers after init.`);
|
|
}
|
|
});
|
|
}
|
|
this.assert_power_roles();
|
|
}
|
|
|
|
private assert_power_roles(): void {
|
|
if (this.is_player_game()) {
|
|
if(!Object.values(this.powers).every(p => p.role === p.name)) {
|
|
// logger.warn("Inconsistent power roles for a player game."); // Allow for multi-power control
|
|
}
|
|
} else {
|
|
if(this.role !== diploStrings.OBSERVER_TYPE && this.role !== diploStrings.OMNISCIENT_TYPE && this.role !== diploStrings.SERVER_TYPE) {
|
|
logger.warn(`Game role ${this.role} is not a special type for a non-player game.`);
|
|
}
|
|
if(!Object.values(this.powers).every(p => p.role === this.role)) {
|
|
// logger.warn(`Inconsistent power roles; not all match game role ${this.role}.`); // Allow for multi-power control
|
|
}
|
|
}
|
|
}
|
|
|
|
get current_short_phase(): string {
|
|
return this.map.phase_abbr(this.phase, this.phase);
|
|
}
|
|
get is_game_done(): boolean { return this.phase === 'COMPLETED'; }
|
|
get is_game_forming(): boolean { return this.status === diploStrings.FORMING; }
|
|
|
|
is_supporting_orders_phase(): boolean {
|
|
return this.phase_type === 'M';
|
|
}
|
|
|
|
private _validate_status(reinit_powers: boolean): void {
|
|
if (!this.map) this.map = new DiplomacyMap(this.map_name);
|
|
this.victory = this.map.victory;
|
|
if (!this.victory || this.victory.length === 0) {
|
|
this.victory = [Math.floor(this.map.scs.length / 2) + 1];
|
|
}
|
|
|
|
if (!this.phase) this.phase = this.map.phase || "SPRING 1901 MOVEMENT"; // Default if map phase is also empty
|
|
|
|
const phaseParts = this.phase.split(' ');
|
|
if (phaseParts.length === 3) {
|
|
this.phase_type = phaseParts[2][0].toUpperCase();
|
|
} else if (this.phase === diploStrings.FORMING || this.phase === diploStrings.COMPLETED) {
|
|
this.phase_type = null; // Or some other indicator like '-'
|
|
} else {
|
|
logger.error(`Phase string "${this.phase}" is not in the expected "SEASON YEAR TYPE" format.`);
|
|
this.phase_type = '-'; // Default/error
|
|
}
|
|
|
|
if (this.phase !== diploStrings.FORMING && this.phase !== diploStrings.COMPLETED) {
|
|
try {
|
|
const year = Math.abs(parseInt(this.phase.split(' ')[1]) - (this.map.first_year || 1901));
|
|
this.win = this.victory[Math.min(year, this.victory.length - 1)];
|
|
} catch (e) {
|
|
this.error.push(err.GAME_BAD_YEAR_GAME_PHASE);
|
|
this.win = this.victory[0]; // Fallback
|
|
}
|
|
}
|
|
|
|
if (reinit_powers) {
|
|
this.powers = {};
|
|
(this.map.powers || []).forEach(pName => {
|
|
this.powers[pName] = new PowerTs(this, pName, { role: this.role });
|
|
});
|
|
}
|
|
}
|
|
|
|
private _begin(): void {
|
|
this._move_to_start_phase();
|
|
this.note = '';
|
|
this.win = this.victory ? this.victory[0] : 0;
|
|
|
|
(this.map.powers || []).forEach(pName => {
|
|
if (!this.powers[pName]) {
|
|
this.powers[pName] = new PowerTs(this, pName, { role: this.role });
|
|
}
|
|
});
|
|
Object.values(this.powers).forEach(power => power.initialize(this));
|
|
this.build_caches();
|
|
logger.info(`Game ${this.game_id} begun. Phase: ${this.phase}`);
|
|
}
|
|
|
|
private _move_to_start_phase(): void {
|
|
this.phase = this.map.phase || "SPRING 1901 MOVEMENT";
|
|
const parts = this.phase.split(' ');
|
|
this.phase_type = (parts.length === 3) ? parts[2][0].toUpperCase() : '-';
|
|
}
|
|
|
|
public get_power(power_name?: string | null): PowerTs | null {
|
|
if (!power_name) return null;
|
|
return this.powers[power_name.toUpperCase()] || null;
|
|
}
|
|
|
|
public has_power(power_name: string): boolean {
|
|
return !!this.get_power(power_name);
|
|
}
|
|
|
|
public add_rule(rule: string): void {
|
|
if (!this.rules.includes(rule)) {
|
|
this.rules.push(rule);
|
|
}
|
|
}
|
|
|
|
public update_hash(powerName: string, details: any): void { /* Placeholder for Zobrist Hashing */ }
|
|
|
|
public clear_cache(): void {
|
|
this._unit_owner_cache = null;
|
|
this.convoy_paths_possible = null;
|
|
this.convoy_paths_dest = new Map();
|
|
logger.debug("Game caches cleared.");
|
|
}
|
|
public build_caches(): void {
|
|
this.clear_cache();
|
|
this._build_list_possible_convoys_ts();
|
|
this._build_unit_owner_cache_ts();
|
|
logger.debug("Game.build_caches() called and executed helper methods.");
|
|
}
|
|
|
|
public get_state(): any {
|
|
const state: any = {};
|
|
state['timestamp'] = commonUtils.timestamp_microseconds();
|
|
state['zobrist_hash'] = this.zobrist_hash;
|
|
state['note'] = this.note;
|
|
state['name'] = this.current_short_phase;
|
|
state['units'] = {}; state['retreats'] = {}; state['centers'] = {};
|
|
state['homes'] = {}; state['influence'] = {}; state['civil_disorder'] = {};
|
|
state['builds'] = {};
|
|
|
|
for (const power of Object.values(this.powers)) {
|
|
state['units'][power.name] = [...power.units, ...Object.keys(power.retreats).map(u => `*${u}`)];
|
|
state['retreats'][power.name] = { ...power.retreats };
|
|
state['centers'][power.name] = [...power.centers];
|
|
state['homes'][power.name] = [...(power.homes || [])];
|
|
state['influence'][power.name] = [...power.influence];
|
|
state['civil_disorder'][power.name] = power.civil_disorder;
|
|
state['builds'][power.name] = {};
|
|
if (this.phase_type !== 'A') {
|
|
state['builds'][power.name]['count'] = 0;
|
|
state['builds'][power.name]['homes'] = [];
|
|
} else {
|
|
const build_count = power.centers.length - power.units.length;
|
|
state['builds'][power.name]['count'] = build_count;
|
|
const build_sites = (build_count > 0) ? this._build_sites(power) : [];
|
|
state['builds'][power.name]['homes'] = build_sites;
|
|
if (build_count > 0) {
|
|
state['builds'][power.name]['count'] = Math.min(build_sites.length, build_count);
|
|
}
|
|
}
|
|
}
|
|
state["phase"] = this.phase;
|
|
return state;
|
|
}
|
|
|
|
private _build_sites(power: PowerTs): string[] {
|
|
let potential_homes = power.homes || [];
|
|
if (this.rules.includes('BUILD_ANY')) { // Untested rule variant
|
|
potential_homes = [...power.centers];
|
|
}
|
|
const occupied_locs = new Set<string>();
|
|
Object.values(this.powers).forEach(p => p.units.forEach(u => occupied_locs.add(u.substring(2,5).toUpperCase())));
|
|
|
|
return potential_homes.filter(h_base =>
|
|
power.centers.includes(h_base.toUpperCase()) &&
|
|
!occupied_locs.has(h_base.toUpperCase())
|
|
).map(h => h.toUpperCase());
|
|
}
|
|
|
|
get_orders_from_power(power_name: string): Record<string, string> | string[] {
|
|
power_name = power_name.toUpperCase();
|
|
const power = this.get_power(power_name);
|
|
if (!power) return [];
|
|
if (this.phase_type === 'M' || this.phase_type === 'R') { // Retreat orders also in power.orders for units
|
|
return { ...power.orders };
|
|
}
|
|
return [...power.adjust]; // Build/Disband orders in power.adjust
|
|
}
|
|
|
|
_get_all_orders(): Record<string, Record<string, string> | string[]> {
|
|
const all_orders: Record<string, Record<string, string> | string[]> = {};
|
|
for (const pName of Object.keys(this.powers)) {
|
|
all_orders[pName] = this.get_orders_from_power(pName);
|
|
}
|
|
return all_orders;
|
|
}
|
|
|
|
get_orders(power_name?: string): string[] | Record<string, string[]> {
|
|
if (power_name) {
|
|
const power = this.get_power(power_name.toUpperCase());
|
|
if (!power) return [];
|
|
|
|
if (this.phase_type === 'M' || this.phase_type === 'R') {
|
|
// For M and R phases, orders are { [unitFullName]: orderSuffix }
|
|
// We need to reconstruct the full order string.
|
|
const full_orders: string[] = [];
|
|
for (const unitFullName in power.orders) {
|
|
if (power.orders[unitFullName]) { // Ensure there's an order part
|
|
full_orders.push(`${unitFullName} ${power.orders[unitFullName]}`);
|
|
} else { // Implicit hold if unit in power.orders but value is empty/null
|
|
full_orders.push(`${unitFullName} H`);
|
|
}
|
|
}
|
|
return full_orders;
|
|
} else { // A phase
|
|
return power.adjust.filter(order => !!order && order.toUpperCase() !== 'WAIVE' && !order.toUpperCase().startsWith('VOID '));
|
|
}
|
|
} else {
|
|
const allFormattedOrders: Record<string, string[]> = {};
|
|
for (const pName of Object.keys(this.powers)) {
|
|
allFormattedOrders[pName] = this.get_orders(pName) as string[];
|
|
}
|
|
return allFormattedOrders;
|
|
}
|
|
}
|
|
|
|
private _set_orders_internal(power: PowerTs, order_strings: string[], expand: boolean, replace: boolean): void {
|
|
// For M and R phases, orders are stored in power.orders = { [unitName]: "order suffix" }
|
|
// For A phase, orders are stored in power.adjust = string[]
|
|
|
|
const ordersToProcess = order_strings.filter(o => o && o.trim() !== "");
|
|
|
|
if (this.phase_type === 'A') { // Adjustment phase
|
|
if (replace) power.adjust = [];
|
|
ordersToProcess.forEach(order => {
|
|
// Basic syntax validation for adjustment orders.
|
|
// Example: "A PAR B" or "F LON D" or "WAIVE"
|
|
// _parse_order_string will handle most syntax.
|
|
const parsed = this._parse_order_string(order, power.name);
|
|
if (parsed.is_valid_syntax && (parsed.order_type === 'B' || parsed.order_type === 'D' || parsed.order_type === 'W')) {
|
|
if (replace || !power.adjust.includes(order)) { // Simple check for duplicates if not replacing
|
|
power.adjust.push(order);
|
|
}
|
|
} else {
|
|
this.error.push(err.STD_GAME_BAD_ORDER.replace('%s', order) + (parsed.validation_error ? ` (${parsed.validation_error})` : ""));
|
|
}
|
|
});
|
|
} else { // Movement or Retreat phase
|
|
if (replace) power.orders = {};
|
|
ordersToProcess.forEach(order_full_str => {
|
|
const parsed = this._parse_order_string(order_full_str, power.name);
|
|
if (parsed.is_valid_syntax && parsed.unit_type && parsed.unit_location) {
|
|
const unitFullName = `${parsed.unit_type} ${parsed.unit_location}`;
|
|
// Reconstruct the order suffix
|
|
const suffixParts: string[] = [];
|
|
if (parsed.order_type && parsed.order_type !== 'H') suffixParts.push(parsed.order_type); // H is often implicit
|
|
|
|
if (parsed.order_type === 'M' || parsed.order_type === 'R') {
|
|
if (parsed.target_location) suffixParts.push(parsed.target_location + (parsed.target_coast ? `/${parsed.target_coast}`: ""));
|
|
if (parsed.via_convoy) suffixParts.push("VIA");
|
|
} else if (parsed.order_type === 'S') {
|
|
if (parsed.supported_unit_type && parsed.supported_unit_location) {
|
|
suffixParts.push(parsed.supported_unit_type, parsed.supported_unit_location);
|
|
if (parsed.support_target_location) {
|
|
suffixParts.push("-", parsed.support_target_location + (parsed.support_target_coast ? `/${parsed.support_target_coast}`: ""));
|
|
}
|
|
}
|
|
} else if (parsed.order_type === 'C') {
|
|
if (parsed.convoyed_unit_type && parsed.convoyed_unit_location && parsed.convoy_destination_location) {
|
|
suffixParts.push(parsed.convoyed_unit_type, parsed.convoyed_unit_location, "-", parsed.convoy_destination_location);
|
|
}
|
|
} else if (parsed.order_type === 'D') {
|
|
// Suffix is just 'D'
|
|
} else if (parsed.order_type === 'H') {
|
|
// Suffix is just 'H' or empty
|
|
}
|
|
|
|
|
|
const orderSuffix = suffixParts.length > 0 ? suffixParts.join(" ") : "H"; // Default to Hold if no other parts
|
|
|
|
if (replace || !power.orders[unitFullName]) {
|
|
power.orders[unitFullName] = orderSuffix;
|
|
} else {
|
|
this.error.push(err.GAME_UNIT_REORDERED.replace('%s', unitFullName));
|
|
}
|
|
} else {
|
|
this.error.push(err.STD_GAME_BAD_ORDER.replace('%s', order_full_str) + (parsed.validation_error ? ` (${parsed.validation_error})` : ""));
|
|
}
|
|
});
|
|
}
|
|
power.order_is_set = (Object.keys(power.orders).length > 0 || power.adjust.length > 0) ?
|
|
OrderSettings.ORDER_SET : OrderSettings.ORDER_SET_EMPTY;
|
|
}
|
|
// ... (rest of the class, including _resolve_moves, _apply_adjudication_results_ts, etc.)
|
|
// ... and the new methods: _update_sc_ownership, _get_supply_center_owner, _can_build_unit_type_in_province, _resolve_adjustments
|
|
// ... and the modified _process_internal and _advance_phase
|
|
}
|