mirror of
https://github.com/GoodStartLabs/AI_Diplomacy.git
synced 2026-04-29 17:35:18 +00:00
Jules was unable to complete the task in time. Please review the work done so far and provide feedback for Jules to continue.
This commit is contained in:
parent
eea059ce5a
commit
3140458246
42 changed files with 13026 additions and 390 deletions
|
|
@ -3,13 +3,22 @@
|
|||
import { DiplomacyMap } from './map';
|
||||
import { PowerTs } from './power';
|
||||
import { DiplomacyMessage, GLOBAL_RECIPIENT, OBSERVER_RECIPIENT, OMNISCIENT_RECIPIENT, SYSTEM_SENDER } from './message';
|
||||
// import { Renderer } from './renderer'; // Placeholder
|
||||
import { GamePhaseData, MESSAGES_TYPE_PLACEHOLDER as MESSAGES_TYPE } from '../utils/game_phase_data'; // Assuming GamePhaseData is in utils or a dedicated file
|
||||
import * as diploStrings from '../utils/strings'; // Placeholder, eventually specific strings
|
||||
import * as err from '../utils/errors'; // Placeholder
|
||||
import * as common from '../utils/common'; // Placeholder
|
||||
import * as parsing from '../utils/parsing'; // Placeholder
|
||||
import { OrderSettings, DEFAULT_GAME_RULES } from '../utils/constants'; // Placeholder
|
||||
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 = {
|
||||
|
|
@ -20,96 +29,98 @@ const logger = {
|
|||
};
|
||||
|
||||
// Simpler SortedDict replacement for now, assuming Map preserves insertion order for iteration.
|
||||
// For strict sorted behavior based on custom comparator for phases, a dedicated library or implementation would be needed.
|
||||
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 from __slots__ and __init__
|
||||
// Properties
|
||||
victory: number[] | null = null;
|
||||
no_rules: Set<string> = new Set();
|
||||
meta_rules: string[] = [];
|
||||
phase: string = '';
|
||||
note: string = '';
|
||||
map: DiplomacyMap;
|
||||
powers: Record<string, PowerTs> = {}; // power_name -> PowerTs instance
|
||||
powers: Record<string, PowerTs> = {};
|
||||
outcome: string[] = [];
|
||||
error: string[] = []; // Stores error messages (strings, not Error objects from Python)
|
||||
popped: string[] = []; // list of units that were disbanded because they couldn't retreat
|
||||
error: string[] = [];
|
||||
popped: string[] = [];
|
||||
|
||||
messages: SortedMap<number, DiplomacyMessage>; // timestamp -> Message
|
||||
order_history: SortedMap<string, Record<string, string[]>>; // phase_short_name -> power_name -> orders
|
||||
orders: Record<string, string> = {}; // unit_str -> order_string (current phase)
|
||||
ordered_units: Record<string, string[]> = {}; // power_name -> list of units that received orders
|
||||
messages: SortedMap<number, DiplomacyMessage>;
|
||||
order_history: SortedMap<string, Record<string, string[]>>;
|
||||
orders: Record<string, UnitOrders> = {};
|
||||
ordered_units: PowerOrderedUnits = {};
|
||||
|
||||
phase_type: string | null = null; // 'M', 'R', 'A', or '-'
|
||||
win: number = 0; // Min centers to win based on current year and victory conditions
|
||||
phase_type: string | null = null;
|
||||
win: number = 0;
|
||||
|
||||
// Adjudication-related properties
|
||||
combat: Record<string, Record<number, Array<[string, string[]]>>> = {};
|
||||
command: Record<string, string> = {}; // unit_str -> full_order_str (finalized for processing)
|
||||
result: Record<string, any[]> = {}; // unit_str -> list of result codes/objects
|
||||
supports: Record<string, [number, string[]]> = {}; // unit_str -> [count, non_dislodging_supporters[]]
|
||||
dislodged: Record<string, string> = {}; // dislodged_unit_str -> attacking_loc_short
|
||||
lost: Record<string, string> = {}; // lost_center_loc_short -> original_owner_name
|
||||
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: Record<string, string[][]> = {};
|
||||
convoy_paths_possible: Array<[string, Set<string>, Set<string>]> | null = null;
|
||||
convoy_paths_dest: Record<string, Record<string, Set<string>[]>> | null = null;
|
||||
convoy_paths: ConvoyPathsTable = {};
|
||||
convoy_paths_possible: PossibleConvoyPathInfo[] | null = null;
|
||||
convoy_paths_dest: Map<string, Map<string, Set<string>[]>> = new Map();
|
||||
|
||||
zobrist_hash: string = "0"; // Python uses int, JS can use string for large numbers
|
||||
// renderer: Renderer | null = null; // Placeholder
|
||||
zobrist_hash: string = "0";
|
||||
|
||||
game_id: string;
|
||||
map_name: string = 'standard';
|
||||
role: string; // Current player's role (power_name, OBSERVER, OMNISCIENT, SERVER)
|
||||
role: string;
|
||||
rules: string[] = [];
|
||||
|
||||
message_history: SortedMap<string, SortedMap<number, DiplomacyMessage>>;
|
||||
state_history: SortedMap<string, any>; // phase_short_name -> game_state_dict
|
||||
result_history: SortedMap<string, Record<string, any[]>>; // phase_short_name -> unit_str -> results
|
||||
state_history: SortedMap<string, any>;
|
||||
result_history: SortedMap<string, Record<string, any[]>>;
|
||||
|
||||
status: string; // FORMING, ACTIVE, PAUSED, COMPLETED, CANCELED
|
||||
status: string;
|
||||
timestamp_created: number;
|
||||
n_controls: number | null = null; // Expected number of human players
|
||||
deadline: number = 300; // seconds
|
||||
registration_password: string | null = null; // Hashed password
|
||||
n_controls: number | null = null;
|
||||
deadline: number = 300;
|
||||
registration_password: string | null = null;
|
||||
|
||||
// Client-specific game properties (placeholders, might be on a derived class)
|
||||
observer_level: string | null = null;
|
||||
controlled_powers: string[] | null = null;
|
||||
daide_port: number | null = null;
|
||||
|
||||
fixed_state: [string, string] | null = null; // [phase_abbr, zobrist_hash] for context manager
|
||||
power_model_map: Record<string, string> = {}; // For AI agents
|
||||
phase_summaries: Record<string, string> = {}; // phase_short_name -> summary_text
|
||||
fixed_state: [string, string] | null = null;
|
||||
power_model_map: Record<string, string> = {};
|
||||
phase_summaries: Record<string, string> = {};
|
||||
|
||||
// Caches
|
||||
private _unit_owner_cache: Map<string, PowerTs | null> | null = null; // key: "unit_str,coast_req_bool"
|
||||
parsed_orders_this_phase: ParsedOrder[] = [];
|
||||
|
||||
// For SortedDict phase key wrapping
|
||||
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> = {}) {
|
||||
// Initialize many properties from initial_props or defaults
|
||||
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); // Load map
|
||||
this.map = new DiplomacyMap(this.map_name);
|
||||
|
||||
this.role = initial_props.role || diploStrings.SERVER_TYPE;
|
||||
this.rules = [...(initial_props.rules || DEFAULT_GAME_RULES)]; // Process rules via add_rule later
|
||||
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 || ''; // Will be set by _begin or set_state
|
||||
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>(); // TODO: Handle initial messages if any
|
||||
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>();
|
||||
|
|
@ -122,17 +133,11 @@ export class DiplomacyGame {
|
|||
this.registration_password = initial_props.registration_password || null;
|
||||
this.zobrist_hash = initial_props.zobrist_hash || "0";
|
||||
|
||||
// Phase wrapper for sorted history keys
|
||||
// In TS, if keys are strings like "S1901M", standard string sort might not be chronological.
|
||||
// The Python version uses a custom class that implements __lt__ based on map.compare_phases.
|
||||
// For TS Map, keys are iterated in insertion order. If chronological processing is key,
|
||||
// we might need to store phases in an array or use a library for sorted maps with custom comparators.
|
||||
// For now, this is a conceptual placeholder.
|
||||
this.orders = initial_props.orders || {};
|
||||
|
||||
this._phase_wrapper_type = (phaseStr: string) => phaseStr;
|
||||
|
||||
|
||||
// Process initial rules (Python does this via property setter or __init__ loop)
|
||||
const initialRules = [...this.rules]; // copy before clearing
|
||||
const initialRules = [...this.rules];
|
||||
this.rules = [];
|
||||
initialRules.forEach(rule => this.add_rule(rule));
|
||||
|
||||
|
|
@ -140,28 +145,23 @@ export class DiplomacyGame {
|
|||
if (this.rules.includes('SOLITAIRE')) this.n_controls = 0;
|
||||
else if (this.n_controls === 0) this.add_rule('SOLITAIRE');
|
||||
|
||||
// Validate status and initialize powers if game is new
|
||||
this._validate_status(initial_props.powers === undefined); // reinit_powers if not loading from existing state
|
||||
this._validate_status(initial_props.powers === undefined);
|
||||
|
||||
if (initial_props.powers) {
|
||||
for (const [pName, pData] of Object.entries(initial_props.powers)) {
|
||||
// Assuming pData is partial data for PowerTs constructor
|
||||
this.powers[pName] = new PowerTs(this, pName, pData as Partial<PowerTs>);
|
||||
}
|
||||
} else if (this.status !== diploStrings.FORMING) { // If not forming and no powers given, _begin initializes them
|
||||
} else if (this.status !== diploStrings.FORMING) {
|
||||
this._begin();
|
||||
}
|
||||
|
||||
// Wrap history fields from initial_props if they exist
|
||||
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)]));
|
||||
|
||||
|
||||
// Final checks from Python __init__
|
||||
if (this.map && this.map.powers) { // map should be loaded by now
|
||||
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.`);
|
||||
|
|
@ -174,56 +174,61 @@ export class DiplomacyGame {
|
|||
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.");
|
||||
// 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}.`);
|
||||
// logger.warn(`Inconsistent power roles; not all match game role ${this.role}.`); // Allow for multi-power control
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Basic Property Getters ---
|
||||
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; }
|
||||
// ... other is_game_... status getters
|
||||
|
||||
// --- Core Methods (Stubs for now) ---
|
||||
is_supporting_orders_phase(): boolean {
|
||||
return this.phase_type === 'M';
|
||||
}
|
||||
|
||||
private _validate_status(reinit_powers: boolean): void {
|
||||
logger.debug(`Validating status. Current: ${this.status}, reinit_powers: ${reinit_powers}`);
|
||||
if (!this.map) this.map = new DiplomacyMap(this.map_name); // Ensure map is loaded
|
||||
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;
|
||||
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]; // M, R, A
|
||||
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 {
|
||||
this.phase_type = '-'; // For FORMING, COMPLETED
|
||||
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);
|
||||
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); }
|
||||
} catch (e) {
|
||||
this.error.push(err.GAME_BAD_YEAR_GAME_PHASE);
|
||||
this.win = this.victory[0]; // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (reinit_powers) {
|
||||
this.powers = {}; // Clear existing if any
|
||||
this.map.powers.forEach(pName => {
|
||||
this.powers = {};
|
||||
(this.map.powers || []).forEach(pName => {
|
||||
this.powers[pName] = new PowerTs(this, pName, { role: this.role });
|
||||
// Initialize is called in _begin or if powers are passed in initial_props
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -233,7 +238,7 @@ export class DiplomacyGame {
|
|||
this.note = '';
|
||||
this.win = this.victory ? this.victory[0] : 0;
|
||||
|
||||
this.map.powers.forEach(pName => {
|
||||
(this.map.powers || []).forEach(pName => {
|
||||
if (!this.powers[pName]) {
|
||||
this.powers[pName] = new PowerTs(this, pName, { role: this.role });
|
||||
}
|
||||
|
|
@ -244,8 +249,9 @@ export class DiplomacyGame {
|
|||
}
|
||||
|
||||
private _move_to_start_phase(): void {
|
||||
this.phase = this.map.phase; // Get initial phase from map
|
||||
this.phase_type = this.phase.split(' ')[2][0];
|
||||
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 {
|
||||
|
|
@ -258,44 +264,34 @@ export class DiplomacyGame {
|
|||
}
|
||||
|
||||
public add_rule(rule: string): void {
|
||||
// Simplified rule addition. Full logic from Python is complex.
|
||||
if (!this.rules.includes(rule)) {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder for game.update_hash (critical for state tracking if Zobrist is used)
|
||||
public update_hash(powerName: string, details: any): void {
|
||||
// logger.debug(`update_hash called for ${powerName}`, details);
|
||||
}
|
||||
// Placeholder for game.clear_cache (called after state changes)
|
||||
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 = null;
|
||||
// logger.debug("Game caches cleared.");
|
||||
this.convoy_paths_dest = new Map();
|
||||
logger.debug("Game caches cleared.");
|
||||
}
|
||||
|
||||
public build_caches(): void {
|
||||
this.clear_cache();
|
||||
// this._build_list_possible_convoys(); // Placeholder
|
||||
// this._build_unit_owner_cache(); // Placeholder
|
||||
logger.warn("Game.build_caches() is a simplified stub.");
|
||||
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 {
|
||||
// Simplified version of Python's get_state
|
||||
const state: any = {};
|
||||
state['timestamp'] = commonUtils.timestamp_microseconds();
|
||||
state['zobrist_hash'] = this.zobrist_hash;
|
||||
state['note'] = this.note;
|
||||
state['name'] = this.current_short_phase; // Uses property getter
|
||||
state['units'] = {};
|
||||
state['retreats'] = {};
|
||||
state['centers'] = {};
|
||||
state['homes'] = {};
|
||||
state['influence'] = {};
|
||||
state['civil_disorder'] = {};
|
||||
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)) {
|
||||
|
|
@ -305,38 +301,153 @@ export class DiplomacyGame {
|
|||
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 {
|
||||
state['builds'][power.name]['count'] = power.centers.length - power.units.length;
|
||||
}
|
||||
state['builds'][power.name]['homes'] = (this.phase_type === 'A' && state['builds'][power.name]['count'] > 0)
|
||||
? this._build_sites(power)
|
||||
: [];
|
||||
if (this.phase_type === 'A' && state['builds'][power.name]['count'] > 0) {
|
||||
state['builds'][power.name]['count'] = Math.min(state['builds'][power.name]['homes'].length, state['builds'][power.name]['count']);
|
||||
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; // Full phase string
|
||||
state["phase"] = this.phase;
|
||||
return state;
|
||||
}
|
||||
|
||||
private _build_sites(power: PowerTs): string[] {
|
||||
// Simplified placeholder for _build_sites logic
|
||||
logger.warn("_build_sites is a simplified stub.");
|
||||
let potential_homes = power.homes || [];
|
||||
if (this.rules.includes('BUILD_ANY')) { // BUILD_ANY rule check
|
||||
potential_homes = power.centers;
|
||||
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))));
|
||||
Object.values(this.powers).forEach(p => p.units.forEach(u => occupied_locs.add(u.substring(2,5).toUpperCase())));
|
||||
|
||||
return potential_homes.filter(h => power.centers.includes(h) && !occupied_locs.has(h));
|
||||
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
|
||||
}
|
||||
|
||||
// Many methods like process, _resolve_moves, _valid_order, etc. are very complex and omitted for this initial structure.
|
||||
// These would be added incrementally.
|
||||
_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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,14 @@
|
|||
// This file can be used to export symbols from other modules in this directory.
|
||||
// For now, it's empty as the corresponding __init__.py was empty.
|
||||
// diplomacy/engine/index.ts
|
||||
export { DiplomacyMap } from './map';
|
||||
export type { ConvoyPathInfo, ConvoyPathData } from './map'; // Exporting types if they might be needed externally
|
||||
export { DiplomacyMessage } from './message';
|
||||
export { PowerTs as Power } from './power'; // Exporting PowerTs as Power for consistency if used elsewhere
|
||||
export { Renderer } from './renderer';
|
||||
// Game will be exported once created/translated
|
||||
|
||||
// Other exports from this directory can be added here as modules are completed.
|
||||
// e.g. export * from './game'; when game.ts is ready
|
||||
// e.g. export * from './unit'; when unit.ts is ready (if it exists)
|
||||
// e.g. export * from './province'; when province.ts is ready (if it exists)
|
||||
// e.g. export * from './order'; when order.ts is ready (if it exists)
|
||||
// e.g. export * from './adjudicator'; when adjudicator.ts is ready (if it exists)
|
||||
|
|
|
|||
123
diplomacy/engine/interfaces.ts
Normal file
123
diplomacy/engine/interfaces.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// diplomacy/engine/interfaces.ts
|
||||
|
||||
import { PowerTs } from './power'; // For _unit_owner_cache if defined here
|
||||
import { DiplomacyMessage } from './message'; // For message history types
|
||||
|
||||
// From diplomacy.utils.order_results
|
||||
export enum OrderResult {
|
||||
OK = 'ok', // Assuming '' or OK from python is 'ok'
|
||||
NO_CONVOY = 'no convoy',
|
||||
BOUNCE = 'bounce',
|
||||
VOID = 'void',
|
||||
CUT = 'cut',
|
||||
DISLODGED = 'dislodged',
|
||||
DISRUPTED = 'disrupted',
|
||||
DISBAND = 'disband',
|
||||
MAYBE = 'maybe', // Used in intermediate steps
|
||||
// Add any other results if they appear
|
||||
}
|
||||
|
||||
// Placeholder for what DiplomacyMessage.toJSON() might return
|
||||
// This should align with the actual toJSON implementation in message.ts
|
||||
export interface DiplomacyMessageData {
|
||||
phase: string;
|
||||
sender: string;
|
||||
recipient: string;
|
||||
message: string;
|
||||
time_sent?: number;
|
||||
// any other fields from toJSON()
|
||||
}
|
||||
|
||||
export interface GameStateHistoryEntry {
|
||||
timestamp: number;
|
||||
zobrist_hash: string;
|
||||
note: string;
|
||||
name: string; // short phase name e.g. S1901M
|
||||
units: Record<string, string[]>; // { FRANCE: ["A PAR", "*F BRE"], ... }
|
||||
retreats: Record<string, Record<string, string[]>>; // { FRANCE: { "F BRE": ["ENG", "MAO"] }, ... }
|
||||
centers: Record<string, string[]>; // { FRANCE: ["PAR", "MAR"], ... }
|
||||
homes: Record<string, string[]>;
|
||||
influence: Record<string, string[]>;
|
||||
civil_disorder: Record<string, number>; // Or boolean, depending on Python version
|
||||
builds: Record<string, { count: number; homes: string[] }>;
|
||||
phase: string; // long phase name
|
||||
}
|
||||
|
||||
export interface SupportEntry {
|
||||
count: number;
|
||||
from: string[]; // list of units whose support does NOT count toward dislodgment
|
||||
}
|
||||
|
||||
// Used in Game.combat
|
||||
// Format: {loc: { attack_strength: [ ['src loc unit', [supporting_unit_locs_not_counting_for_dislodgement]] ]}}
|
||||
// e.g. { 'MUN': { 1 : [ ['A MUN', [] ], ['A RUH', [] ] ], 2 : [ ['A SIL', ['A BOH']] ] } }
|
||||
export interface CombatSiteEntry {
|
||||
[attackStrength: number]: Array<[string, string[]]>; // unit_name_at_src_loc, list_of_supporting_unit_locs
|
||||
}
|
||||
export type CombatTable = Record<string, CombatSiteEntry>; // loc_being_attacked_or_held
|
||||
|
||||
/**
|
||||
* Stores information about possible convoy paths based on current fleet locations.
|
||||
* Used in `Game.convoy_paths_possible`.
|
||||
*/
|
||||
export interface PossibleConvoyPathInfo {
|
||||
start: string; // Starting location of the army
|
||||
fleetsRequired: Set<string>; // Set of fleet locations required for this path segment
|
||||
possibleDests: Set<string>; // Set of destination locations reachable via these fleets from start
|
||||
}
|
||||
|
||||
// Cache for _unit_owner
|
||||
// Key: unit string (e.g., "A PAR", "F STP/SC") or unit string without coast (e.g., "F STP")
|
||||
// Value: PowerTs instance or null
|
||||
export type UnitOwnerCache = Map<string, PowerTs | null>;
|
||||
|
||||
// For Game.orders (mapping unit to its order string)
|
||||
export type UnitOrders = Record<string, string>;
|
||||
|
||||
// For Game.ordered_units (mapping power name to list of its units that have orders)
|
||||
export type PowerOrderedUnits = Record<string, string[]>;
|
||||
|
||||
// For Game.convoy_paths (mapping army unit to list of possible paths)
|
||||
// Each path is a list of locations: [start_loc, fleet1_loc, fleet2_loc, ..., end_loc]
|
||||
export type ConvoyPathsTable = Record<string, string[][]>;
|
||||
|
||||
// For may_convoy variable in _resolve_moves
|
||||
// Maps an army unit (string) to a list of fleet locations (strings) that are involved in some valid path for it
|
||||
export type MayConvoyTable = Record<string, string[]>;
|
||||
|
||||
// Structured Order Representation
|
||||
export interface ParsedOrder {
|
||||
order_string: string;
|
||||
is_valid_syntax: boolean;
|
||||
validation_error?: string;
|
||||
|
||||
unit_power?: string;
|
||||
unit_location?: string;
|
||||
unit_type?: 'A' | 'F';
|
||||
|
||||
order_type?: 'H' | 'M' | 'S' | 'C' | 'B' | 'D' | 'W' | 'R'; // Hold, Move, Support, Convoy, Build, Disband, Waive, Retreat
|
||||
|
||||
// For Move orders
|
||||
target_location?: string;
|
||||
target_coast?: string;
|
||||
|
||||
// For Support orders
|
||||
supported_unit_location?: string;
|
||||
supported_unit_type?: 'A' | 'F';
|
||||
support_target_location?: string;
|
||||
support_target_coast?: string;
|
||||
|
||||
// For Convoy orders
|
||||
convoyed_unit_location?: string;
|
||||
convoyed_unit_type?: 'A' | 'F';
|
||||
convoy_destination_location?: string;
|
||||
|
||||
// For Build orders
|
||||
build_unit_type?: 'A' | 'F';
|
||||
|
||||
via_convoy?: boolean;
|
||||
|
||||
// For rule validation
|
||||
is_valid_rule?: boolean;
|
||||
rule_validation_error?: string;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
429
diplomacy/engine/renderer.ts
Normal file
429
diplomacy/engine/renderer.ts
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
// diplomacy/engine/renderer.ts
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
||||
import { DiplomacyGame } from './game';
|
||||
import { DiplomacyMap } from './map'; // Assuming DiplomacyMap might be needed directly or via game.map
|
||||
// import { ParsedOrder } from './interfaces'; // If rendering parsed orders directly
|
||||
|
||||
// Constants
|
||||
const LAYER_ORDER = 'OrderLayer';
|
||||
const LAYER_UNIT = 'UnitLayer';
|
||||
const LAYER_DISL = 'DislodgedUnitLayer';
|
||||
const ARMY = 'Army';
|
||||
const FLEET = 'Fleet';
|
||||
|
||||
// Placeholder for settings if not imported from a central place
|
||||
const settings = {
|
||||
PACKAGE_DIR: path.join(__dirname, '../../..'), // Adjust path as necessary to reach project root
|
||||
};
|
||||
|
||||
// Helper to get attribute value, similar to Python's _attr
|
||||
function getAttribute(node: Element, attrName: string, namespaceURI?: string | null): string | null {
|
||||
if (namespaceURI) {
|
||||
const attr = node.getAttributeNS(namespaceURI, attrName);
|
||||
return attr;
|
||||
}
|
||||
const attrItem = node.attributes.getNamedItem(attrName);
|
||||
return attrItem ? attrItem.value : null;
|
||||
}
|
||||
|
||||
interface RendererMetadata {
|
||||
color: Record<string, string>; // powerName -> colorString
|
||||
symbol_size: Record<string, [string, string]>; // symbolName -> [height, width]
|
||||
orders: Record<string, any>; // Placeholder, structure depends on jdipNS:ORDERDRAWING specifics
|
||||
coord: Record<string, { // provinceName (uppercase, e.g. PAR, MAR/SC)
|
||||
unit?: [string, string]; // [x, y]
|
||||
disl?: [string, string]; // [x, y]
|
||||
}>;
|
||||
}
|
||||
|
||||
export class Renderer {
|
||||
private game: DiplomacyGame;
|
||||
private metadata: RendererMetadata;
|
||||
private xml_map: string | null = null; // Store as string, parse when needed
|
||||
|
||||
constructor(game: DiplomacyGame, svg_path_override?: string) {
|
||||
this.game = game;
|
||||
this.metadata = {
|
||||
color: {},
|
||||
symbol_size: {},
|
||||
orders: {}, // To be defined by _load_metadata
|
||||
coord: {} // To be defined by _load_metadata
|
||||
};
|
||||
|
||||
let svg_path = svg_path_override;
|
||||
if (!svg_path) {
|
||||
// Default SVG path logic (assuming game.map.name and game.map.root_map are available)
|
||||
const map_name = this.game.map_name || 'standard'; // Use game.map_name
|
||||
const root_map = this.game.map.root_map || map_name.split('.')[0]; // Ensure map.root_map exists
|
||||
|
||||
for (const file_name of [`${map_name}.svg`, `${root_map}.svg`]) {
|
||||
const potential_path = path.join(settings.PACKAGE_DIR, 'maps', 'svg', file_name);
|
||||
if (fs.existsSync(potential_path)) {
|
||||
svg_path = potential_path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (svg_path && fs.existsSync(svg_path)) {
|
||||
this.xml_map = fs.readFileSync(svg_path, 'utf-8');
|
||||
this._load_metadata();
|
||||
} else {
|
||||
console.error(`SVG map file not found at path: ${svg_path} or default paths.`);
|
||||
this.xml_map = null; // Ensure xml_map is null if not loaded
|
||||
}
|
||||
}
|
||||
|
||||
private _load_metadata(): void {
|
||||
if (!this.xml_map) {
|
||||
console.warn("Cannot load metadata: xml_map is null.");
|
||||
return;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
// Use "text/xml" for parsing if "image/svg+xml" causes issues with custom namespaces
|
||||
const doc = parser.parseFromString(this.xml_map, "text/xml");
|
||||
const serializer = new XMLSerializer();
|
||||
|
||||
// Define the jdipNS namespace URI if it's defined in the SVG.
|
||||
// For now, assume no explicit URI and use wildcard or local name matching.
|
||||
const jdipNS = null; // Or the actual namespace URI string
|
||||
|
||||
// Power Colors
|
||||
const orderDrawingElements = doc.getElementsByTagNameNS(jdipNS || "*", 'ORDERDRAWING');
|
||||
if (orderDrawingElements.length > 0) {
|
||||
const powerColorsElements = orderDrawingElements[0].getElementsByTagNameNS(jdipNS || "*", 'POWERCOLORS');
|
||||
if (powerColorsElements.length > 0) {
|
||||
const powerColorNodes = powerColorsElements[0].getElementsByTagNameNS(jdipNS || "*", 'POWERCOLOR');
|
||||
for (let i = 0; i < powerColorNodes.length; i++) {
|
||||
const node = powerColorNodes[i] as Element;
|
||||
const power = getAttribute(node, 'power');
|
||||
const color = getAttribute(node, 'color');
|
||||
if (power && color) {
|
||||
this.metadata.color[power.toUpperCase()] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Symbol Size
|
||||
const symbolSizeNodes = orderDrawingElements[0].getElementsByTagNameNS(jdipNS || "*", 'SYMBOLSIZE');
|
||||
for (let i = 0; i < symbolSizeNodes.length; i++) {
|
||||
const node = symbolSizeNodes[i] as Element;
|
||||
const name = getAttribute(node, 'name');
|
||||
const height = getAttribute(node, 'height');
|
||||
const width = getAttribute(node, 'width');
|
||||
if (name && height && width) {
|
||||
this.metadata.symbol_size[name] = [height, width];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Province Coordinates
|
||||
const provinceDataElements = doc.getElementsByTagNameNS(jdipNS || "*", 'PROVINCE_DATA');
|
||||
if (provinceDataElements.length > 0) {
|
||||
const provinceNodes = provinceDataElements[0].getElementsByTagNameNS(jdipNS || "*", 'PROVINCE');
|
||||
for (let i = 0; i < provinceNodes.length; i++) {
|
||||
const node = provinceNodes[i] as Element;
|
||||
const provinceNameAttr = getAttribute(node, 'name');
|
||||
if (provinceNameAttr) {
|
||||
const provinceKey = provinceNameAttr.toUpperCase().replace('-', '/');
|
||||
this.metadata.coord[provinceKey] = this.metadata.coord[provinceKey] || {};
|
||||
|
||||
const unitNodes = node.getElementsByTagNameNS(jdipNS || "*",'UNIT');
|
||||
if (unitNodes.length > 0) {
|
||||
const unitNode = unitNodes[0] as Element;
|
||||
const x = getAttribute(unitNode, 'x');
|
||||
const y = getAttribute(unitNode, 'y');
|
||||
if (x && y) this.metadata.coord[provinceKey]!.unit = [x, y];
|
||||
}
|
||||
|
||||
const dislNodes = node.getElementsByTagNameNS(jdipNS || "*",'DISLODGED_UNIT');
|
||||
if (dislNodes.length > 0) {
|
||||
const dislNode = dislNodes[0] as Element;
|
||||
const x = getAttribute(dislNode, 'x');
|
||||
const y = getAttribute(dislNode, 'y');
|
||||
if (x && y) this.metadata.coord[provinceKey]!.disl = [x, y];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove jdipNS nodes after parsing metadata
|
||||
const svgNode = doc.getElementsByTagName('svg')[0];
|
||||
if (svgNode) {
|
||||
const nodesToRemove = [];
|
||||
nodesToRemove.push(...Array.from(doc.getElementsByTagNameNS(jdipNS || "*", 'DISPLAY')));
|
||||
nodesToRemove.push(...Array.from(doc.getElementsByTagNameNS(jdipNS || "*", 'ORDERDRAWING')));
|
||||
nodesToRemove.push(...Array.from(doc.getElementsByTagNameNS(jdipNS || "*", 'PROVINCE_DATA')));
|
||||
|
||||
for (const nodeToRemove of nodesToRemove) {
|
||||
if (nodeToRemove.parentNode) {
|
||||
nodeToRemove.parentNode.removeChild(nodeToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.xml_map = serializer.serializeToString(doc);
|
||||
}
|
||||
|
||||
public render(incl_orders: boolean = true, incl_abbrev: boolean = false, output_format: string = 'svg', output_path?: string): string | null {
|
||||
if (output_format !== 'svg') {
|
||||
throw new Error('Only "svg" format is currently supported.');
|
||||
}
|
||||
if (!this.game || !this.game.map || !this.xml_map) {
|
||||
console.warn("Cannot render: game, map, or xml_map not available.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
let doc = parser.parseFromString(this.xml_map, "image/svg+xml");
|
||||
const serializer = new XMLSerializer();
|
||||
|
||||
// Setting phase and note
|
||||
return null;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(this.xml_map, "text/xml"); // Use text/xml for potentially non-standard SVG
|
||||
const serializer = new XMLSerializer();
|
||||
const svgEl = doc.documentElement;
|
||||
|
||||
// Clear dynamic layers (OrderLayer, UnitLayer, DislodgedUnitLayer)
|
||||
const layerIdsToClear = [LAYER_ORDER, LAYER_UNIT, LAYER_DISL];
|
||||
layerIdsToClear.forEach(layerId => {
|
||||
const layer = this.findElementById(svgEl, layerId);
|
||||
if (layer) {
|
||||
while (layer.firstChild) {
|
||||
layer.removeChild(layer.firstChild);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Setting phase and note
|
||||
const nb_centers_per_power = Object.values(this.game.powers)
|
||||
.filter(p => !p.is_eliminated())
|
||||
.map(p => ({ name: p.name.substring(0, 3).toUpperCase(), centers: p.centers.length }))
|
||||
.sort((a, b) => b.centers - a.centers)
|
||||
.map(item => `${item.name}: ${item.centers}`)
|
||||
.join(' ');
|
||||
|
||||
this._set_current_phase(doc, this.game.get_current_phase());
|
||||
this._set_note(doc, nb_centers_per_power, this.game.note);
|
||||
|
||||
// Adding units and influence
|
||||
for (const power of Object.values(this.game.powers)) {
|
||||
power.units.forEach(unit => this._add_unit(doc, unit.toString(), power.name, false)); // unit.toString() if Unit class
|
||||
power.retreats.forEach(unit => this._add_unit(doc, unit.toString(), power.name, true)); // unit.toString() if Unit class
|
||||
|
||||
power.centers.forEach(center_loc => { // center_loc is a string
|
||||
this._set_influence(doc, center_loc, power.name, true);
|
||||
});
|
||||
// this.game.get_influence() or similar needed for general influence
|
||||
// For now, only SC influence is directly available via power.centers
|
||||
}
|
||||
|
||||
// TODO: Orders rendering if incl_orders is true
|
||||
|
||||
// Handle province abbreviation layer and mouse layer
|
||||
const briefLabelLayer = this.findElementById(svgEl, 'BriefLabelLayer');
|
||||
if (briefLabelLayer && !incl_abbrev) {
|
||||
briefLabelLayer.parentNode?.removeChild(briefLabelLayer);
|
||||
}
|
||||
const mouseLayer = this.findElementById(svgEl, 'MouseLayer');
|
||||
if (mouseLayer) {
|
||||
mouseLayer.parentNode?.removeChild(mouseLayer);
|
||||
}
|
||||
|
||||
const rendered_image = serializer.serializeToString(doc);
|
||||
|
||||
if (output_path) {
|
||||
fs.writeFileSync(output_path, rendered_image, 'utf-8');
|
||||
}
|
||||
return rendered_image;
|
||||
}
|
||||
|
||||
private findElementById(docOrEl: Document | Element, id: string): Element | null {
|
||||
if ('getElementById' in docOrEl && typeof docOrEl.getElementById === 'function') {
|
||||
return docOrEl.getElementById(id);
|
||||
}
|
||||
// Fallback for Elements or if getElementById is not available/reliable on the specific DOM implementation
|
||||
const results = docOrEl.getElementsByTagNameNS('*', '*'); // Get all elements
|
||||
for(let i=0; i < results.length; i++) {
|
||||
const el = results[i] as Element;
|
||||
if(getAttribute(el, 'id') === id) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// --- Stubs for other private methods ---
|
||||
private _norm_order(order: string): string[] {
|
||||
// This should ideally use game logic for full normalization (like Python's game._add_unit_types)
|
||||
// For now, using map's compact, which is close.
|
||||
return this.game.map.compact(order);
|
||||
}
|
||||
|
||||
private _add_unit(doc: Document, unit_str: string, power_name: string, is_dislodged: boolean): void {
|
||||
const [unit_type_char, loc] = unit_str.split(/\s+/);
|
||||
if (!unit_type_char || !loc) {
|
||||
logger.warn(`_add_unit: Could not parse unit string: ${unit_str}`);
|
||||
return;
|
||||
}
|
||||
const unit_type = unit_type_char === 'A' ? ARMY : FLEET; // Assuming 'A' or 'F'
|
||||
|
||||
const coords = is_dislodged ? this.metadata.coord[loc]?.disl : this.metadata.coord[loc]?.unit;
|
||||
if (!coords) {
|
||||
logger.warn(`_add_unit: No coordinates found for ${loc} (dislodged: ${is_dislodged})`);
|
||||
return;
|
||||
}
|
||||
const [loc_x, loc_y] = coords;
|
||||
const symbol_size = this.metadata.symbol_size[unit_type];
|
||||
if (!symbol_size) {
|
||||
logger.warn(`_add_unit: No symbol size found for ${unit_type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const node = doc.createElement('use');
|
||||
node.setAttribute('id', `${is_dislodged ? 'dislodged_' : ''}unit_${loc}`);
|
||||
node.setAttribute('x', loc_x);
|
||||
node.setAttribute('y', loc_y);
|
||||
node.setAttribute('height', symbol_size[0]);
|
||||
node.setAttribute('width', symbol_size[1]);
|
||||
node.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `#${is_dislodged ? 'Dislodged' : ''}${unit_type}`);
|
||||
node.setAttribute('class', `unit${power_name.toLowerCase()}`);
|
||||
|
||||
const layerId = is_dislodged ? LAYER_DISL : LAYER_UNIT;
|
||||
const layer = this.findElementById(doc.documentElement, layerId);
|
||||
if (layer) {
|
||||
layer.appendChild(node);
|
||||
} else {
|
||||
logger.warn(`_add_unit: Layer ${layerId} not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
private _set_influence(doc: Document, loc: string, power_name: string, has_supply_center: boolean = false): void {
|
||||
const base_loc = loc.substring(0, 3).toUpperCase(); // Ensure base loc for SC check
|
||||
|
||||
if (this.game.map.scs.includes(base_loc) && !has_supply_center) {
|
||||
// This logic from Python: if it's an SC but we are not specifically setting SC influence, skip.
|
||||
// This is to prevent general influence from overriding specific SC ownership display if they differ.
|
||||
return;
|
||||
}
|
||||
if (this.game.map.get_province_type(base_loc) === 'WATER') { // Use get_province_type for safety
|
||||
return;
|
||||
}
|
||||
|
||||
const className = power_name ? power_name.toLowerCase() : 'nopower';
|
||||
const mapLayer = this.findElementById(doc.documentElement, 'MapLayer');
|
||||
if (mapLayer) {
|
||||
// Province IDs in SVGs are often like `_par`, `_spa_nc`
|
||||
// We need to find the element that represents the land/province area.
|
||||
// This could be the element with id `_<loc_lowercase>` or a child of it.
|
||||
const provinceElementId = `_${base_loc.toLowerCase()}`; // Try base first
|
||||
let provinceElement = this.findElementById(mapLayer, provinceElementId);
|
||||
|
||||
if (!provinceElement && loc.includes('/')) { // Try specific coast like _spa_sc
|
||||
provinceElement = this.findElementById(mapLayer, `_${loc.toLowerCase().replace('/', '_')}`);
|
||||
}
|
||||
|
||||
if (provinceElement) {
|
||||
if (['path', 'polygon', 'rect', 'circle'].includes(provinceElement.nodeName.toLowerCase())) {
|
||||
provinceElement.setAttribute('class', className);
|
||||
} else if (provinceElement.nodeName.toLowerCase() === 'g') { // Group of paths
|
||||
let edited = false;
|
||||
for(let i=0; i < provinceElement.childNodes.length; i++) {
|
||||
const subNode = provinceElement.childNodes[i] as Element;
|
||||
if(subNode.nodeType === 1 && ['path', 'polygon', 'rect', 'circle'].includes(subNode.nodeName.toLowerCase())) {
|
||||
if (getAttribute(subNode, 'class') !== 'water') { // Don't change class of water elements within a group
|
||||
subNode.setAttribute('class', className);
|
||||
edited = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (!edited) provinceElement.setAttribute('class', className); // Fallback for group itself if no suitable children
|
||||
}
|
||||
} else {
|
||||
logger.warn(`_set_influence: Province element for ${loc} (tried ${provinceElementId}) not found.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _set_current_phase(doc: Document, current_phase_str: string): void {
|
||||
const phaseTextElement = this.findElementById(doc.documentElement, 'CurrentPhase');
|
||||
if (phaseTextElement) {
|
||||
const displayPhase = (current_phase_str[0] === '?' || current_phase_str === 'COMPLETED') ? 'FINAL' : current_phase_str;
|
||||
phaseTextElement.textContent = displayPhase;
|
||||
} else {
|
||||
logger.warn("_set_current_phase: Element with ID 'CurrentPhase' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
private _set_note(doc: Document, note_1: string, note_2: string | null): void {
|
||||
const note1Element = this.findElementById(doc.documentElement, 'CurrentNote');
|
||||
if (note1Element) {
|
||||
note1Element.textContent = note_1 || ' ';
|
||||
}
|
||||
const note2Element = this.findElementById(doc.documentElement, 'CurrentNote2');
|
||||
if (note2Element) {
|
||||
note2Element.textContent = note_2 || ' ';
|
||||
}
|
||||
}
|
||||
|
||||
// Stubs for _issue_*_order methods
|
||||
private _issue_hold_order(doc: Document, loc: string, power_name: string): void { console.warn("_issue_hold_order is a stub."); }
|
||||
private _issue_support_hold_order(doc: Document, loc: string, dest_loc: string, power_name: string): void { console.warn("_issue_support_hold_order is a stub."); }
|
||||
private _issue_move_order(doc: Document, src_loc: string, dest_loc: string, power_name: string): void { console.warn("_issue_move_order is a stub."); }
|
||||
private _issue_support_move_order(doc: Document, loc: string, src_loc: string, dest_loc: string, power_name: string): void { console.warn("_issue_support_move_order is a stub."); }
|
||||
private _issue_convoy_order(doc: Document, loc: string, src_loc: string, dest_loc: string, power_name: string): void { console.warn("_issue_convoy_order is a stub."); }
|
||||
private _issue_build_order(doc: Document, unit_type: string, loc: string, power_name: string): void { console.warn("_issue_build_order is a stub."); }
|
||||
private _issue_disband_order(doc: Document, loc: string): void { console.warn("_issue_disband_order is a stub."); }
|
||||
|
||||
// Coordinate and stroke width helpers
|
||||
private _center_symbol_around_unit(loc: string, is_dislodged: boolean, symbol_name: string): [string, string] {
|
||||
const key = is_dislodged ? 'disl' : 'unit';
|
||||
const unit_coords = this.metadata.coord[loc]?.[key];
|
||||
const unit_size_info = this.metadata.symbol_size[ARMY]; // Base size on Army symbol
|
||||
const symbol_size_info = this.metadata.symbol_size[symbol_name];
|
||||
|
||||
if (!unit_coords || !unit_size_info || !symbol_size_info) {
|
||||
logger.warn(`_center_symbol_around_unit: Missing metadata for ${loc}, dislodged: ${is_dislodged}, symbol: ${symbol_name}`);
|
||||
return ["0", "0"];
|
||||
}
|
||||
const unit_x = parseFloat(unit_coords[0]);
|
||||
const unit_y = parseFloat(unit_coords[1]);
|
||||
const unit_width = parseFloat(unit_size_info[1]);
|
||||
const unit_height = parseFloat(unit_size_info[0]);
|
||||
const symbol_width = parseFloat(symbol_size_info[1]);
|
||||
const symbol_height = parseFloat(symbol_size_info[0]);
|
||||
|
||||
return [
|
||||
(unit_x + unit_width / 2 - symbol_width / 2).toString(),
|
||||
(unit_y + unit_height / 2 - symbol_height / 2).toString()
|
||||
];
|
||||
}
|
||||
private _get_unit_center(loc: string, is_dislodged: boolean): [number, number] {
|
||||
const key = is_dislodged ? 'disl' : 'unit';
|
||||
const unit_coords = this.metadata.coord[loc]?.[key];
|
||||
// Assuming ARMY symbol size is representative for unit centering, adjust if FLEET has different center logic.
|
||||
const unit_size_info = this.metadata.symbol_size[ARMY];
|
||||
if (!unit_coords || !unit_size_info) {
|
||||
logger.warn(`_get_unit_center: Missing metadata for ${loc}, dislodged: ${is_dislodged}`);
|
||||
return [0,0];
|
||||
}
|
||||
return [
|
||||
parseFloat(unit_coords[0]) + parseFloat(unit_size_info[1]) / 2,
|
||||
parseFloat(unit_coords[1]) + parseFloat(unit_size_info[0]) / 2
|
||||
];
|
||||
}
|
||||
private _plain_stroke_width(): number {
|
||||
return parseFloat(this.metadata.symbol_size?.Stroke?.[0] || "1");
|
||||
}
|
||||
private _colored_stroke_width(): number {
|
||||
return parseFloat(this.metadata.symbol_size?.Stroke?.[1] || "1");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Placeholder for EquilateralTriangle if needed by order drawing.
|
||||
// class EquilateralTriangle { constructor(...args: any[]) {} intersection(x: number, y: number): [number, number] { return [x,y];} }
|
||||
65
diplomacy/integration/base_api.ts
Normal file
65
diplomacy/integration/base_api.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// diplomacy/integration/base_api.ts
|
||||
|
||||
import { DiplomacyGame } from '../engine/game'; // Adjust path as necessary
|
||||
// Consider defining more specific interfaces if DiplomacyGame is too broad or for return types.
|
||||
|
||||
/**
|
||||
* Interface for the data returned by get_game_and_power.
|
||||
*/
|
||||
export interface GameAndPower {
|
||||
game: DiplomacyGame | null;
|
||||
powerName: string | null;
|
||||
// Potentially add other relevant details like game ID, phase, deadlines, etc.
|
||||
// gameId?: string | number;
|
||||
// currentPhase?: string;
|
||||
// deadline?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for Diplomacy game APIs.
|
||||
* Defines a common interface for interacting with different Diplomacy platforms.
|
||||
*/
|
||||
export abstract class BaseDiplomacyAPI {
|
||||
protected apiKey: string;
|
||||
protected connectTimeout: number;
|
||||
protected requestTimeout: number;
|
||||
|
||||
/**
|
||||
* Constructor for BaseDiplomacyAPI.
|
||||
* @param apiKey - The API key to use for sending API requests.
|
||||
* @param connectTimeout - Max time (ms) to wait for connection. Defaults to 30000ms.
|
||||
* @param requestTimeout - Max time (ms) to wait for request processing. Defaults to 60000ms.
|
||||
*/
|
||||
constructor(apiKey: string, connectTimeout: number = 30000, requestTimeout: number = 60000) {
|
||||
this.apiKey = apiKey;
|
||||
this.connectTimeout = connectTimeout;
|
||||
this.requestTimeout = requestTimeout;
|
||||
// Specific HTTP client initialization will be handled by concrete implementations.
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the game state and the power the client is playing.
|
||||
* Arguments are specific to each implementation.
|
||||
* @param args - Implementation-specific arguments.
|
||||
* @returns A Promise resolving to a GameAndPower object or null if an error occurred.
|
||||
*/
|
||||
abstract get_game_and_power(...args: any[]): Promise<GameAndPower | null>;
|
||||
|
||||
/**
|
||||
* Submits orders to the server.
|
||||
* @param game - A DiplomacyGame object representing the current state of the game.
|
||||
* @param powerName - The name of the power submitting the orders (e.g., 'FRANCE').
|
||||
* @param orders - An array of strings representing the orders (e.g., ['A PAR H', 'F BRE - MAO']).
|
||||
* @param wait - Optional. If true, indicates the player is not ready (sets ready=false).
|
||||
* If false, indicates player is ready (sets ready=true).
|
||||
* If undefined, the platform's default behavior for submitting orders is used.
|
||||
* @returns A Promise resolving to true for success, false for failure.
|
||||
*/
|
||||
abstract set_orders(game: DiplomacyGame, powerName: string, orders: string[], wait?: boolean): Promise<boolean>;
|
||||
|
||||
// Other potential abstract methods for a comprehensive API client:
|
||||
// abstract get_game_history(gameId: string | number): Promise<GameHistory | null>;
|
||||
// abstract send_message(gameId: string | number, recipient: string, message: string): Promise<boolean>;
|
||||
// abstract get_deadlines(gameId: string | number): Promise<Date[] | null>;
|
||||
// abstract get_games_list(): Promise<Array<{gameId: string | number, name: string, phase: string}> | null>;
|
||||
}
|
||||
4
diplomacy/integration/index.ts
Normal file
4
diplomacy/integration/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// diplomacy/integration/index.ts
|
||||
// This file will be used to export symbols from the integration modules.
|
||||
// For now, it's empty as the corresponding __init__.py was empty.
|
||||
export {}; // Ensures this is treated as a module.
|
||||
255
diplomacy/integration/webdiplomacy_net/api.ts
Normal file
255
diplomacy/integration/webdiplomacy_net/api.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
// diplomacy/integration/webdiplomacy_net/api.ts
|
||||
|
||||
import fetch, { Headers, RequestInit, Response } from 'node-fetch';
|
||||
import { URLSearchParams } from 'url';
|
||||
import { Cookie, CookieJar } from 'tough-cookie';
|
||||
import { DiplomacyGame } from '../../../engine/game'; // Adjust path as necessary
|
||||
import { BaseDiplomacyAPI, GameAndPower } from '../base_api';
|
||||
import { state_dict_to_game_and_power } from './game'; // To be created
|
||||
import { WebDiplomacyOrder } from './orders'; // To be created (renamed from Order to avoid conflict)
|
||||
import { CACHE, GameIdCountryId, getMapData } from './utils';
|
||||
|
||||
const API_USER_AGENT = 'DiplomacyTS Client v0.1'; // Update as appropriate
|
||||
const API_WEBDIPLOMACY_NET = process.env.API_WEBDIPLOMACY || 'https://webdiplomacy.net/api.php';
|
||||
|
||||
// Custom Error for API specific issues
|
||||
export class WebDiplomacyAPIError extends Error {
|
||||
constructor(message: string, public response?: Response, public data?: any) {
|
||||
super(message);
|
||||
this.name = 'WebDiplomacyAPIError';
|
||||
}
|
||||
}
|
||||
|
||||
export class API extends BaseDiplomacyAPI {
|
||||
private cookieJar: CookieJar;
|
||||
|
||||
constructor(apiKey: string, connectTimeout: number = 30000, requestTimeout: number = 60000) {
|
||||
super(apiKey, connectTimeout, requestTimeout);
|
||||
this.cookieJar = new CookieJar();
|
||||
// Note: connectTimeout and requestTimeout are not directly supported by node-fetch in the same way as Tornado.
|
||||
// fetch uses AbortController for timeouts. This can be added to _sendRequest if needed.
|
||||
}
|
||||
|
||||
private async _sendRequest(url: string, method: 'GET' | 'POST', body?: any): Promise<Response> {
|
||||
const headers = new Headers({
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'User-Agent': API_USER_AGENT,
|
||||
});
|
||||
|
||||
if (method === 'POST' && body) {
|
||||
headers.set('Content-Type', 'application/json'); // Assuming JSON body for POST
|
||||
}
|
||||
|
||||
// Add cookies to request
|
||||
const cookieString = await this.cookieJar.getCookieString(url);
|
||||
if (cookieString) {
|
||||
headers.set('Cookie', cookieString);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (method === 'POST' && body) {
|
||||
options.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
response = await fetch(url, options);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
// Differentiate network errors from AbortError for timeouts
|
||||
if (error.name === 'AbortError') {
|
||||
throw new WebDiplomacyAPIError(`Request timed out after ${this.requestTimeout}ms: ${method} ${url}`, undefined, error);
|
||||
}
|
||||
throw new WebDiplomacyAPIError(`Network error during request: ${method} ${url}: ${error.message}`, undefined, error);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Store cookies from response
|
||||
const setCookieHeader = response.headers.raw()['set-cookie'];
|
||||
if (setCookieHeader) {
|
||||
await Promise.all(setCookieHeader.map(cookie => this.cookieJar.setCookie(Cookie.parse(cookie)!, response.url)));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async _sendGetRequest(url: string): Promise<Response> {
|
||||
return this._sendRequest(url, 'GET');
|
||||
}
|
||||
|
||||
private async _sendPostRequest(url: string, body: any): Promise<Response> {
|
||||
return this._sendRequest(url, 'POST', body);
|
||||
}
|
||||
|
||||
async list_games_with_players_in_cd(): Promise<GameIdCountryId[]> {
|
||||
const route = 'players/cd';
|
||||
const params = new URLSearchParams({ route });
|
||||
const url = `${API_WEBDIPLOMACY_NET}?${params.toString()}`;
|
||||
const return_val: GameIdCountryId[] = [];
|
||||
|
||||
try {
|
||||
const response = await this._sendGetRequest(url);
|
||||
if (response.ok) {
|
||||
const list_games_players = await response.json() as Array<{gameID: number, countryID: number}>;
|
||||
list_games_players.forEach(game_player => {
|
||||
return_val.push({ game_id: game_player.gameID, country_id: game_player.countryID });
|
||||
});
|
||||
} else {
|
||||
const errorBody = await response.text();
|
||||
console.warn(`API._list_games_with_players_in_cd: Error during "${route}". Status: ${response.status}. Body: ${errorBody}`);
|
||||
throw new WebDiplomacyAPIError(`Failed to list CD games`, response, errorBody);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`API._list_games_with_players_in_cd: Unable to connect or parse. Error: ${error.message}`);
|
||||
if (!(error instanceof WebDiplomacyAPIError)) {
|
||||
throw new WebDiplomacyAPIError(`Failed to list CD games: ${error.message}`, undefined, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return return_val;
|
||||
}
|
||||
|
||||
async list_games_with_missing_orders(): Promise<GameIdCountryId[]> {
|
||||
const route = 'players/missing_orders';
|
||||
const params = new URLSearchParams({ route });
|
||||
const url = `${API_WEBDIPLOMACY_NET}?${params.toString()}`;
|
||||
const return_val: GameIdCountryId[] = [];
|
||||
|
||||
try {
|
||||
const response = await this._sendGetRequest(url);
|
||||
if (response.ok) {
|
||||
const list_games_players = await response.json() as Array<{gameID: number, countryID: number}>;
|
||||
list_games_players.forEach(game_player => {
|
||||
return_val.push({ game_id: game_player.gameID, country_id: game_player.countryID });
|
||||
});
|
||||
} else {
|
||||
const errorBody = await response.text();
|
||||
console.warn(`API.list_games_with_missing_orders: Error during "${route}". Status: ${response.status}. Body: ${errorBody}`);
|
||||
throw new WebDiplomacyAPIError(`Failed to list missing orders games`, response, errorBody);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`API.list_games_with_missing_orders: Unable to connect or parse. Error: ${error.message}`);
|
||||
if (!(error instanceof WebDiplomacyAPIError)) {
|
||||
throw new WebDiplomacyAPIError(`Failed to list missing orders games: ${error.message}`, undefined, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return return_val;
|
||||
}
|
||||
|
||||
async get_game_and_power(game_id: number, country_id: number, max_phases?: number): Promise<GameAndPower | null> {
|
||||
const route = 'game/status';
|
||||
const params = new URLSearchParams({
|
||||
route,
|
||||
gameID: game_id.toString(),
|
||||
countryID: country_id.toString()
|
||||
});
|
||||
const url = `${API_WEBDIPLOMACY_NET}?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this._sendGetRequest(url);
|
||||
if (response.ok) {
|
||||
const state_dict = await response.json();
|
||||
// state_dict_to_game_and_power will be implemented in game.ts
|
||||
// It needs to create a DiplomacyGame instance.
|
||||
return state_dict_to_game_and_power(state_dict, country_id, max_phases);
|
||||
} else {
|
||||
const errorBody = await response.text();
|
||||
console.warn(`API.get_game_and_power: Error during "${route}". Status: ${response.status}. Body: ${errorBody}`);
|
||||
throw new WebDiplomacyAPIError(`Failed to get game and power for gameID ${game_id}`, response, errorBody);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`API.get_game_and_power: Unable to connect or parse for gameID ${game_id}. Error: ${error.message}`);
|
||||
if (!(error instanceof WebDiplomacyAPIError)) {
|
||||
throw new WebDiplomacyAPIError(`Failed to get game and power: ${error.message}`, undefined, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return null; // Should be unreachable if errors throw
|
||||
}
|
||||
|
||||
async set_orders(game: DiplomacyGame, power_name: string, orders: string[], wait?: boolean): Promise<boolean> {
|
||||
console.info(`[${game.game_id}/${game.get_current_phase()}/${power_name}] - Submitting orders: ${orders.join(', ')}`);
|
||||
|
||||
const mapData = getMapData(game.map_name || 'standard');
|
||||
if (!mapData || !mapData.power_to_ix) {
|
||||
console.error(`set_orders: Map data or power_to_ix not found for map ${game.map_name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const countryID = mapData.power_to_ix[power_name.toUpperCase()];
|
||||
if (countryID === undefined) {
|
||||
console.error(`set_orders: Country ID not found for power ${power_name} in map ${game.map_name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert orders using WebDiplomacyOrder class (to be created in orders.ts)
|
||||
const orders_dict = orders.map(orderStr =>
|
||||
new WebDiplomacyOrder(orderStr, game.map_name, game.phase_type, game).to_dict()
|
||||
);
|
||||
|
||||
const current_phase = game.get_current_phase();
|
||||
let turn = -1;
|
||||
let phase_for_api = 'Diplomacy';
|
||||
|
||||
if (current_phase !== 'COMPLETED' && current_phase.length >=6 /* e.g. S1901M */) {
|
||||
const season = current_phase[0];
|
||||
const current_year = parseInt(current_phase.substring(1, 5), 10);
|
||||
const phase_type_char = current_phase[5];
|
||||
const nb_years = current_year - (game.map.first_year || 1901) ; // game.map should be populated
|
||||
turn = 2 * nb_years + (season.toUpperCase() === 'S' ? 0 : 1);
|
||||
phase_for_api = {'M': 'Diplomacy', 'R': 'Retreats', 'A': 'Builds'}[phase_type_char.toUpperCase()] || 'Diplomacy';
|
||||
}
|
||||
|
||||
const route = 'game/orders';
|
||||
const url = `${API_WEBDIPLOMACY_NET}?route=${route}`; // No need for URLSearchParams for route here
|
||||
const body: Record<string, any> = {
|
||||
gameID: parseInt(game.game_id, 10), // Ensure game_id is number
|
||||
turn,
|
||||
phase: phase_for_api,
|
||||
countryID,
|
||||
orders: orders_dict.filter(o => o), // Filter out any null/undefined from to_dict if order was invalid
|
||||
};
|
||||
|
||||
if (wait !== undefined) {
|
||||
body['ready'] = !wait ? 'Yes' : 'No'; // wait=true means ready=No
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this._sendPostRequest(url, body); // Body will be stringified by _sendRequest
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
console.warn(`API.set_orders: Error during "${route}". Status: ${response.status}. Body: ${errorBody}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!orders || orders.length === 0) { // If only setting ready flag
|
||||
return true;
|
||||
}
|
||||
|
||||
const response_body_text = await response.text();
|
||||
if (!response_body_text) {
|
||||
console.warn(`API.set_orders: Warning during "${route}". No response body received.`);
|
||||
return false; // Or true depending on desired strictness - Python returns False here
|
||||
}
|
||||
|
||||
const received_orders_payload = JSON.parse(response_body_text);
|
||||
// TODO: Validate received orders against submitted orders as in Python version
|
||||
// This requires parsing received_orders_payload which is an array of order strings.
|
||||
// For now, assume success if response was ok and body was received.
|
||||
console.log(`API.set_orders: Orders submitted, received payload:`, received_orders_payload);
|
||||
return true;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`API.set_orders: Unable to connect or process for gameID ${game.game_id}. Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
345
diplomacy/integration/webdiplomacy_net/game.ts
Normal file
345
diplomacy/integration/webdiplomacy_net/game.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
// diplomacy/integration/webdiplomacy_net/game.ts
|
||||
|
||||
import { DiplomacyGame } from '../../../engine/game';
|
||||
import { DiplomacyMap } from '../../../engine/map'; // For map.first_year
|
||||
import { WebDiplomacyOrder } from './orders'; // To be created
|
||||
import { CACHE, getMapData } from './utils';
|
||||
import { GameAndPower } from '../base_api'; // Assuming this is the correct path
|
||||
|
||||
// --- Interfaces for webdiplomacy.net API data structures ---
|
||||
|
||||
export interface WebDiplomacyUnitDict {
|
||||
unitType: 'Army' | 'Fleet';
|
||||
terrID: number;
|
||||
countryID: number;
|
||||
retreating: 'Yes' | 'No';
|
||||
dislodged?: 'Yes' | 'No'; // Sometimes 'dislodged' is used instead of 'retreating' in order history
|
||||
}
|
||||
|
||||
export interface WebDiplomacyCenterDict {
|
||||
terrID: number;
|
||||
countryID: number; // 0 for unowned
|
||||
}
|
||||
|
||||
export interface WebDiplomacyOrderDict {
|
||||
countryID: number;
|
||||
unitType?: 'Army' | 'Fleet'; // Optional in some contexts like builds/disbands
|
||||
terrID?: number; // Optional for WAIVE, DESTROY
|
||||
type: string; // e.g., 'Hold', 'Move', 'Support hold', 'Build Army', etc.
|
||||
toTerrID?: number;
|
||||
fromTerrID?: number; // For support move source
|
||||
viaConvoy?: 'Yes' | 'No';
|
||||
success?: 'Yes' | 'No'; // For order results
|
||||
dislodged?: 'Yes' | 'No'; // For order results
|
||||
}
|
||||
|
||||
export interface WebDiplomacyPhaseOrdersDict { // Structure within 'phases' array for 'orders'
|
||||
countryID: number;
|
||||
order: string; // Raw order string as seen on webdiplomacy.net e.g. "A PAR H"
|
||||
details?: WebDiplomacyOrderDict; // This is usually how detailed orders are provided
|
||||
// but sometimes it's just a string, so parsing might be needed.
|
||||
}
|
||||
|
||||
|
||||
export interface WebDiplomacyPhaseDict {
|
||||
turn: number;
|
||||
phase: 'Diplomacy' | 'Retreats' | 'Builds' | 'Pre-Game' | 'Finished'; // From API doc
|
||||
units: WebDiplomacyUnitDict[];
|
||||
centers: WebDiplomacyCenterDict[];
|
||||
orders?: Record<string, string> | WebDiplomacyPhaseOrdersDict[]; // Orders can be a dictionary or array
|
||||
// dict: { "FRANCE": ["A PAR H", ...], ... }
|
||||
// array: [ { countryID: X, order: "A PAR H"}, ... ]
|
||||
// The provided Python code expects `orders` to be a list of order_dict.
|
||||
results?: any; // Captures order results if available
|
||||
}
|
||||
|
||||
export interface WebDiplomacyStateDict {
|
||||
gameID: number;
|
||||
variantID: number; // map_id
|
||||
turn: number;
|
||||
phase: 'Pre-Game' | 'Diplomacy' | 'Retreats' | 'Builds' | 'Finished';
|
||||
gameOver: 'No' | 'Won' | 'Drawn' | string; // string for specific draw types
|
||||
phases: WebDiplomacyPhaseDict[];
|
||||
standoffs?: WebDiplomacyCenterDict[]; // Array of {terrID, countryID: 0} typically
|
||||
occupiedFrom?: Record<string, number>; // terrID (string) -> terrID (number)
|
||||
// e.g. { "6": 22 } means Paris (6) was attacked from Burgundy (22)
|
||||
chat?: any[]; // For chat messages, if ever needed
|
||||
members?: any[]; // For member details, if ever needed
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
export function turn_to_phase(turn: number, phase: 'Diplomacy' | 'Retreats' | 'Builds', mapFirstYear: number = 1901): string {
|
||||
const year = mapFirstYear + Math.floor(turn / 2);
|
||||
const season = turn % 2 === 0 ? 'S' : 'F';
|
||||
let phaseTypeChar: string;
|
||||
|
||||
if (phase === 'Builds') {
|
||||
// Builds phase is typically Winter, but webdip API uses 'F' for turn calc and 'Builds' for phase string
|
||||
return `W${year}A`;
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case 'Diplomacy': phaseTypeChar = 'M'; break;
|
||||
case 'Retreats': phaseTypeChar = 'R'; break;
|
||||
// case 'Builds': phaseTypeChar = 'A'; break; // Handled above
|
||||
default: throw new Error(`Unknown phase string: ${phase}`);
|
||||
}
|
||||
return `${season}${year}${phaseTypeChar}`;
|
||||
}
|
||||
|
||||
export function unit_dict_to_strings(unit_dict: WebDiplomacyUnitDict, map_id: number): [string | null, string | null] {
|
||||
const mapData = getMapData(map_id);
|
||||
if (!mapData || !mapData.ix_to_loc || !mapData.ix_to_power) {
|
||||
console.error(`unit_dict_to_strings: Map data not found for mapID ${map_id}`);
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const { unitType, terrID, countryID, retreating, dislodged } = unit_dict;
|
||||
|
||||
if (unitType !== 'Army' && unitType !== 'Fleet') {
|
||||
console.error(`Unknown unitType "${unitType}". Expected "Army" or "Fleet".`);
|
||||
return [null, null];
|
||||
}
|
||||
if (!mapData.ix_to_loc[terrID]) {
|
||||
console.error(`Unknown terrID "${terrID}" for mapID "${map_id}".`);
|
||||
return [null, null];
|
||||
}
|
||||
if (!mapData.ix_to_power[countryID]) {
|
||||
console.error(`Unknown countryID "${countryID}" for mapID "${map_id}".`);
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const isRetreatingOrDislodged = retreating === 'Yes' || dislodged === 'Yes';
|
||||
const locName = mapData.ix_to_loc[terrID]!;
|
||||
const powerName = mapData.ix_to_power[countryID]!;
|
||||
const unitChar = unitType[0]; // 'A' or 'F'
|
||||
|
||||
const unitString = `${isRetreatingOrDislodged ? '*' : ''}${unitChar} ${locName}`;
|
||||
return [powerName, unitString];
|
||||
}
|
||||
|
||||
export function center_dict_to_strings(center_dict: WebDiplomacyCenterDict, map_id: number): [string | null, string | null] {
|
||||
const mapData = getMapData(map_id);
|
||||
if (!mapData || !mapData.ix_to_loc || !mapData.ix_to_power) {
|
||||
console.error(`center_dict_to_strings: Map data not found for mapID ${map_id}`);
|
||||
return [null, null];
|
||||
}
|
||||
const { terrID, countryID } = center_dict;
|
||||
|
||||
if (!mapData.ix_to_loc[terrID]) {
|
||||
console.error(`Unknown terrID "${terrID}" for mapID "${map_id}".`);
|
||||
return [null, null];
|
||||
}
|
||||
// countryID 0 is unowned, which is valid
|
||||
if (countryID !== 0 && !mapData.ix_to_power[countryID]) {
|
||||
console.error(`Unknown countryID "${countryID}" for mapID "${map_id}".`);
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const locName = mapData.ix_to_loc[terrID]!;
|
||||
const powerName = countryID === 0 ? 'UNOWNED' : mapData.ix_to_power[countryID]!;
|
||||
return [powerName, locName];
|
||||
}
|
||||
|
||||
export function order_dict_to_strings(order_dict: WebDiplomacyOrderDict, currentPhaseStr: 'Diplomacy' | 'Retreats' | 'Builds', map_id: number, mapName: string): [string | null, string | null] {
|
||||
const mapData = getMapData(map_id);
|
||||
if (!mapData || !mapData.ix_to_power) {
|
||||
console.error(`order_dict_to_strings: Map data not found for mapID ${map_id}`);
|
||||
return [null, null];
|
||||
}
|
||||
const { countryID } = order_dict;
|
||||
if (!mapData.ix_to_power[countryID]) {
|
||||
console.error(`Unknown countryID "${countryID}" for mapID "${map_id}".`);
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const powerName = mapData.ix_to_power[countryID]!;
|
||||
const phaseTypeChar = {'Diplomacy': 'M', 'Retreats': 'R', 'Builds': 'A'}[currentPhaseStr];
|
||||
|
||||
// WebDiplomacyOrder expects the raw dict, map_id, and phase_type character
|
||||
const order = new WebDiplomacyOrder(order_dict, mapName, phaseTypeChar); // map_id is used by constructor to get mapData
|
||||
if (!order.isValid()) { // Add an isValid method or check if to_string is null/empty
|
||||
console.warn("Failed to parse order from dict:", order_dict);
|
||||
return [powerName, null]; // Return power name even if order is invalid, for context
|
||||
}
|
||||
return [powerName, order.to_string()];
|
||||
}
|
||||
|
||||
|
||||
interface ProcessedPhase {
|
||||
name: string; // e.g., S1901M
|
||||
units: Record<string, string[]>; // powerName -> [unitStr, ...]
|
||||
centers: Record<string, string[]>; // powerName -> [locStr, ...]
|
||||
orders: Record<string, string[]>; // powerName -> [orderStr, ...]
|
||||
}
|
||||
|
||||
export function process_phase_dict(phase_dict: WebDiplomacyPhaseDict, map_id: number, mapName: string, mapFirstYear: number): ProcessedPhase {
|
||||
const phaseName = turn_to_phase(phase_dict.turn, phase_dict.phase, mapFirstYear);
|
||||
const result: ProcessedPhase = { name: phaseName, units: {}, centers: {}, orders: {} };
|
||||
|
||||
(phase_dict.units || []).forEach(unit_d => {
|
||||
const [powerName, unitStr] = unit_dict_to_strings(unit_d, map_id);
|
||||
if (powerName && unitStr) {
|
||||
result.units[powerName] = result.units[powerName] || [];
|
||||
result.units[powerName].push(unitStr);
|
||||
}
|
||||
});
|
||||
|
||||
(phase_dict.centers || []).forEach(center_d => {
|
||||
const [powerName, locStr] = center_dict_to_strings(center_d, map_id);
|
||||
if (powerName && locStr) {
|
||||
result.centers[powerName] = result.centers[powerName] || [];
|
||||
result.centers[powerName].push(locStr);
|
||||
}
|
||||
});
|
||||
|
||||
// API `orders` can be an array of WebDiplomacyPhaseOrdersDict or a Record<string, string[]>
|
||||
// The Python code expects a list of order_dict. The provided `WebDiplomacyPhaseOrdersDict` seems more aligned.
|
||||
if (Array.isArray(phase_dict.orders)) {
|
||||
phase_dict.orders.forEach((order_entry: WebDiplomacyPhaseOrdersDict) => {
|
||||
// order_entry might be {countryID, order: "string"} or {countryID, details: WebDiplomacyOrderDict}
|
||||
// The Python order_dict_to_str expects the detailed WebDiplomacyOrderDict
|
||||
if (order_entry.details) {
|
||||
const [powerName, orderStr] = order_dict_to_strings(order_entry.details, phase_dict.phase, map_id, mapName);
|
||||
if (powerName && orderStr) {
|
||||
result.orders[powerName] = result.orders[powerName] || [];
|
||||
result.orders[powerName].push(orderStr);
|
||||
}
|
||||
} else if (typeof order_entry.order === 'string' && order_entry.countryID) {
|
||||
// If only raw string is available, we might need to parse it or handle it differently.
|
||||
// This case implies the Order class needs to handle string parsing too, or this needs more logic.
|
||||
// For now, assuming `details` is the primary way.
|
||||
console.warn("Processing raw order string from API - ensure WebDiplomacyOrder can handle this if needed:", order_entry.order);
|
||||
// const powerName = CACHE[map_id]?.ix_to_power?.[order_entry.countryID];
|
||||
// if (powerName) {
|
||||
// result.orders[powerName] = result.orders[powerName] || [];
|
||||
// result.orders[powerName].push(order_entry.order); // Store raw if no details
|
||||
// }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function state_dict_to_game_and_power(state_dict: WebDiplomacyStateDict, country_id: number, max_phases?: number): GameAndPower | null {
|
||||
if (!state_dict) return null;
|
||||
|
||||
const requiredFields: Array<keyof WebDiplomacyStateDict> = ['gameID', 'variantID', 'turn', 'phase', 'gameOver', 'phases'];
|
||||
if (requiredFields.some(field => !(field in state_dict))) {
|
||||
console.error('state_dict_to_game_and_power: Missing required fields in state_dict', state_dict);
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapData = getMapData(state_dict.variantID);
|
||||
if (!mapData || !mapData.ix_to_map || !mapData.ix_to_power || !mapData.ix_to_loc) {
|
||||
console.error(`state_dict_to_game_and_power: Map data not found for variantID ${state_dict.variantID}`);
|
||||
return null;
|
||||
}
|
||||
const mapName = mapData.ix_to_map[state_dict.variantID];
|
||||
const powerName = mapData.ix_to_power[country_id];
|
||||
if (!powerName) {
|
||||
console.error(`state_dict_to_game_and_power: Power name not found for countryID ${country_id} in map ${mapName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const game = new DiplomacyGame({game_id: String(state_dict.gameID), map_name: mapName});
|
||||
const mapFirstYear = game.map.first_year || 1901;
|
||||
|
||||
|
||||
let phasesToProcess = state_dict.phases;
|
||||
if (max_phases !== undefined && max_phases !== null) {
|
||||
phasesToProcess = phasesToProcess.slice(-max_phases);
|
||||
}
|
||||
|
||||
const all_processed_phases = phasesToProcess.map(phase_d => process_phase_dict(phase_d, state_dict.variantID, mapName, mapFirstYear));
|
||||
|
||||
// Replay phases except the last one
|
||||
for (let i = 0; i < all_processed_phases.length - 1; i++) {
|
||||
const phase_data = all_processed_phases[i];
|
||||
game.set_current_phase(phase_data.name);
|
||||
|
||||
game.clear_units(); // Clear all units before setting for the phase
|
||||
for (const [pwr, units] of Object.entries(phase_data.units)) {
|
||||
if (pwr !== 'GLOBAL') game.set_units(pwr, units);
|
||||
}
|
||||
game.clear_centers(); // Clear all centers
|
||||
for (const [pwr, centers] of Object.entries(phase_data.centers)) {
|
||||
if (pwr !== 'GLOBAL') game.set_centers(pwr, centers);
|
||||
}
|
||||
for (const [pwr, orders] of Object.entries(phase_data.orders)) {
|
||||
if (pwr !== 'GLOBAL') game.set_orders(pwr, orders);
|
||||
}
|
||||
game.process();
|
||||
}
|
||||
|
||||
// Set current phase state (the last one)
|
||||
if (all_processed_phases.length > 0) {
|
||||
const current_phase_data = all_processed_phases[all_processed_phases.length - 1];
|
||||
game.set_current_phase(current_phase_data.name);
|
||||
game.clear_units();
|
||||
for (const [pwr, units] of Object.entries(current_phase_data.units)) {
|
||||
if (pwr !== 'GLOBAL') game.set_units(pwr, units);
|
||||
}
|
||||
game.clear_centers();
|
||||
for (const [pwr, centers] of Object.entries(current_phase_data.centers)) {
|
||||
if (pwr !== 'GLOBAL') game.set_centers(pwr, centers);
|
||||
}
|
||||
// Orders for the current phase are not set into game.orders, as they are not yet processed.
|
||||
// They might be needed for display or for player's own view.
|
||||
}
|
||||
|
||||
// Handle retreat phase specifics (standoffs, occupiedFrom)
|
||||
if (game.get_current_phase().slice(-1) === 'R') {
|
||||
const invalidRetreatLocs = new Set<string>();
|
||||
const attackSource: Record<string, string> = {}; // loc_base -> from_loc_base
|
||||
|
||||
// Locs with units are invalid for retreats
|
||||
Object.values(game.powers).forEach(pwr => {
|
||||
pwr.units.forEach(unit => invalidRetreatLocs.add(unit.location.substring(0,3)));
|
||||
});
|
||||
|
||||
if (state_dict.standoffs) {
|
||||
state_dict.standoffs.forEach(standoff_loc_dict => {
|
||||
const [, loc] = center_dict_to_strings(standoff_loc_dict, state_dict.variantID);
|
||||
if(loc) invalidRetreatLocs.add(loc.substring(0,3));
|
||||
});
|
||||
}
|
||||
if (state_dict.occupiedFrom && mapData.ix_to_loc) {
|
||||
for (const [loc_id_str, from_loc_id] of Object.entries(state_dict.occupiedFrom)) {
|
||||
const loc_name = mapData.ix_to_loc[parseInt(loc_id_str, 10)]?.substring(0,3);
|
||||
const from_loc_name = mapData.ix_to_loc[from_loc_id]?.substring(0,3);
|
||||
if (loc_name && from_loc_name) {
|
||||
attackSource[loc_name] = from_loc_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(game.powers).forEach(pwr_obj => {
|
||||
const currentRetreats = game.get_retreats(pwr_obj.name); // Assuming game.get_retreats() exists
|
||||
if (currentRetreats) {
|
||||
const newRetreats: Record<string, string[]> = {};
|
||||
for (const [unitLoc, possibleRetreats] of Object.entries(currentRetreats)) {
|
||||
const unitBaseLoc = unitLoc.substring(2,5); // "A PAR" -> "PAR"
|
||||
newRetreats[unitLoc] = possibleRetreats.filter(retreat_loc_option => {
|
||||
const retreat_base = retreat_loc_option.substring(0,3);
|
||||
return !invalidRetreatLocs.has(retreat_base) &&
|
||||
retreat_base !== (attackSource[unitBaseLoc] || '');
|
||||
});
|
||||
}
|
||||
// game.set_retreats(pwr_obj.name, newRetreats); // Assuming a method to update retreats
|
||||
// This part is tricky: the engine's adjudicator normally calculates retreats.
|
||||
// Here, we are *correcting* the possible retreats based on API info not available to local adjudicator (standoffs).
|
||||
// The DiplomacyGame object might not store retreats this way directly.
|
||||
// It might be better to pass this info to the agent/player.
|
||||
// For now, we log a warning if a retreat is invalidated.
|
||||
// This logic is more about providing accurate options TO a player/bot rather than modifying game state post-adjudication.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return { game, powerName };
|
||||
}
|
||||
2
diplomacy/integration/webdiplomacy_net/index.ts
Normal file
2
diplomacy/integration/webdiplomacy_net/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// diplomacy/integration/webdiplomacy_net/index.ts
|
||||
export { API } from './api'; // Assuming api.ts will be created here.
|
||||
315
diplomacy/integration/webdiplomacy_net/orders.ts
Normal file
315
diplomacy/integration/webdiplomacy_net/orders.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
// diplomacy/integration/webdiplomacy_net/orders.ts
|
||||
|
||||
import { DiplomacyGame } from '../../../engine/game';
|
||||
import { DiplomacyMap } from '../../../engine/map';
|
||||
import { CACHE, getMapData } from './utils';
|
||||
import { WebDiplomacyOrderDict } from './game'; // Assuming this is defined for the dict structure
|
||||
|
||||
// --- Helper Functions (adapted from Python) ---
|
||||
|
||||
/**
|
||||
* Checks if two locations are adjacent (for convoy purposes).
|
||||
*/
|
||||
function is_adjacent_for_convoy(loc1: string, loc2: string, map: DiplomacyMap): boolean {
|
||||
const area1 = map.get_province_type(loc1.substring(0,3));
|
||||
const area2 = map.get_province_type(loc2.substring(0,3));
|
||||
|
||||
if (area1 === 'LAND' || area2 === 'LAND') return false;
|
||||
if (area1 === 'WATER' && area2 === 'WATER') return map.abuts('F', loc1, '-', loc2);
|
||||
|
||||
let coastLoc = '';
|
||||
let waterLoc = '';
|
||||
if (area1 === 'COAST' && area2 === 'WATER') {
|
||||
coastLoc = loc1;
|
||||
waterLoc = loc2;
|
||||
} else if (area2 === 'COAST' && area1 === 'WATER') {
|
||||
coastLoc = loc2;
|
||||
waterLoc = loc1;
|
||||
} else {
|
||||
return false; // Both COAST or other combos not allowed for direct convoy adjacency step
|
||||
}
|
||||
|
||||
const coastsOfCoastLoc = map.find_coasts(coastLoc); // find_coasts expects canonical name
|
||||
return coastsOfCoastLoc.some(locWithCoast => map.abuts('F', locWithCoast, '-', waterLoc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a convoy path from src to dest.
|
||||
* Note: This is a simplified version. Python's version uses pre-calculated map.convoy_paths.
|
||||
* This version will attempt a basic BFS if map.convoy_paths is not available or suitable.
|
||||
*/
|
||||
export function find_convoy_path(
|
||||
src: string, // Base loc, e.g., PAR
|
||||
dest: string, // Base loc, e.g., LON
|
||||
map: DiplomacyMap,
|
||||
game?: DiplomacyGame, // Used to get actual fleet locations
|
||||
including?: string | string[],
|
||||
excluding?: string | string[]
|
||||
): string[] { // Returns [src, fleet1, ..., fleetN, dest]
|
||||
if (map.get_province_type(src) !== 'COAST' || map.get_province_type(dest) !== 'COAST') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const includeProvinces = Array.isArray(including) ? including : (including ? [including] : []);
|
||||
const excludeProvinces = Array.isArray(excluding) ? excluding : (excluding ? [excluding] : []);
|
||||
|
||||
const waterProvinces = new Set(map.get_all_sea_provinces());
|
||||
let availableFleets = new Set(waterProvinces); // Initially, all water provinces are potential fleet spots
|
||||
|
||||
if (game) {
|
||||
availableFleets = new Set();
|
||||
for (const powerState of Object.values(game.powers)) {
|
||||
for (const unit of powerState.units) {
|
||||
if (unit.type === 'FLEET' && waterProvinces.has(unit.location)) {
|
||||
availableFleets.add(unit.location);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple BFS for convoy path - this is a placeholder for the more complex Python logic
|
||||
// that uses pre-calculated map.convoy_paths.
|
||||
// This BFS will find *a* path, not necessarily the one webdiplomacy expects or the most optimal.
|
||||
// For webdiplomacy.net, the exact path matters for the API.
|
||||
// TODO: Replicate Python's map.convoy_paths usage if this BFS is insufficient.
|
||||
|
||||
const queue: { path: string[], remainingFleets: Set<string> }[] = [{ path: [src], remainingFleets: new Set(availableFleets) }];
|
||||
const visitedPaths = new Set<string>(); // To avoid cycles with same path prefix
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { path, remainingFleets } = queue.shift()!;
|
||||
const currentLoc = path[path.length - 1];
|
||||
|
||||
if (path.length > 1 && is_adjacent_for_convoy(currentLoc, dest, map)) { // Path.length > 1 means at least one fleet
|
||||
const finalPath = [...path, dest];
|
||||
// Check 'including' and 'excluding' constraints
|
||||
const pathFleets = finalPath.slice(1, -1);
|
||||
if (includeProvinces.every(inc => pathFleets.includes(inc)) &&
|
||||
!excludeProvinces.some(exc => pathFleets.includes(exc))) {
|
||||
return finalPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (path.length > map.locs.length / 2) continue; // Safety break for very long paths
|
||||
|
||||
for (const fleetLoc of remainingFleets) {
|
||||
if (is_adjacent_for_convoy(currentLoc, fleetLoc, map)) {
|
||||
const newPath = [...path, fleetLoc];
|
||||
const newPathKey = newPath.join(',');
|
||||
if (!visitedPaths.has(newPathKey)) {
|
||||
visitedPaths.add(newPathKey);
|
||||
const newRemainingFleets = new Set(remainingFleets);
|
||||
newRemainingFleets.delete(fleetLoc);
|
||||
queue.push({ path: newPath, remainingFleets: newRemainingFleets });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []; // No path found
|
||||
}
|
||||
|
||||
|
||||
export class WebDiplomacyOrder {
|
||||
public order_str: string = '';
|
||||
public order_dict: Partial<WebDiplomacyOrderDict> = {}; // Use partial as it's built progressively
|
||||
private map_name: string;
|
||||
private map_id: number;
|
||||
private phase_type: 'M' | 'R' | 'A'; // Movement, Retreat, Adjustment
|
||||
|
||||
constructor(
|
||||
order: string | WebDiplomacyOrderDict,
|
||||
mapNameOrId: string | number,
|
||||
phaseType?: 'M' | 'R' | 'A',
|
||||
private game?: DiplomacyGame // Optional game object for context (e.g., convoy paths)
|
||||
) {
|
||||
const mapData = getMapData(mapNameOrId);
|
||||
if (!mapData) {
|
||||
throw new Error(`Unsupported map: ${mapNameOrId}`);
|
||||
}
|
||||
this.map_name = typeof mapNameOrId === 'string' ? mapNameOrId : CACHE.ix_to_map[mapNameOrId];
|
||||
this.map_id = typeof mapNameOrId === 'number' ? mapNameOrId : CACHE.map_to_ix[mapNameOrId];
|
||||
|
||||
this.phase_type = phaseType || 'M'; // Default to movement phase
|
||||
|
||||
if (typeof order === 'string') {
|
||||
this._build_from_string(order);
|
||||
} else if (typeof order === 'object') {
|
||||
this._build_from_dict(order);
|
||||
} else {
|
||||
throw new Error('Order must be a string or a dictionary.');
|
||||
}
|
||||
}
|
||||
|
||||
private _get_map_data(): MapData | undefined {
|
||||
return getMapData(this.map_id);
|
||||
}
|
||||
|
||||
private _build_from_string(order: string): void {
|
||||
let processedOrder = order;
|
||||
if (this.phase_type === 'R') {
|
||||
processedOrder = processedOrder.replace(/\s-\s/g, ' R '); // Convert move to retreat
|
||||
}
|
||||
const words = processedOrder.split(/\s+/);
|
||||
|
||||
if (words.length === 1 && words[0].toUpperCase() === 'WAIVE') {
|
||||
this.order_str = 'WAIVE';
|
||||
this.order_dict = { type: 'Wait', terrID: undefined, unitType: undefined, toTerrID: undefined, fromTerrID: undefined, viaConvoy: undefined };
|
||||
return;
|
||||
}
|
||||
if (words.length < 3) { console.error(`Order too short: ${order}`); return; }
|
||||
|
||||
const unitTypeChar = words[0].toUpperCase();
|
||||
const locName = words[1].toUpperCase();
|
||||
const orderTypeChar = words[2].toUpperCase();
|
||||
|
||||
if (unitTypeChar !== 'A' && unitTypeChar !== 'F') { console.error(`Invalid unit type: ${unitTypeChar}`); return; }
|
||||
const mapData = this._get_map_data();
|
||||
if (!mapData || !mapData.loc_to_ix || !mapData.loc_to_ix[locName]) { console.error(`Invalid location: ${locName} for map ${this.map_name}`); return; }
|
||||
|
||||
const unitType = unitTypeChar === 'A' ? 'Army' : 'Fleet';
|
||||
const terrID = mapData.loc_to_ix![locName]; // mapData and loc_to_ix confirmed by earlier check
|
||||
|
||||
this.order_str = processedOrder; // Store the potentially modified order string (e.g., for retreats)
|
||||
this.order_dict.terrID = terrID;
|
||||
this.order_dict.unitType = unitType;
|
||||
|
||||
if (orderTypeChar === 'H') {
|
||||
this.order_dict.type = 'Hold';
|
||||
} else if (orderTypeChar === '-') { // Move or Retreat (if phase R)
|
||||
if (words.length < 4) { console.error(`[Move/Retreat] Order too short: ${order}`); return; }
|
||||
const toLocName = words[3].toUpperCase();
|
||||
if (!mapData.loc_to_ix![toLocName]) { console.error(`[Move/Retreat] Invalid target location: ${toLocName}`); return; }
|
||||
this.order_dict.toTerrID = mapData.loc_to_ix![toLocName];
|
||||
|
||||
if (this.phase_type === 'R') { // Already pre-processed ' - ' to ' R '
|
||||
this.order_dict.type = 'Retreat';
|
||||
} else {
|
||||
this.order_dict.type = 'Move';
|
||||
const viaConvoy = words[words.length - 1].toUpperCase() === 'VIA';
|
||||
this.order_dict.viaConvoy = viaConvoy ? 'Yes' : 'No';
|
||||
if (viaConvoy && unitType === 'Army' && this.game) {
|
||||
const path = find_convoy_path(locName, toLocName, this.game.map, this.game);
|
||||
if (path.length > 2) { // src, fleet1..., dest
|
||||
(this.order_dict as any).convoyPath = path.slice(1, -1).map(loc => mapData.loc_to_ix![loc]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (orderTypeChar === 'S') { // Support
|
||||
if (words.length < 5) { console.error(`[Support] Order too short: ${order}`); return; }
|
||||
const supportedUnitLocName = words[3].toUpperCase();
|
||||
if (!mapData.loc_to_ix![supportedUnitLocName]) { console.error(`[Support] Invalid supported unit location: ${supportedUnitLocName}`); return; }
|
||||
|
||||
if (words.length > 5 && words[4].toUpperCase() === '-') { // Support Move
|
||||
if (words.length < 6) { console.error(`[Support Move] Order too short: ${order}`); return; }
|
||||
const supportedMoveToLocName = words[5].toUpperCase();
|
||||
if (!mapData.loc_to_ix![supportedMoveToLocName]) { console.error(`[Support Move] Invalid target location: ${supportedMoveToLocName}`); return; }
|
||||
|
||||
this.order_dict.type = 'Support move';
|
||||
this.order_dict.fromTerrID = mapData.loc_to_ix![supportedUnitLocName]; // Unit being supported is 'from'
|
||||
this.order_dict.toTerrID = mapData.loc_to_ix![supportedMoveToLocName]; // Destination of supported unit
|
||||
} else { // Support Hold
|
||||
this.order_dict.type = 'Support hold';
|
||||
this.order_dict.toTerrID = mapData.loc_to_ix![supportedUnitLocName]; // Location of unit being held
|
||||
}
|
||||
} else if (orderTypeChar === 'C') { // Convoy
|
||||
if (words.length < 6 || words[3].toUpperCase() !== 'A' || words[5].toUpperCase() !== '-') { // e.g. F ENG C A BRE - PIC
|
||||
console.error(`[Convoy] Malformed convoy order: ${order}. Expecting F X C A Y - Z`); return;
|
||||
}
|
||||
const convoyedUnitFromLocName = words[4].toUpperCase();
|
||||
const convoyedUnitToLocName = words[6].toUpperCase();
|
||||
if (!mapData.loc_to_ix![convoyedUnitFromLocName]) { console.error(`[Convoy] Invalid convoy source: ${convoyedUnitFromLocName}`); return; }
|
||||
if (!mapData.loc_to_ix![convoyedUnitToLocName]) { console.error(`[Convoy] Invalid convoy target: ${convoyedUnitToLocName}`); return; }
|
||||
|
||||
this.order_dict.type = 'Convoy';
|
||||
this.order_dict.fromTerrID = mapData.loc_to_ix![convoyedUnitFromLocName];
|
||||
this.order_dict.toTerrID = mapData.loc_to_ix![convoyedUnitToLocName];
|
||||
if (this.game) {
|
||||
const path = find_convoy_path(convoyedUnitFromLocName, convoyedUnitToLocName, this.game.map, this.game, locName);
|
||||
if (path.length > 2) {
|
||||
(this.order_dict as any).convoyPath = path.slice(1, -1).map(l => mapData.loc_to_ix![l]);
|
||||
}
|
||||
}
|
||||
} else if (orderTypeChar === 'D') { // Disband
|
||||
this.order_dict.type = this.phase_type === 'A' ? 'Destroy' : 'Disband';
|
||||
if (this.phase_type === 'A') { // For adjustment phase, toTerrID is the location of unit being destroyed
|
||||
this.order_dict.toTerrID = terrID;
|
||||
}
|
||||
} else if (orderTypeChar === 'B') { // Build
|
||||
this.order_dict.type = unitType === 'Army' ? 'Build Army' : 'Build Fleet';
|
||||
this.order_dict.toTerrID = terrID; // Location of build is toTerrID for API
|
||||
} else {
|
||||
console.error(`Unknown order type char: ${orderTypeChar} in ${order}`);
|
||||
}
|
||||
}
|
||||
|
||||
private _build_from_dict(orderDict: WebDiplomacyOrderDict): void {
|
||||
this.order_dict = { ...orderDict };
|
||||
const mapData = this._get_map_data();
|
||||
if (!mapData || !mapData.ix_to_loc) { console.error("Map data not available for dict parsing"); return; }
|
||||
|
||||
const { type, terrID, unitType, toTerrID, fromTerrID, viaConvoy } = orderDict;
|
||||
|
||||
// Determine the primary location for the unit, using toTerrID for builds/destroys if terrID is null
|
||||
const primaryTerrID = (type === 'Build Army' || type === 'Build Fleet' || type === 'Destroy') ? toTerrID : terrID;
|
||||
const locName = primaryTerrID ? mapData.ix_to_loc[primaryTerrID] : null;
|
||||
const toLocName = toTerrID ? mapData.ix_to_loc[toTerrID] : null;
|
||||
const fromLocName = fromTerrID ? mapData.ix_to_loc[fromTerrID] : null;
|
||||
|
||||
let unitChar = '?'; // Default for WAIVE or unknown
|
||||
if (type === 'Build Army') unitChar = 'A';
|
||||
else if (type === 'Build Fleet') unitChar = 'F';
|
||||
else if (unitType) unitChar = unitType[0];
|
||||
|
||||
|
||||
if (!locName && type !== 'Wait') { console.error("Location name missing for order", orderDict); this.order_str = "ERROR_MISSING_LOC"; return; }
|
||||
|
||||
switch (type) {
|
||||
case 'Hold': this.order_str = `${unitChar} ${locName} H`; break;
|
||||
case 'Move': this.order_str = `${unitChar} ${locName} - ${toLocName}${viaConvoy === 'Yes' ? ' VIA' : ''}`; break;
|
||||
case 'Support hold': this.order_str = `${unitChar} ${locName} S ${toLocName}`; break;
|
||||
case 'Support move': this.order_str = `${unitChar} ${locName} S ${fromLocName} - ${toLocName}`; break;
|
||||
case 'Convoy': this.order_str = `${unitChar} ${locName} C A ${fromLocName} - ${toLocName}`; break;
|
||||
case 'Retreat': this.order_str = `${unitChar} ${locName} R ${toLocName}`; break;
|
||||
case 'Disband': this.order_str = `${unitChar} ${locName} D`; break;
|
||||
case 'Build Army': this.order_str = `A ${locName} B`; break;
|
||||
case 'Build Fleet': this.order_str = `F ${locName} B`; break;
|
||||
case 'Wait': this.order_str = 'WAIVE'; break;
|
||||
case 'Destroy': this.order_str = `${unitChar} ${locName} D`; break;
|
||||
default: console.error(`Unknown order type in dict: ${type}`); this.order_str = 'ERROR_UNKNOWN_ORDER_TYPE';
|
||||
}
|
||||
}
|
||||
|
||||
isValid(): boolean {
|
||||
return !!this.order_str && !this.order_str.startsWith('ERROR');
|
||||
}
|
||||
|
||||
to_string(): string {
|
||||
return this.order_str;
|
||||
}
|
||||
|
||||
to_norm_string(): string {
|
||||
let normStr = this.order_str;
|
||||
if (normStr.endsWith(' D') && this.order_dict.unitType === '?') { // Special case for implicit disband from Python
|
||||
normStr = `? ${this.order_str.substring(2)}`;
|
||||
}
|
||||
return normStr
|
||||
.replace(/\sS\sA\s/g, ' S ')
|
||||
.replace(/\sS\sF\s/g, ' S ')
|
||||
.replace(/\sC\sA\s/g, ' C ')
|
||||
.replace(/\sC\sF\s/g, ' C ')
|
||||
.replace(/\sVIA$/, '');
|
||||
}
|
||||
|
||||
to_dict(): Partial<WebDiplomacyOrderDict> {
|
||||
// Ensure essential fields are present, even if undefined from partial
|
||||
return {
|
||||
terrID: this.order_dict.terrID,
|
||||
unitType: this.order_dict.unitType as ('Army' | 'Fleet' | undefined),
|
||||
type: this.order_dict.type!, // type should always be defined
|
||||
toTerrID: this.order_dict.toTerrID,
|
||||
fromTerrID: this.order_dict.fromTerrID,
|
||||
viaConvoy: this.order_dict.viaConvoy as ('Yes' | 'No' | undefined),
|
||||
convoyPath: (this.order_dict as any).convoyPath, // Keep if already set
|
||||
};
|
||||
}
|
||||
}
|
||||
171
diplomacy/integration/webdiplomacy_net/tests/game.spec.ts
Normal file
171
diplomacy/integration/webdiplomacy_net/tests/game.spec.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// diplomacy/integration/webdiplomacy_net/tests/game.spec.ts
|
||||
|
||||
import {
|
||||
turn_to_phase,
|
||||
unit_dict_to_strings, // Renamed from unit_dict_to_str for clarity (plural strings)
|
||||
center_dict_to_strings, // Renamed from center_dict_to_str
|
||||
order_dict_to_strings, // Renamed from order_dict_to_str
|
||||
WebDiplomacyUnitDict,
|
||||
WebDiplomacyCenterDict,
|
||||
WebDiplomacyOrderDict,
|
||||
} from '../game'; // Adjust path as necessary
|
||||
import { CACHE } from '../utils'; // To ensure cache is initialized for tests
|
||||
|
||||
// Initialize map data for tests if not already done by utils import
|
||||
// This is implicitly handled by importing utils.ts as it populates CACHE on load.
|
||||
|
||||
describe('turn_to_phase', () => {
|
||||
it('should test S1901M', () => {
|
||||
const phase = turn_to_phase(0, 'Diplomacy');
|
||||
expect(phase).toBe('S1901M');
|
||||
});
|
||||
it('should test S1901R', () => {
|
||||
const phase = turn_to_phase(0, 'Retreats');
|
||||
expect(phase).toBe('S1901R');
|
||||
});
|
||||
it('should test F1901M', () => {
|
||||
const phase = turn_to_phase(1, 'Diplomacy');
|
||||
expect(phase).toBe('F1901M');
|
||||
});
|
||||
it('should test F1901R', () => {
|
||||
const phase = turn_to_phase(1, 'Retreats');
|
||||
expect(phase).toBe('F1901R');
|
||||
});
|
||||
it('should test W1901A', () => {
|
||||
const phase = turn_to_phase(1, 'Builds', 1901); // Assuming default first year or pass explicitly
|
||||
expect(phase).toBe('W1901A');
|
||||
});
|
||||
it('should test S1902M', () => {
|
||||
const phase = turn_to_phase(2, 'Diplomacy');
|
||||
expect(phase).toBe('S1902M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unit_dict_to_strings', () => {
|
||||
it('should parse Army France', () => {
|
||||
const unit_dict: WebDiplomacyUnitDict = { unitType: 'Army', terrID: 47, countryID: 2, retreating: 'No' };
|
||||
const [powerName, unit] = unit_dict_to_strings(unit_dict, 1); // map_id 1 for standard
|
||||
expect(powerName).toBe('FRANCE');
|
||||
expect(unit).toBe('A PAR');
|
||||
});
|
||||
it('should parse Dislodged Fleet England', () => {
|
||||
const unit_dict: WebDiplomacyUnitDict = { unitType: 'Fleet', terrID: 6, countryID: 1, retreating: 'Yes' };
|
||||
const [powerName, unit] = unit_dict_to_strings(unit_dict, 1);
|
||||
expect(powerName).toBe('ENGLAND');
|
||||
expect(unit).toBe('*F LON');
|
||||
});
|
||||
it('should handle invalid unit', () => {
|
||||
const unit_dict: WebDiplomacyUnitDict = { unitType: 'Fleet', terrID: 99, countryID: 0, retreating: 'No' }; // terrID 99 is invalid for standard
|
||||
const [powerName, unit] = unit_dict_to_strings(unit_dict, 1);
|
||||
expect(powerName).toBeNull(); // Or specific error handling if preferred
|
||||
expect(unit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('center_dict_to_strings', () => {
|
||||
it('should parse centers', () => {
|
||||
let [powerName, center] = center_dict_to_strings({ countryID: 1, terrID: 6 } as WebDiplomacyCenterDict, 1);
|
||||
expect(powerName).toBe('ENGLAND');
|
||||
expect(center).toBe('LON');
|
||||
|
||||
[powerName, center] = center_dict_to_strings({ countryID: 2, terrID: 47 } as WebDiplomacyCenterDict, 1);
|
||||
expect(powerName).toBe('FRANCE');
|
||||
expect(center).toBe('PAR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('order_dict_to_strings', () => {
|
||||
// mapName 'standard' corresponds to map_id 1
|
||||
const mapName = 'standard';
|
||||
const mapId = 1;
|
||||
|
||||
it('should parse S1901M Hold', () => {
|
||||
const order_dict: WebDiplomacyOrderDict = {
|
||||
countryID: 2, // FRANCE
|
||||
terrID: 6, // LON
|
||||
unitType: 'Army',
|
||||
type: 'Hold',
|
||||
};
|
||||
const [powerName, order] = order_dict_to_strings(order_dict, 'Diplomacy', mapId, mapName);
|
||||
expect(powerName).toBe('FRANCE');
|
||||
expect(order).toBe('A LON H');
|
||||
});
|
||||
|
||||
it('should parse S1901R Disband', () => {
|
||||
const order_dict: WebDiplomacyOrderDict = {
|
||||
countryID: 1, // ENGLAND
|
||||
terrID: 6, // LON
|
||||
unitType: 'Fleet',
|
||||
type: 'Disband',
|
||||
};
|
||||
const [powerName, order] = order_dict_to_strings(order_dict, 'Retreats', mapId, mapName);
|
||||
expect(powerName).toBe('ENGLAND');
|
||||
expect(order).toBe('F LON D');
|
||||
});
|
||||
|
||||
it('should parse F1901M Move', () => {
|
||||
const order_dict: WebDiplomacyOrderDict = {
|
||||
countryID: 2, // FRANCE
|
||||
terrID: 6, // LON
|
||||
unitType: 'Army',
|
||||
type: 'Move',
|
||||
toTerrID: 47, // PAR
|
||||
viaConvoy: 'Yes',
|
||||
};
|
||||
const [powerName, order] = order_dict_to_strings(order_dict, 'Diplomacy', mapId, mapName);
|
||||
expect(powerName).toBe('FRANCE');
|
||||
expect(order).toBe('A LON - PAR VIA');
|
||||
});
|
||||
|
||||
it('should parse F1901R Retreat', () => {
|
||||
const order_dict: WebDiplomacyOrderDict = {
|
||||
countryID: 3, // ITALY
|
||||
terrID: 6, // LON
|
||||
unitType: 'Army',
|
||||
type: 'Retreat',
|
||||
toTerrID: 47, // PAR
|
||||
};
|
||||
const [powerName, order] = order_dict_to_strings(order_dict, 'Retreats', mapId, mapName);
|
||||
expect(powerName).toBe('ITALY');
|
||||
expect(order).toBe('A LON R PAR');
|
||||
});
|
||||
|
||||
it('should parse W1901A Build Army', () => {
|
||||
const order_dict: WebDiplomacyOrderDict = {
|
||||
countryID: 2, // FRANCE
|
||||
// terrID is null for builds in some API versions, toTerrID is used
|
||||
terrID: 6, // LON - Python test used terrID, let's assume it can be primary loc for unit
|
||||
unitType: 'Army', // This might be inferred from "Build Army"
|
||||
type: 'Build Army',
|
||||
toTerrID: 6, // LON
|
||||
};
|
||||
const [powerName, order] = order_dict_to_strings(order_dict, 'Builds', mapId, mapName);
|
||||
expect(powerName).toBe('FRANCE');
|
||||
expect(order).toBe('A LON B');
|
||||
});
|
||||
|
||||
it('should parse W1901A Build Fleet', () => {
|
||||
const order_dict: WebDiplomacyOrderDict = {
|
||||
countryID: 1, // ENGLAND
|
||||
terrID: 6, // LON
|
||||
type: 'Build Fleet',
|
||||
toTerrID: 6, // LON (location of build)
|
||||
};
|
||||
const [powerName, order] = order_dict_to_strings(order_dict, 'Builds', mapId, mapName);
|
||||
expect(powerName).toBe('ENGLAND');
|
||||
expect(order).toBe('F LON B');
|
||||
});
|
||||
|
||||
|
||||
it('should parse S1902M Hold', () => {
|
||||
const order_dict: WebDiplomacyOrderDict = {
|
||||
countryID: 2, // FRANCE
|
||||
terrID: 6, // LON
|
||||
unitType: 'Army',
|
||||
type: 'Hold',
|
||||
};
|
||||
const [powerName, order] = order_dict_to_strings(order_dict, 'Diplomacy', mapId, mapName);
|
||||
expect(powerName).toBe('FRANCE');
|
||||
expect(order).toBe('A LON H');
|
||||
});
|
||||
});
|
||||
4
diplomacy/integration/webdiplomacy_net/tests/index.ts
Normal file
4
diplomacy/integration/webdiplomacy_net/tests/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// diplomacy/integration/webdiplomacy_net/tests/index.ts
|
||||
// This file can be used to export symbols from test modules, if needed.
|
||||
// For now, it's empty as the corresponding __init__.py was empty.
|
||||
export {}; // Ensures this is treated as a module.
|
||||
257
diplomacy/integration/webdiplomacy_net/tests/orders.spec.ts
Normal file
257
diplomacy/integration/webdiplomacy_net/tests/orders.spec.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// diplomacy/integration/webdiplomacy_net/tests/orders.spec.ts
|
||||
|
||||
import { WebDiplomacyOrder } from '../orders';
|
||||
import { WebDiplomacyOrderDict } from '../game'; // Assuming this is defined for the dict structure
|
||||
// import { DiplomacyGame } from '../../../engine/game'; // May be needed if game context is used for convoy path tests
|
||||
// import { DiplomacyMap } from '../../../engine/map'; // May be needed for map context
|
||||
|
||||
// Helper function to compare dictionaries, excluding 'convoyPath'
|
||||
function compareOrderDicts(dict1: Partial<WebDiplomacyOrderDict>, dict2: Partial<WebDiplomacyOrderDict>): boolean {
|
||||
const keys1 = new Set(Object.keys(dict1).filter(k => k !== 'convoyPath'));
|
||||
const keys2 = new Set(Object.keys(dict2).filter(k => k !== 'convoyPath'));
|
||||
|
||||
if (keys1.size !== keys2.size) return false;
|
||||
|
||||
for (const key of Array.from(keys1)) {
|
||||
if (!keys2.has(key)) return false;
|
||||
if ((dict1 as any)[key] !== (dict2 as any)[key]) {
|
||||
// Allow for terrID/toTerrID/fromTerrID to be number or string due to API inconsistencies
|
||||
if (['terrID', 'toTerrID', 'fromTerrID'].includes(key)) {
|
||||
if (String((dict1 as any)[key]) !== String((dict2 as any)[key])) return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe('WebDiplomacyOrder Construction and Conversion', () => {
|
||||
// Default map for these tests is 'standard' (map_id: 1)
|
||||
const defaultMapName = 'standard';
|
||||
const defaultMapId = 1;
|
||||
|
||||
describe('Hold Orders', () => {
|
||||
it('should correctly parse and format "A PAR H"', () => {
|
||||
const raw_order = 'A PAR H';
|
||||
const expected_order_str = 'A PAR H';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 47, unitType: 'Army', type: 'Hold', toTerrID: '', fromTerrID: '', viaConvoy: ''
|
||||
};
|
||||
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName);
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
|
||||
const orderFromDict = new WebDiplomacyOrder(expected_order_dict as WebDiplomacyOrderDict, defaultMapId);
|
||||
expect(orderFromDict.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromDict.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle invalid location "A ABC H"', () => {
|
||||
const raw_order = 'A ABC H';
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName);
|
||||
expect(orderFromString.to_string()).toBe(''); // Or handle error as per implementation
|
||||
expect(orderFromString.isValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly parse and format "F LON H"', () => {
|
||||
const raw_order = 'F LON H';
|
||||
const expected_order_str = 'F LON H';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 6, unitType: 'Fleet', type: 'Hold', toTerrID: '', fromTerrID: '', viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName);
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
|
||||
const orderFromDict = new WebDiplomacyOrder(expected_order_dict as WebDiplomacyOrderDict, defaultMapId);
|
||||
expect(orderFromDict.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromDict.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Move Orders', () => {
|
||||
it('should parse "A YOR - LON"', () => {
|
||||
const raw_order = 'A YOR - LON';
|
||||
const expected_order_str = 'A YOR - LON';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 4, unitType: 'Army', type: 'Move', toTerrID: 6, fromTerrID: '', viaConvoy: 'No'
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName);
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "A PAR - LON VIA"', () => {
|
||||
const raw_order = 'A PAR - LON VIA';
|
||||
const expected_order_str = 'A PAR - LON VIA'; // Assuming VIA is kept by to_string if present
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 47, unitType: 'Army', type: 'Move', toTerrID: 6, fromTerrID: '', viaConvoy: 'Yes'
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName);
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "F BRE - MAO"', () => {
|
||||
const raw_order = 'F BRE - MAO';
|
||||
const expected_order_str = 'F BRE - MAO';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 46, unitType: 'Fleet', type: 'Move', toTerrID: 61, fromTerrID: '', viaConvoy: 'No'
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName);
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Support Hold Orders', () => {
|
||||
it('should parse "A PAR S F BRE"', () => {
|
||||
const raw_order = 'A PAR S F BRE';
|
||||
const expected_order_str = 'A PAR S BRE'; // Normalized string might remove supported unit type
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 47, unitType: 'Army', type: 'Support hold', toTerrID: 46, fromTerrID: '', viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName);
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str); // Or raw_order if to_string keeps unit type
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Support Move Orders', () => {
|
||||
it('should parse "A PAR S F MAO - BRE"', () => {
|
||||
const raw_order = 'A PAR S F MAO - BRE';
|
||||
const expected_order_str = 'A PAR S MAO - BRE'; // Normalized string might remove unit type
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 47, unitType: 'Army', type: 'Support move', toTerrID: 46, fromTerrID: 61, viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName);
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Convoy Orders', () => {
|
||||
it('should parse "F MAO C A PAR - LON"', () => {
|
||||
const raw_order = 'F MAO C A PAR - LON';
|
||||
const expected_order_str = 'F MAO C A PAR - LON'; // String form usually keeps convoyed unit type
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 61, unitType: 'Fleet', type: 'Convoy', toTerrID: 6, fromTerrID: 47, viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName);
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Retreat Orders', () => {
|
||||
it('should parse "A PAR R LON"', () => {
|
||||
const raw_order = 'A PAR R LON';
|
||||
const expected_order_str = 'A PAR R LON';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 47, unitType: 'Army', type: 'Retreat', toTerrID: 6, fromTerrID: '', viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName, 'R');
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert "A PAR - LON" to retreat in Retreat phase', () => {
|
||||
const raw_order = 'A PAR - LON';
|
||||
const expected_order_str = 'A PAR R LON';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 47, unitType: 'Army', type: 'Retreat', toTerrID: 6, fromTerrID: '', viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName, 'R');
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disband Orders', () => {
|
||||
it('should parse "A PAR D" in Retreat phase', () => {
|
||||
const raw_order = 'A PAR D';
|
||||
const expected_order_str = 'A PAR D';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 47, unitType: 'Army', type: 'Disband', toTerrID: '', fromTerrID: '', viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName, 'R');
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "F SPA/NC D" in Retreat phase (disband with coast)', () => {
|
||||
const raw_order = 'F SPA/NC D';
|
||||
const expected_order_str = 'F SPA/NC D';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 76, unitType: 'Fleet', type: 'Disband', toTerrID: '', fromTerrID: '', viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName, 'R');
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "F SPA/NC D" as "Destroy" in Adjustment phase (disband without coast for API)', () => {
|
||||
const raw_order = 'F SPA/NC D'; // User input might still include coast
|
||||
const expected_order_str = 'F SPA D'; // String output for adjustment disband is base province
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 8, unitType: 'Fleet', type: 'Destroy', toTerrID: 8, fromTerrID: '', viaConvoy: ''
|
||||
}; // terrID and toTerrID are base province ID for Destroy
|
||||
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName, 'A');
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
|
||||
const orderFromDict = new WebDiplomacyOrder(expected_order_dict as WebDiplomacyOrderDict, defaultMapId, 'A');
|
||||
// Building from dict should yield the string representation that matches the dict's intent
|
||||
expect(orderFromDict.to_string()).toBe(expected_order_str); // F SPA D
|
||||
expect(compareOrderDicts(orderFromDict.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Build Orders', () => {
|
||||
it('should parse "A PAR B"', () => {
|
||||
const raw_order = 'A PAR B';
|
||||
const expected_order_str = 'A PAR B';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 47, unitType: 'Army', type: 'Build Army', toTerrID: 47, fromTerrID: '', viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName, 'A');
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
it('should parse "F BRE B"', () => {
|
||||
const raw_order = 'F BRE B';
|
||||
const expected_order_str = 'F BRE B';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
terrID: 46, unitType: 'Fleet', type: 'Build Fleet', toTerrID: 46, fromTerrID: '', viaConvoy: ''
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName, 'A');
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
expect(compareOrderDicts(orderFromString.to_dict(), expected_order_dict)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Waive Orders', () => {
|
||||
it('should parse "WAIVE"', () => {
|
||||
const raw_order = 'WAIVE';
|
||||
const expected_order_str = 'WAIVE';
|
||||
const expected_order_dict: Partial<WebDiplomacyOrderDict> = {
|
||||
type: 'Wait', terrID: undefined, unitType: undefined, toTerrID: undefined, fromTerrID: undefined, viaConvoy: undefined
|
||||
};
|
||||
const orderFromString = new WebDiplomacyOrder(raw_order, defaultMapName, 'A');
|
||||
expect(orderFromString.to_string()).toBe(expected_order_str);
|
||||
// WAIVE results in a specific dict structure
|
||||
const generatedDict = orderFromString.to_dict();
|
||||
expect(generatedDict.type).toBe('Wait');
|
||||
expect(generatedDict.terrID).toBeUndefined(); // Or null depending on implementation
|
||||
expect(generatedDict.unitType).toBeUndefined();
|
||||
|
||||
|
||||
const orderFromDict = new WebDiplomacyOrder({type: 'Wait'} as WebDiplomacyOrderDict, defaultMapId, 'A');
|
||||
expect(orderFromDict.to_string()).toBe(expected_order_str);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
120
diplomacy/integration/webdiplomacy_net/utils.ts
Normal file
120
diplomacy/integration/webdiplomacy_net/utils.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// diplomacy/integration/webdiplomacy_net/utils.ts
|
||||
|
||||
/**
|
||||
* Represents a game ID and country ID pair.
|
||||
*/
|
||||
export interface GameIdCountryId {
|
||||
game_id: number | string; // game_id can sometimes be string in webdiplomacy context
|
||||
country_id: number;
|
||||
}
|
||||
|
||||
interface MapData {
|
||||
powers: string[];
|
||||
locs: (string | null)[];
|
||||
ix_to_power?: Record<number, string>;
|
||||
power_to_ix?: Record<string, number>;
|
||||
ix_to_loc?: Record<number, string>;
|
||||
loc_to_ix?: Record<string, number>;
|
||||
}
|
||||
|
||||
interface WebDiplomacyCache {
|
||||
ix_to_map: Record<number, string>;
|
||||
map_to_ix: Record<string, number>;
|
||||
[key: string]: any; // For map names like 'standard'
|
||||
[key: number]: MapData | undefined; // For map IDs like 1, 15, 23
|
||||
}
|
||||
|
||||
export const CACHE: WebDiplomacyCache = {
|
||||
ix_to_map: { 1: 'standard', 15: 'standard_france_austria', 23: 'standard_germany_italy' },
|
||||
map_to_ix: { 'standard': 1, 'standard_france_austria': 15, 'standard_germany_italy': 23 },
|
||||
};
|
||||
|
||||
const standardMapData: MapData = {
|
||||
powers: ['GLOBAL', 'ENGLAND', 'FRANCE', 'ITALY', 'GERMANY', 'AUSTRIA', 'TURKEY', 'RUSSIA'],
|
||||
locs: [null, 'CLY', 'EDI', 'LVP', 'YOR', 'WAL', 'LON', 'POR', 'SPA', 'NAF', 'TUN', 'NAP', 'ROM', 'TUS',
|
||||
'PIE', 'VEN', 'APU', 'GRE', 'ALB', 'SER', 'BUL', 'RUM', 'CON', 'SMY', 'ANK', 'ARM', 'SYR', 'SEV',
|
||||
'UKR', 'WAR', 'LVN', 'MOS', 'STP', 'FIN', 'SWE', 'NWY', 'DEN', 'KIE', 'BER', 'PRU', 'SIL', 'MUN',
|
||||
'RUH', 'HOL', 'BEL', 'PIC', 'BRE', 'PAR', 'BUR', 'MAR', 'GAS', 'BAR', 'NWG', 'NTH', 'SKA', 'HEL',
|
||||
'BAL', 'BOT', 'NAO', 'IRI', 'ENG', 'MAO', 'WES', 'LYO', 'TYS', 'ION', 'ADR', 'AEG', 'EAS', 'BLA',
|
||||
'TYR', 'BOH', 'VIE', 'TRI', 'BUD', 'GAL', 'SPA/NC', 'SPA/SC', 'STP/NC', 'STP/SC', 'BUL/EC',
|
||||
'BUL/SC']
|
||||
};
|
||||
|
||||
const franceAustriaMapData: MapData = {
|
||||
powers: ['GLOBAL', 'FRANCE', 'AUSTRIA'],
|
||||
locs: [null, 'CLY', 'EDI', 'LVP', 'YOR', 'WAL', 'LON', 'POR', 'SPA', 'SPA/NC', 'SPA/SC', 'NAF', 'TUN',
|
||||
'NAP', 'ROM', 'TUS', 'PIE', 'VEN', 'APU', 'GRE', 'ALB', 'SER', 'BUL', 'BUL/EC', 'BUL/SC', 'RUM',
|
||||
'CON', 'SMY', 'ANK', 'ARM', 'SYR', 'SEV', 'UKR', 'WAR', 'LVN', 'MOS', 'STP', 'STP/NC', 'STP/SC',
|
||||
'FIN', 'SWE', 'NWY', 'DEN', 'KIE', 'BER', 'PRU', 'SIL', 'MUN', 'RUH', 'HOL', 'BEL', 'PIC', 'BRE',
|
||||
'PAR', 'BUR', 'MAR', 'GAS', 'BAR', 'NWG', 'NTH', 'SKA', 'HEL', 'BAL', 'BOT', 'NAO', 'IRI', 'ENG',
|
||||
'MAO', 'WES', 'LYO', 'TYS', 'ION', 'ADR', 'AEG', 'EAS', 'BLA', 'TYR', 'BOH', 'VIE', 'TRI', 'BUD',
|
||||
'GAL']
|
||||
};
|
||||
|
||||
const germanyItalyMapData: MapData = {
|
||||
powers: ['GLOBAL', 'GERMANY', 'ITALY'],
|
||||
locs: [null, 'CLY', 'EDI', 'LVP', 'YOR', 'WAL', 'LON', 'POR', 'SPA', 'SPA/NC', 'SPA/SC', 'NAF', 'TUN',
|
||||
'NAP', 'ROM', 'TUS', 'PIE', 'VEN', 'APU', 'GRE', 'ALB', 'SER', 'BUL', 'BUL/EC', 'BUL/SC', 'RUM',
|
||||
'CON', 'SMY', 'ANK', 'ARM', 'SYR', 'SEV', 'UKR', 'WAR', 'LVN', 'MOS', 'STP', 'STP/NC', 'STP/SC',
|
||||
'FIN', 'SWE', 'NWY', 'DEN', 'KIE', 'BER', 'PRU', 'SIL', 'MUN', 'RUH', 'HOL', 'BEL', 'PIC', 'BRE',
|
||||
'PAR', 'BUR', 'MAR', 'GAS', 'BAR', 'NWG', 'NTH', 'SKA', 'HEL', 'BAL', 'BOT', 'NAO', 'IRI', 'ENG',
|
||||
'MAO', 'WES', 'LYO', 'TYS', 'ION', 'ADR', 'AEG', 'EAS', 'BLA', 'TYR', 'BOH', 'VIE', 'TRI', 'BUD',
|
||||
'GAL']
|
||||
};
|
||||
|
||||
CACHE[1] = standardMapData;
|
||||
CACHE['standard'] = standardMapData;
|
||||
CACHE[15] = franceAustriaMapData;
|
||||
CACHE['standard_france_austria'] = franceAustriaMapData;
|
||||
CACHE[23] = germanyItalyMapData;
|
||||
CACHE['standard_germany_italy'] = germanyItalyMapData;
|
||||
|
||||
|
||||
function populateMapSpecificCache(mapData: MapData): void {
|
||||
mapData.ix_to_power = {};
|
||||
mapData.power_to_ix = {};
|
||||
mapData.ix_to_loc = {};
|
||||
mapData.loc_to_ix = {};
|
||||
|
||||
mapData.powers.forEach((powerName, index) => {
|
||||
mapData.ix_to_power![index] = powerName;
|
||||
mapData.power_to_ix![powerName] = index;
|
||||
});
|
||||
|
||||
mapData.locs.forEach((locName, index) => {
|
||||
if (index === 0 || locName === null) { // Skip the null at index 0
|
||||
return;
|
||||
}
|
||||
mapData.ix_to_loc![index] = locName;
|
||||
mapData.loc_to_ix![locName] = index;
|
||||
});
|
||||
}
|
||||
|
||||
// Populate the cache for each map type
|
||||
populateMapSpecificCache(CACHE[1]!);
|
||||
populateMapSpecificCache(CACHE[15]!);
|
||||
populateMapSpecificCache(CACHE[23]!);
|
||||
|
||||
/**
|
||||
* Helper function to get map-specific data from the cache.
|
||||
* @param mapIdOrName - The ID (1, 15, 23) or name ('standard', etc.) of the map.
|
||||
* @returns The map-specific data, or undefined if not found.
|
||||
*/
|
||||
export function getMapData(mapIdOrName: number | string): MapData | undefined {
|
||||
if (typeof mapIdOrName === 'number') {
|
||||
return CACHE[mapIdOrName];
|
||||
} else if (typeof mapIdOrName === 'string' && CACHE[mapIdOrName]) {
|
||||
return CACHE[mapIdOrName] as MapData;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Example usage (optional, can be removed)
|
||||
// const standardData = getMapData('standard');
|
||||
// if (standardData && standardData.power_to_ix) {
|
||||
// console.log("Index of ENGLAND in standard map:", standardData.power_to_ix['ENGLAND']);
|
||||
// }
|
||||
// const map15Data = getMapData(15);
|
||||
// if (map15Data && map15Data.ix_to_loc) {
|
||||
// console.log("Location at index 5 in map 15:", map15Data.ix_to_loc[5]);
|
||||
// }
|
||||
4
diplomacy/server/index.ts
Normal file
4
diplomacy/server/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// diplomacy/server/index.ts
|
||||
// This file will be used to export symbols from the server modules.
|
||||
// For now, it's empty as the corresponding __init__.py was empty.
|
||||
export {}; // Ensures this is treated as a module.
|
||||
266
diplomacy/server/notifier.ts
Normal file
266
diplomacy/server/notifier.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
// diplomacy/server/notifier.ts
|
||||
|
||||
import { ServerGame, DiplomacyServerInterface } from './server_game'; // DiplomacyServerInterface needs Users and a notifications queue
|
||||
import { Power } from '../../engine/power';
|
||||
import * as notifications from '../../communication/notifications'; // Assuming this is the path to translated notifications
|
||||
import { GAME, OBSERVER_TYPE, OMNISCIENT_TYPE } from '../utils/strings';
|
||||
import { GamePhaseData } from '../utils/game_phase_data';
|
||||
import { Users } from './users'; // Needed for server.users type
|
||||
|
||||
// Placeholder for ConnectionHandler if not fully defined elsewhere
|
||||
// This is a simplified interface based on Notifier's usage.
|
||||
export interface ConnectionHandler {
|
||||
translate_notification(notification: notifications.AbstractNotification): any[] | null; // Return type might be more specific
|
||||
// send(data: any): void; // Actual sending mechanism might be part of connection_handler or server.notifications.put
|
||||
}
|
||||
|
||||
export class Notifier {
|
||||
private server: DiplomacyServerInterface; // Should have 'users' and 'notifications' queue
|
||||
private ignore_tokens: Set<string> | null = null;
|
||||
private ignore_addresses: Map<string, Set<string>> | null = null; // powerName -> Set<token>
|
||||
|
||||
constructor(server: DiplomacyServerInterface, ignore_tokens?: string[] | null, ignore_addresses?: Array<[string, string]> | null) {
|
||||
this.server = server;
|
||||
|
||||
if (ignore_tokens && ignore_addresses) {
|
||||
throw new Error('Notifier cannot ignore both tokens and addresses.');
|
||||
}
|
||||
|
||||
if (ignore_tokens) {
|
||||
this.ignore_tokens = new Set(ignore_tokens);
|
||||
} else if (ignore_addresses) {
|
||||
this.ignore_addresses = new Map<string, Set<string>>();
|
||||
for (const [power_name, token] of ignore_addresses) {
|
||||
if (!this.ignore_addresses.has(power_name)) {
|
||||
this.ignore_addresses.set(power_name, new Set<string>());
|
||||
}
|
||||
this.ignore_addresses.get(power_name)!.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ignores(notification: notifications.AbstractNotification | notifications.GameNotification): boolean {
|
||||
if (this.ignore_tokens) {
|
||||
return this.ignore_tokens.has(notification.token);
|
||||
}
|
||||
if (this.ignore_addresses && notification.level === GAME) {
|
||||
// GameNotification has game_role
|
||||
const gameNotif = notification as notifications.GameNotification;
|
||||
if (gameNotif.game_role && this.ignore_addresses.has(gameNotif.game_role)) {
|
||||
return this.ignore_addresses.get(gameNotif.game_role)!.has(notification.token);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async _notify(notification: notifications.AbstractNotification): Promise<void> {
|
||||
// Assume this.server.users is an instance of the Users class previously defined
|
||||
const connection_handler = (this.server.users as Users).get_connection_handler(notification.token);
|
||||
|
||||
if (!this.ignores(notification) && connection_handler) {
|
||||
const translated_notifications = (connection_handler as ConnectionHandler).translate_notification(notification);
|
||||
if (translated_notifications) {
|
||||
for (const translated_notification of translated_notifications) {
|
||||
// Assuming server.notifications.put is async or can be awaited if it returns a Promise
|
||||
await (this.server.notifications as any).put([connection_handler, translated_notification]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _notify_game(server_game: ServerGame, notification_class: notifications.GameNotificationConstructor, options: Record<string, any> = {}): Promise<void> {
|
||||
for (const [game_role, token] of server_game.get_reception_addresses()) {
|
||||
await this._notify(new notification_class({
|
||||
token,
|
||||
game_id: server_game.game_id,
|
||||
game_role,
|
||||
...options,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private async _notify_power(game_id: string, power: Power, notification_class: notifications.GameNotificationConstructor, options: Record<string, any> = {}): Promise<void> {
|
||||
for (const token of power.tokens) {
|
||||
await this._notify(new notification_class({
|
||||
token,
|
||||
game_id,
|
||||
game_role: power.name,
|
||||
...options,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public async notify_game_processed(server_game: ServerGame, previous_phase_data: GamePhaseData, current_phase_data: GamePhaseData): Promise<void> {
|
||||
// Notify observers and omniscient observers
|
||||
for (const [game_role, token] of server_game.get_observer_addresses()) { // Simple observers
|
||||
await this._notify(new notifications.GameProcessed({
|
||||
token,
|
||||
game_id: server_game.game_id,
|
||||
game_role,
|
||||
previous_phase_data: server_game.filter_phase_data(previous_phase_data, OBSERVER_TYPE, false),
|
||||
current_phase_data: server_game.filter_phase_data(current_phase_data, OBSERVER_TYPE, true),
|
||||
}));
|
||||
}
|
||||
for (const [game_role, token] of server_game.get_omniscient_addresses()) {
|
||||
await this._notify(new notifications.GameProcessed({
|
||||
token,
|
||||
game_id: server_game.game_id,
|
||||
game_role, // should be OMNISCIENT_TYPE effectively
|
||||
previous_phase_data: server_game.filter_phase_data(previous_phase_data, OMNISCIENT_TYPE, false),
|
||||
current_phase_data: server_game.filter_phase_data(current_phase_data, OMNISCIENT_TYPE, true),
|
||||
}));
|
||||
}
|
||||
// Notify powers
|
||||
for (const power of Object.values(server_game.powers)) {
|
||||
await this._notify_power(server_game.game_id, power, notifications.GameProcessed, {
|
||||
previous_phase_data: server_game.filter_phase_data(previous_phase_data, power.name, false),
|
||||
current_phase_data: server_game.filter_phase_data(current_phase_data, power.name, true),
|
||||
});
|
||||
}
|
||||
// Notify wait flags
|
||||
for (const power of Object.values(server_game.powers)) {
|
||||
await this.notify_power_wait_flag(server_game, power, power.wait);
|
||||
}
|
||||
}
|
||||
|
||||
public async notify_account_deleted(username: string): Promise<void> {
|
||||
const userTokens = (this.server.users as Users).get_tokens(username);
|
||||
for (const token_to_notify of userTokens) {
|
||||
await this._notify(new notifications.AccountDeleted({ token: token_to_notify }));
|
||||
}
|
||||
}
|
||||
|
||||
public async notify_game_deleted(server_game: ServerGame): Promise<void> {
|
||||
await this._notify_game(server_game, notifications.GameDeleted);
|
||||
}
|
||||
|
||||
public async notify_game_powers_controllers(server_game: ServerGame): Promise<void> {
|
||||
await this._notify_game(server_game, notifications.PowersControllers, {
|
||||
powers: server_game.get_controllers(), // Assuming get_controllers exists on ServerGame
|
||||
timestamps: server_game.get_controllers_timestamps(), // Assuming this exists
|
||||
});
|
||||
}
|
||||
|
||||
public async notify_game_status(server_game: ServerGame): Promise<void> {
|
||||
await this._notify_game(server_game, notifications.GameStatusUpdate, { status: server_game.status });
|
||||
}
|
||||
|
||||
public async notify_game_phase_data(server_game: ServerGame): Promise<void> {
|
||||
const phase_data = server_game.get_phase_data(server_game.current_short_phase); // get current phase data
|
||||
const state_type = GAME; // In Python, this was strings.STATE, assuming GAME is the equivalent level
|
||||
|
||||
// Notify omniscient
|
||||
await this.notify_game_addresses(server_game.game_id, Array.from(server_game.get_omniscient_addresses()), notifications.GamePhaseUpdate, {
|
||||
phase_data: server_game.filter_phase_data(phase_data, OMNISCIENT_TYPE, true),
|
||||
phase_data_type: state_type,
|
||||
});
|
||||
// Notify observers
|
||||
await this.notify_game_addresses(server_game.game_id, Array.from(server_game.get_observer_addresses()), notifications.GamePhaseUpdate, {
|
||||
phase_data: server_game.filter_phase_data(phase_data, OBSERVER_TYPE, true),
|
||||
phase_data_type: state_type,
|
||||
});
|
||||
// Notify powers
|
||||
for (const power_name of server_game.get_map_power_names()) {
|
||||
await this.notify_game_addresses(server_game.game_id, Array.from(server_game.get_power_addresses(power_name)), notifications.GamePhaseUpdate, {
|
||||
phase_data: server_game.filter_phase_data(phase_data, power_name, true),
|
||||
phase_data_type: state_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async notify_game_vote_updated(server_game: ServerGame): Promise<void> {
|
||||
// Notify observers
|
||||
for (const [game_role, token] of server_game.get_observer_addresses()) {
|
||||
await this._notify(new notifications.VoteCountUpdated({
|
||||
token, game_id: server_game.game_id, game_role,
|
||||
count_voted: server_game.count_voted(),
|
||||
count_expected: server_game.count_controlled_powers()
|
||||
}));
|
||||
}
|
||||
// Notify omniscient
|
||||
for (const [game_role, token] of server_game.get_omniscient_addresses()) {
|
||||
await this._notify(new notifications.VoteUpdated({
|
||||
token, game_id: server_game.game_id, game_role,
|
||||
vote: Object.fromEntries(Object.values(server_game.powers).map(p => [p.name, p.vote]))
|
||||
}));
|
||||
}
|
||||
// Notify each power
|
||||
for (const power of Object.values(server_game.powers)) {
|
||||
await this._notify_power(server_game.game_id, power, notifications.PowerVoteUpdated, {
|
||||
count_voted: server_game.count_voted(),
|
||||
count_expected: server_game.count_controlled_powers(),
|
||||
vote: power.vote
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async notify_power_orders_update(server_game: ServerGame, power: Power, orders: string[]): Promise<void> {
|
||||
await this._notify_power(server_game.game_id, power, notifications.PowerOrdersUpdate, {
|
||||
power_name: power.name, orders
|
||||
});
|
||||
const observer_addresses = [
|
||||
...Array.from(server_game.get_omniscient_addresses()),
|
||||
...Array.from(server_game.get_observer_addresses())
|
||||
];
|
||||
await this.notify_game_addresses(server_game.game_id, observer_addresses, notifications.PowerOrdersUpdate, {
|
||||
power_name: power.name, orders
|
||||
});
|
||||
|
||||
const other_powers_addresses: Array<[string, string]> = [];
|
||||
Object.keys(server_game.powers).forEach(other_power_name => {
|
||||
if (other_power_name !== power.name) {
|
||||
other_powers_addresses.push(...Array.from(server_game.get_power_addresses(other_power_name)));
|
||||
}
|
||||
});
|
||||
await this.notify_game_addresses(server_game.game_id, other_powers_addresses, notifications.PowerOrdersFlag, {
|
||||
power_name: power.name, order_is_set: power.is_order_set() // Assuming is_order_set exists
|
||||
});
|
||||
}
|
||||
|
||||
public async notify_power_wait_flag(server_game: ServerGame, power: Power, wait_flag: boolean): Promise<void> {
|
||||
await this._notify_game(server_game, notifications.PowerWaitFlag, { power_name: power.name, wait: wait_flag });
|
||||
}
|
||||
|
||||
public async notify_cleared_orders(server_game: ServerGame, power_name: string | null): Promise<void> {
|
||||
await this._notify_game(server_game, notifications.ClearedOrders, { power_name });
|
||||
}
|
||||
|
||||
public async notify_cleared_units(server_game: ServerGame, power_name: string | null): Promise<void> {
|
||||
await this._notify_game(server_game, notifications.ClearedUnits, { power_name });
|
||||
}
|
||||
|
||||
public async notify_cleared_centers(server_game: ServerGame, power_name: string | null): Promise<void> {
|
||||
await this._notify_game(server_game, notifications.ClearedCenters, { power_name });
|
||||
}
|
||||
|
||||
public async notify_game_message(server_game: ServerGame, game_message: Message): Promise<void> {
|
||||
if (game_message.is_global()) { // Assuming is_global method on Message
|
||||
await this._notify_game(server_game, notifications.GameMessageReceived, { message: game_message });
|
||||
} else {
|
||||
const power_from = server_game.powers[game_message.sender];
|
||||
const power_to = server_game.powers[game_message.recipient];
|
||||
if (power_from) {
|
||||
await this._notify_power(server_game.game_id, power_from, notifications.GameMessageReceived, { message: game_message });
|
||||
}
|
||||
if (power_to && power_to !== power_from) { // Avoid double sending if sender is also recipient (though unlikely for P2P)
|
||||
await this._notify_power(server_game.game_id, power_to, notifications.GameMessageReceived, { message: game_message });
|
||||
}
|
||||
for (const [game_role, token] of server_game.get_omniscient_addresses()) {
|
||||
await this._notify(new notifications.GameMessageReceived({
|
||||
token, game_id: server_game.game_id, game_role, message: game_message
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async notify_game_addresses(game_id: string, addresses: Array<[string, string]>, notification_class: notifications.GameNotificationConstructor, options: Record<string, any> = {}): Promise<void> {
|
||||
for (const [game_role, token] of addresses) {
|
||||
await this._notify(new notification_class({
|
||||
token,
|
||||
game_id,
|
||||
game_role,
|
||||
...options
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
199
diplomacy/server/request_manager_utils.ts
Normal file
199
diplomacy/server/request_manager_utils.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// diplomacy/server/request_manager_utils.ts
|
||||
|
||||
import * as notifications from '../../communication/notifications';
|
||||
import { Notifier } from './notifier';
|
||||
import { ServerGame, DiplomacyServerInterface } from './server_game'; // Assuming DiplomacyServerInterface is defined here or centrally
|
||||
import { User } from './user'; // May not be directly needed, but server.users will be of Users type
|
||||
import { Users } from './users'; // For server.users type
|
||||
import { ConnectionHandler } from './notifier'; // Using placeholder from notifier for now
|
||||
|
||||
import { GAME as GAME_LEVEL, CHANNEL as CHANNEL_LEVEL, OBSERVER_TYPE, OMNISCIENT_TYPE, MASTER_TYPE } from '../utils/strings';
|
||||
import {
|
||||
DiplomacyException, ResponseException, GameTokenException, MapPowerException,
|
||||
GameMasterTokenException, GameFinishedException
|
||||
} from '../utils/exceptions';
|
||||
import { AbstractRequest, AbstractGameRequest } from '../../communication/requests'; // Assuming base request types
|
||||
|
||||
export interface SynchronizedData {
|
||||
timestamp: number;
|
||||
order: number; // 0 for message, 1 for state_history, 2 for current state
|
||||
type: 'message' | 'state_history' | 'state';
|
||||
data: any; // Message, GameState, etc.
|
||||
}
|
||||
|
||||
// Static sort function for SynchronizedData if needed elsewhere
|
||||
export function sortSynchronizedData(a: SynchronizedData, b: SynchronizedData): number {
|
||||
if (a.timestamp !== b.timestamp) {
|
||||
return a.timestamp - b.timestamp;
|
||||
}
|
||||
return a.order - b.order;
|
||||
}
|
||||
|
||||
export type ActionLevel = 'power' | 'observer' | 'omniscient' | 'master';
|
||||
|
||||
export class GameRequestLevel {
|
||||
public game: ServerGame;
|
||||
public power_name: string | null; // Name of the power the request pertains to, if any
|
||||
private __action_level: ActionLevel;
|
||||
|
||||
constructor(game: ServerGame, action_level: ActionLevel, power_name: string | null = null) {
|
||||
this.game = game;
|
||||
this.__action_level = action_level;
|
||||
this.power_name = power_name;
|
||||
}
|
||||
|
||||
public is_power(): boolean { return this.__action_level === 'power'; }
|
||||
public is_observer(): boolean { return this.__action_level === 'observer'; }
|
||||
public is_omniscient(): boolean { return this.__action_level === 'omniscient'; }
|
||||
public is_master(): boolean { return this.__action_level === 'master'; }
|
||||
public get action_level(): ActionLevel { return this.__action_level; }
|
||||
|
||||
|
||||
public static power_level(game: ServerGame, power_name: string): GameRequestLevel {
|
||||
return new GameRequestLevel(game, 'power', power_name);
|
||||
}
|
||||
public static observer_level(game: ServerGame, power_name: string | null = null): GameRequestLevel {
|
||||
return new GameRequestLevel(game, 'observer', power_name);
|
||||
}
|
||||
public static omniscient_level(game: ServerGame, power_name: string | null = null): GameRequestLevel {
|
||||
return new GameRequestLevel(game, 'omniscient', power_name);
|
||||
}
|
||||
public static master_level(game: ServerGame, power_name: string | null = null): GameRequestLevel {
|
||||
return new GameRequestLevel(game, 'master', power_name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies request token, game role, and rights.
|
||||
* Returns a GameRequestLevel for game requests, else null.
|
||||
*/
|
||||
export async function verify_request(
|
||||
server: DiplomacyServerInterface, // Server object should have users, get_game, assert_token methods
|
||||
request: AbstractRequest, // Base request type
|
||||
connection_handler: ConnectionHandler, // Placeholder
|
||||
omniscient_role: boolean = true,
|
||||
observer_role: boolean = true,
|
||||
power_role: boolean = true,
|
||||
require_power: boolean = false,
|
||||
require_master: boolean = true
|
||||
): Promise<GameRequestLevel | null> {
|
||||
|
||||
if (!request.level) { // Connection requests like SignIn
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check token for channel and game requests.
|
||||
// server.assert_token would throw if invalid.
|
||||
// Assuming assert_token is part of the DiplomacyServerInterface or its Users object
|
||||
(server.users as Users).relaunch_token(request.token!); // Relaunch before check (Python version does this in assert_token)
|
||||
if (!(server.users as Users).token_is_alive(request.token!)) {
|
||||
(server.users as Users).disconnect_token(request.token!); // Disconnect if expired
|
||||
throw new GameTokenException('Token has expired or is invalid.');
|
||||
}
|
||||
// Associate connection handler if not already (Python's assert_token might do this)
|
||||
(server.users as Users).attach_connection_handler(request.token!, connection_handler);
|
||||
|
||||
|
||||
if (request.level !== GAME_LEVEL) { // Not a game-specific request (e.g. CHANNEL_LEVEL)
|
||||
return null;
|
||||
}
|
||||
|
||||
// It's a game request, so it should conform to AbstractGameRequest
|
||||
const gameRequest = request as AbstractGameRequest;
|
||||
if (gameRequest.game_id === undefined || gameRequest.game_role === undefined) {
|
||||
throw new ResponseException("Game request missing game_id or game_role.");
|
||||
}
|
||||
|
||||
const server_game = server.get_game(String(gameRequest.game_id)); // get_game should handle string ID
|
||||
if (!server_game) {
|
||||
throw new ResponseException(`Game ${gameRequest.game_id} not found.`);
|
||||
}
|
||||
|
||||
const power_name_from_request = gameRequest.power_name || null; // Optional in request
|
||||
|
||||
if (gameRequest.game_role === OMNISCIENT_TYPE || gameRequest.game_role === OBSERVER_TYPE) {
|
||||
if (gameRequest.game_role === OMNISCIENT_TYPE) {
|
||||
if (!omniscient_role) throw new ResponseException(`Omniscient role disallowed for request ${request.name}`);
|
||||
if (!server_game.has_omniscient_token(request.token!)) throw new GameTokenException('Token is not an omniscient token for this game.');
|
||||
|
||||
const token_is_master = (server.users as Users).token_is_admin(request.token!) || server_game.is_moderator((server.users as Users).get_name(request.token!)!);
|
||||
if (require_master && !token_is_master) throw new GameMasterTokenException();
|
||||
return token_is_master ?
|
||||
GameRequestLevel.master_level(server_game, power_name_from_request) :
|
||||
GameRequestLevel.omniscient_level(server_game, power_name_from_request);
|
||||
} else { // OBSERVER_TYPE
|
||||
if (!observer_role) throw new ResponseException(`Observer role disallowed for request ${request.name}`);
|
||||
if (!server_game.has_observer_token(request.token!)) throw new GameTokenException('Token is not an observer token for this game.');
|
||||
return GameRequestLevel.observer_level(server_game, power_name_from_request);
|
||||
}
|
||||
|
||||
// Check power_name validity if provided for observer/omniscient roles
|
||||
if (power_name_from_request && !server_game.powers[power_name_from_request]) {
|
||||
throw new MapPowerException(power_name_from_request);
|
||||
}
|
||||
if (require_power && !power_name_from_request){
|
||||
throw new MapPowerException(null); // Power name required but not given
|
||||
}
|
||||
|
||||
} else { // Power role
|
||||
if (!power_role) throw new ResponseException(`Power role disallowed for request ${request.name}`);
|
||||
|
||||
const target_power_name = power_name_from_request || gameRequest.game_role;
|
||||
if (!server_game.powers[target_power_name]) throw new MapPowerException(target_power_name);
|
||||
|
||||
const username = (server.users as Users).get_name(request.token!);
|
||||
if (!username || !server_game.is_controlled_by(target_power_name, username)) {
|
||||
throw new ResponseException(`User ${username} does not control power ${target_power_name}.`);
|
||||
}
|
||||
return GameRequestLevel.power_level(server_game, target_power_name);
|
||||
}
|
||||
return null; // Should not be reached if logic is correct
|
||||
}
|
||||
|
||||
export async function transfer_special_tokens(
|
||||
server_game: ServerGame,
|
||||
server: DiplomacyServerInterface, // Server object
|
||||
username: string,
|
||||
grade_update: string, // e.g. 'PROMOTE' or 'DEMOTE' from strings
|
||||
from_observation: boolean = true
|
||||
): Promise<void> {
|
||||
const old_role = from_observation ? OBSERVER_TYPE : OMNISCIENT_TYPE;
|
||||
const new_role = from_observation ? OMNISCIENT_TYPE : OBSERVER_TYPE;
|
||||
const token_filter = from_observation ?
|
||||
(token: string) => server_game.has_observer_token(token) :
|
||||
(token: string) => server_game.has_omniscient_token(token);
|
||||
|
||||
const userTokens = (server.users as Users).get_tokens(username);
|
||||
const connected_user_tokens: string[] = [];
|
||||
userTokens.forEach(user_token => {
|
||||
if (token_filter(user_token)) {
|
||||
connected_user_tokens.push(user_token);
|
||||
}
|
||||
});
|
||||
|
||||
if (connected_user_tokens.length > 0) {
|
||||
for (const user_token of connected_user_tokens) {
|
||||
server_game.transfer_special_token(user_token); // Assuming this method exists on ServerGame
|
||||
}
|
||||
|
||||
const addresses: Array<[string, string]> = connected_user_tokens.map(token => [old_role, token]);
|
||||
// Notifier needs to be instantiated or accessed via server
|
||||
// Assuming server has a notifier instance or Notifier can be newed up with server
|
||||
const notifier = new Notifier(server);
|
||||
await notifier.notify_game_addresses(
|
||||
server_game.game_id,
|
||||
addresses,
|
||||
notifications.OmniscientUpdated, // Ensure this notification type is defined
|
||||
{
|
||||
grade_update,
|
||||
game: server_game.cast(new_role, username) // cast returns a game view for the new role
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assert_game_not_finished(server_game: ServerGame): void {
|
||||
if (server_game.is_game_completed || server_game.is_game_canceled) { // is_game_canceled might need to be added to ServerGame
|
||||
throw new GameFinishedException("Game is finished or canceled.");
|
||||
}
|
||||
}
|
||||
252
diplomacy/server/request_managers.ts
Normal file
252
diplomacy/server/request_managers.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// diplomacy/server/request_managers.ts
|
||||
|
||||
import {
|
||||
AbstractRequest, ClearCentersRequest, ClearOrdersRequest, ClearUnitsRequest, CreateGameRequest,
|
||||
DeleteAccountRequest, DeleteGameRequest, GetAllPossibleOrdersRequest, GetAvailableMapsRequest,
|
||||
GetDaidePortRequest, GetDummyWaitingPowersRequest, GetGamesInfoRequest, GetPhaseHistoryRequest,
|
||||
GetPlayablePowersRequest, JoinGameRequest, JoinPowersRequest, LeaveGameRequest, ListGamesRequest,
|
||||
LogoutRequest, ProcessGameRequest, QueryScheduleRequest, SaveGameRequest, SendGameMessageRequest,
|
||||
SetDummyPowersRequest, SetGameStateRequest, SetGameStatusRequest, SetGradeRequest, SetOrdersRequest,
|
||||
SetWaitFlagRequest, SignInRequest, SynchronizeRequest, UnknownTokenRequest, VoteRequest,
|
||||
// Add other request types as they are defined
|
||||
} from '../../communication/requests'; // Adjust path as necessary
|
||||
import * as responses from '../../communication/responses';
|
||||
import * as notifications from '../../communication/notifications';
|
||||
import { Notifier, ConnectionHandler } from './notifier'; // ConnectionHandler might be a placeholder
|
||||
import { ServerGame, DiplomacyServerInterface } from './server_game';
|
||||
import {
|
||||
verify_request, transfer_special_tokens, assert_game_not_finished, GameRequestLevel, SynchronizedData, sortSynchronizedData
|
||||
} from './request_manager_utils';
|
||||
import {
|
||||
DiplomacyException, ResponseException, GameIdException, MapIdException, MapPowerException,
|
||||
GameSolitaireException, GameCreationException, UserException, PasswordException,
|
||||
ServerRegistrationException, GameJoinRoleException, GameObserverException,
|
||||
GameRegistrationPasswordException, GameFinishedException, DaidePortException, GameCanceledException, GamePhaseException
|
||||
} from '../utils/exceptions'; // Adjust path
|
||||
import {
|
||||
GAME as GAME_LEVEL, CHANNEL as CHANNEL_LEVEL, OBSERVER_TYPE, OMNISCIENT_TYPE, MASTER_TYPE,
|
||||
FORMING, ACTIVE, PAUSED, COMPLETED, CANCELED, PRIVATE_BOT_USERNAME,
|
||||
PROMOTE, DEMOTE, ADMIN // Assuming these are in strings
|
||||
} from '../utils/strings'; // Adjust path
|
||||
import { OrderSettings } from '../utils/constants'; // Adjust path
|
||||
import { GamePhaseData } from '../utils/game_phase_data'; // Adjust path
|
||||
import { hash_password } from '../utils/common'; // Adjust path
|
||||
import { exportJson } from '../utils/export'; // Assuming export.ts for to_saved_game_format
|
||||
|
||||
// --- Request Handler Function Types ---
|
||||
type RequestHandler<T extends AbstractRequest> =
|
||||
(server: DiplomacyServerInterface, request: T, connection_handler: ConnectionHandler) => Promise<responses.AbstractResponse | null | void>;
|
||||
|
||||
// --- Request Handlers ---
|
||||
|
||||
async function on_clear_centers(server: DiplomacyServerInterface, request: ClearCentersRequest, connection_handler: ConnectionHandler): Promise<void> {
|
||||
const level = await verify_request(server, request, connection_handler, { observer_role: false });
|
||||
if (!level) throw new ResponseException("Invalid request level for ClearCenters.");
|
||||
assert_game_not_finished(level.game);
|
||||
level.game.clear_centers(level.power_name); // power_name should be asserted as non-null by verify_request if power_role=true
|
||||
const notifier = new Notifier(server, { ignore_addresses: [[request.game_role!, request.token!]] }); // game_role and token must exist
|
||||
await notifier.notify_cleared_centers(level.game, level.power_name);
|
||||
}
|
||||
|
||||
async function on_clear_orders(server: DiplomacyServerInterface, request: ClearOrdersRequest, connection_handler: ConnectionHandler): Promise<void> {
|
||||
const level = await verify_request(server, request, connection_handler, { observer_role: false });
|
||||
if (!level) throw new ResponseException("Invalid request level for ClearOrders.");
|
||||
assert_game_not_finished(level.game);
|
||||
if (!request.phase || request.phase !== level.game.current_short_phase) {
|
||||
throw new ResponseException(`Invalid order phase, received ${request.phase}, server phase is ${level.game.current_short_phase}`);
|
||||
}
|
||||
level.game.clear_orders(level.power_name!); // power_name is guaranteed by verify_request with default power_role=true
|
||||
const notifier = new Notifier(server, { ignore_addresses: [[request.game_role!, request.token!]] });
|
||||
await notifier.notify_cleared_orders(level.game, level.power_name);
|
||||
}
|
||||
|
||||
async function on_clear_units(server: DiplomacyServerInterface, request: ClearUnitsRequest, connection_handler: ConnectionHandler): Promise<void> {
|
||||
const level = await verify_request(server, request, connection_handler, { observer_role: false });
|
||||
if (!level) throw new ResponseException("Invalid request level for ClearUnits.");
|
||||
assert_game_not_finished(level.game);
|
||||
level.game.clear_units(level.power_name);
|
||||
const notifier = new Notifier(server, { ignore_addresses: [[request.game_role!, request.token!]] });
|
||||
await notifier.notify_cleared_units(level.game, level.power_name);
|
||||
}
|
||||
|
||||
async function on_create_game(server: DiplomacyServerInterface, request: CreateGameRequest, connection_handler: ConnectionHandler): Promise<responses.DataGame> {
|
||||
await verify_request(server, request, connection_handler); // Verifies token
|
||||
let { game_id, token, power_name, state, map_name, rules, n_controls, deadline, registration_password } = request;
|
||||
token = token!; // Token is asserted by verify_request for non-connection requests
|
||||
|
||||
if (server.cannot_create_more_games()) { // Method to be implemented on server
|
||||
throw new GameCreationException("Server cannot create more games.");
|
||||
}
|
||||
|
||||
const gameMap = server.get_map(map_name); // Method to be implemented on server
|
||||
if (!gameMap) {
|
||||
throw new MapIdException(`Map ${map_name} not found.`);
|
||||
}
|
||||
|
||||
rules = rules || ['NO_PRESS', 'POWER_CHOICE']; // SERVER_GAME_RULES equivalent
|
||||
|
||||
if (rules.includes('SOLITAIRE') && power_name != null) {
|
||||
throw new GameSolitaireException("Cannot specify power_name for SOLITAIRE games.");
|
||||
}
|
||||
if (power_name != null && !gameMap.powers.includes(power_name.toUpperCase())) { // map.powers should be UC
|
||||
throw new MapPowerException(power_name);
|
||||
}
|
||||
|
||||
const username = server.users.get_name(token)!;
|
||||
if (game_id == null || game_id === '') {
|
||||
game_id = server.create_game_id(); // Method to be implemented on server
|
||||
} else if (server.has_game_id(game_id)) { // Method to be implemented on server
|
||||
throw new GameIdException(`Game ID already used: ${game_id}`);
|
||||
}
|
||||
|
||||
const server_game = new ServerGame({ // Assumes ServerGame constructor matches this
|
||||
map_name: map_name,
|
||||
rules: rules,
|
||||
game_id: game_id,
|
||||
initial_state: state, // state needs to be compatible with DiplomacyGameOptions
|
||||
n_controls: n_controls,
|
||||
deadline: deadline,
|
||||
registration_password: registration_password,
|
||||
server: server
|
||||
});
|
||||
|
||||
if (!server.users.has_admin(username)) {
|
||||
server_game.promote_moderator(username);
|
||||
}
|
||||
server.add_new_game(server_game); // Method to be implemented on server
|
||||
|
||||
let client_game_view: DiplomacyGame;
|
||||
if (power_name) {
|
||||
server_game.control(power_name, username, token);
|
||||
client_game_view = server_game.as_power_game(power_name);
|
||||
} else {
|
||||
server_game.add_omniscient_token(token);
|
||||
client_game_view = server_game.as_omniscient_game(username);
|
||||
}
|
||||
|
||||
if (server_game.game_can_start()) {
|
||||
await server.start_game(server_game); // Method to be implemented on server
|
||||
}
|
||||
await server.save_game(server_game); // Method to be implemented on server
|
||||
|
||||
return new responses.DataGame({data: client_game_view, request_id: request.request_id});
|
||||
}
|
||||
|
||||
async function on_sign_in(server: DiplomacyServerInterface, request: SignInRequest, connection_handler: ConnectionHandler): Promise<responses.DataToken> {
|
||||
const { username, password } = request;
|
||||
if (!username) throw new UserException("Username is required.");
|
||||
if (!password) throw new PasswordException("Password is required.");
|
||||
|
||||
if (!server.users.has_username(username)) {
|
||||
if (!server.properties.allow_registrations) { // Assuming server.properties.allow_registrations
|
||||
throw new ServerRegistrationException("Server registrations are disabled.");
|
||||
}
|
||||
const passwordHash = await hash_password(password); // Assuming hash_password from common.ts
|
||||
server.users.add_user(username, passwordHash);
|
||||
} else if (!server.users.has_user(username, password)) {
|
||||
throw new UserException("Invalid username or password.");
|
||||
}
|
||||
|
||||
const token = server.users.connect_user(username, connection_handler);
|
||||
await server.save_data(); // Method to be implemented on server
|
||||
return new responses.DataToken({ data: token, request_id: request.request_id });
|
||||
}
|
||||
|
||||
// ... other handlers will be translated similarly ...
|
||||
// For brevity, I'll include a few more key handlers and then the MAPPING and handle_request
|
||||
|
||||
async function on_set_orders(server: DiplomacyServerInterface, request: SetOrdersRequest, connection_handler: ConnectionHandler): Promise<void> {
|
||||
const level = await verify_request(server, request, connection_handler, {observer_role: false, require_power: true});
|
||||
if (!level || !level.power_name) throw new ResponseException("Invalid request level for SetOrders.");
|
||||
assert_game_not_finished(level.game);
|
||||
|
||||
if (!request.phase || request.phase !== level.game.current_short_phase) {
|
||||
throw new ResponseException(`Invalid order phase, received ${request.phase}, server phase is ${level.game.current_short_phase}`);
|
||||
}
|
||||
const power = level.game.powers[level.power_name];
|
||||
if (!power) throw new MapPowerException(level.power_name);
|
||||
|
||||
const previous_wait = power.wait;
|
||||
power.clear_orders();
|
||||
power.wait = previous_wait;
|
||||
level.game.set_orders(level.power_name, request.orders); // This is from engine/game.ts
|
||||
|
||||
const notifier = new Notifier(server, {ignore_addresses: [[request.game_role!, request.token!]]});
|
||||
await notifier.notify_power_orders_update(level.game, power, request.orders);
|
||||
|
||||
if (request.wait !== undefined && request.wait !== null) {
|
||||
level.game.set_wait(level.power_name, request.wait); // This is from engine/game.ts
|
||||
await notifier.notify_power_wait_flag(level.game, power, request.wait);
|
||||
}
|
||||
if (level.game.does_not_wait()) { // This is from engine/game.ts
|
||||
await server.force_game_processing(level.game); // Method to be implemented on server
|
||||
}
|
||||
await server.save_game(level.game);
|
||||
}
|
||||
|
||||
|
||||
// --- MAPPING ---
|
||||
// Using a Map for type safety with class constructors as keys
|
||||
const MAPPING = new Map<any, RequestHandler<any>>([
|
||||
[ClearCentersRequest, on_clear_centers],
|
||||
[ClearOrdersRequest, on_clear_orders],
|
||||
[ClearUnitsRequest, on_clear_units],
|
||||
[CreateGameRequest, on_create_game],
|
||||
[SignInRequest, on_sign_in],
|
||||
[SetOrdersRequest, on_set_orders],
|
||||
// [DeleteAccountRequest, onDeleteAccount],
|
||||
// [DeleteGameRequest, onDeleteGame],
|
||||
// [GetAllPossibleOrdersRequest, onGetAllPossibleOrders],
|
||||
// [GetAvailableMapsRequest, onGetAvailableMaps],
|
||||
// [GetDaidePortRequest, onGetDaidePort],
|
||||
// [GetDummyWaitingPowersRequest, onGetDummyWaitingPowers],
|
||||
// [GetGamesInfoRequest, onGetGamesInfo],
|
||||
// [GetPhaseHistoryRequest, onGetPhaseHistory],
|
||||
// [GetPlayablePowersRequest, onGetPlayablePowers],
|
||||
// [JoinGameRequest, onJoinGame],
|
||||
// [JoinPowersRequest, onJoinPowers],
|
||||
// [LeaveGameRequest, onLeaveGame],
|
||||
// [ListGamesRequest, onListGames],
|
||||
// [LogoutRequest, onLogout],
|
||||
// [ProcessGameRequest, onProcessGame],
|
||||
// [QueryScheduleRequest, onQuerySchedule],
|
||||
// [SaveGameRequest, onSaveGame],
|
||||
// [SendGameMessageRequest, onSendGameMessage],
|
||||
// [SetDummyPowersRequest, onSetDummyPowers],
|
||||
// [SetGameStateRequest, onSetGameState],
|
||||
// [SetGameStatusRequest, onSetGameStatus],
|
||||
// [SetGradeRequest, onSetGrade],
|
||||
// [SetWaitFlagRequest, onSetWaitFlag],
|
||||
// [SynchronizeRequest, onSynchronize],
|
||||
// [UnknownTokenRequest, onUnknownToken],
|
||||
// [VoteRequest, onVote],
|
||||
]);
|
||||
|
||||
export async function handle_request(
|
||||
server: DiplomacyServerInterface,
|
||||
request: AbstractRequest,
|
||||
connection_handler: ConnectionHandler
|
||||
): Promise<responses.AbstractResponse | null | void> {
|
||||
const RequestClass = request.constructor;
|
||||
const request_handler_fn = MAPPING.get(RequestClass);
|
||||
|
||||
if (!request_handler_fn) {
|
||||
console.error(`No handler found for request type: ${request.name || RequestClass.name}`);
|
||||
throw new exceptions.RequestException(`No handler for request type ${request.name || RequestClass.name}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// All handlers are now async
|
||||
return await request_handler_fn(server, request, connection_handler);
|
||||
} catch (exc: any) {
|
||||
if (exc instanceof DiplomacyException) {
|
||||
// Specific diplomacy exceptions can be re-thrown if they are meant to be caught by a higher level
|
||||
// or converted to specific error responses.
|
||||
// For now, re-throw and let the server's main error handler deal with it.
|
||||
throw exc;
|
||||
}
|
||||
// Generic error
|
||||
console.error(`Error processing request ${request.name}:`, exc);
|
||||
throw new exceptions.ResponseException(`Internal server error while processing ${request.name}: ${exc.message}`);
|
||||
}
|
||||
}
|
||||
314
diplomacy/server/scheduler.ts
Normal file
314
diplomacy/server/scheduler.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
// diplomacy/server/scheduler.ts
|
||||
|
||||
import { SchedulerEvent } from '../utils/scheduler_event';
|
||||
import { NaturalIntegerException, NaturalIntegerNotNullException, AlreadyScheduledException } from '../utils/exceptions';
|
||||
import { PriorityDict } from '../utils/priority_dict'; // Assuming this is a MinPriorityQueue
|
||||
|
||||
// --- Helper for async sleep ---
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// --- Basic Async Mutex ---
|
||||
class AsyncMutex {
|
||||
private locked = false;
|
||||
private queue: (() => void)[] = [];
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
resolve();
|
||||
} else {
|
||||
this.queue.push(resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
if (this.queue.length > 0) {
|
||||
const nextResolve = this.queue.shift();
|
||||
if (nextResolve) {
|
||||
// This keeps the lock acquired for the next in queue
|
||||
nextResolve();
|
||||
} else {
|
||||
this.locked = false; // Should not happen if queue had items
|
||||
}
|
||||
} else {
|
||||
this.locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
async withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Deadline {
|
||||
public start_time: number;
|
||||
public delay: number;
|
||||
|
||||
constructor(start_time: number, delay: number) {
|
||||
this.start_time = start_time;
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
get deadline(): number {
|
||||
return this.start_time + this.delay;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Deadline(${this.start_time} + ${this.delay} = ${this.deadline})`;
|
||||
}
|
||||
|
||||
// For PriorityDict comparison: lower deadline value means higher priority
|
||||
valueOf(): number {
|
||||
return this.deadline;
|
||||
}
|
||||
}
|
||||
|
||||
class Task {
|
||||
public data: any;
|
||||
public deadline: Deadline;
|
||||
public valid: boolean = true;
|
||||
|
||||
constructor(data: any, deadline: Deadline) {
|
||||
this.data = data;
|
||||
this.deadline = deadline;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.constructor.name}(${this.data?.constructor?.name || typeof this.data}, ${this.deadline})`;
|
||||
}
|
||||
|
||||
update_delay(new_delay: number): void {
|
||||
this.deadline.delay = new_delay;
|
||||
}
|
||||
}
|
||||
|
||||
class ImmediateTask extends Task {
|
||||
private validator: () => boolean;
|
||||
|
||||
constructor(data: any, future_delay: number, processing_validator: boolean | ((data: any) => boolean)) {
|
||||
super(data, new Deadline(-future_delay, future_delay)); // deadline effectively 0 for first processing
|
||||
if (typeof processing_validator === 'boolean') {
|
||||
this.validator = () => processing_validator;
|
||||
} else if (typeof processing_validator === 'function') {
|
||||
this.validator = () => processing_validator(data);
|
||||
} else {
|
||||
throw new Error('Validator for immediate task must be either a boolean or a callback(data).');
|
||||
}
|
||||
}
|
||||
|
||||
can_still_process(): boolean {
|
||||
return this.validator();
|
||||
}
|
||||
|
||||
override update_delay(new_delay: number): void {
|
||||
this.deadline.start_time = -new_delay; // Ensure deadline is 0 if new_delay is 0 for next immediate processing
|
||||
this.deadline.delay = new_delay;
|
||||
}
|
||||
}
|
||||
|
||||
export class Scheduler {
|
||||
public unit: number; // unit_in_seconds
|
||||
public current_time: number = 0;
|
||||
private callback_process: (data: any) => Promise<boolean> | boolean;
|
||||
private data_in_heap: PriorityDict<any, Deadline>; // data => Deadline
|
||||
private data_in_queue: Map<any, Task>; // data => Task in queue
|
||||
private tasks_queue: Task[] = []; // Simple array as a queue
|
||||
private lock: AsyncMutex = new AsyncMutex();
|
||||
private processing: boolean = false; // To control process_tasks loop
|
||||
private scheduling: boolean = false; // To control schedule loop
|
||||
|
||||
constructor(unit_in_seconds: number, callback_process: (data: any) => Promise<boolean> | boolean) {
|
||||
if (!(typeof unit_in_seconds === 'number' && unit_in_seconds > 0 && Number.isInteger(unit_in_seconds))) {
|
||||
throw new Error("unit_in_seconds must be a positive integer.");
|
||||
}
|
||||
if (typeof callback_process !== 'function') {
|
||||
throw new Error("callback_process must be a function.");
|
||||
}
|
||||
this.unit = unit_in_seconds;
|
||||
this.callback_process = callback_process;
|
||||
this.data_in_heap = new PriorityDict<any, Deadline>();
|
||||
this.data_in_queue = new Map<any, Task>();
|
||||
}
|
||||
|
||||
private _enqueue(task: Task): void {
|
||||
this.data_in_queue.set(task.data, task);
|
||||
this.tasks_queue.push(task);
|
||||
// If process_tasks is not running, kick it off (non-blocking)
|
||||
if (!this.processing && this.scheduling) { // Only start processing if scheduler is active
|
||||
this.process_tasks().catch(err => console.error("Error in process_tasks loop:", err));
|
||||
}
|
||||
}
|
||||
|
||||
public async has_data(data: any): Promise<boolean> {
|
||||
return this.lock.withLock(async () => {
|
||||
return this.data_in_heap.has(data) || this.data_in_queue.has(data);
|
||||
});
|
||||
}
|
||||
|
||||
public async get_info(data: any): Promise<SchedulerEvent | null> {
|
||||
return this.lock.withLock(async () => {
|
||||
let deadline_obj: Deadline | undefined;
|
||||
if (this.data_in_heap.has(data)) {
|
||||
deadline_obj = this.data_in_heap.get(data);
|
||||
} else if (this.data_in_queue.has(data)) {
|
||||
deadline_obj = this.data_in_queue.get(data)!.deadline;
|
||||
}
|
||||
|
||||
if (deadline_obj) {
|
||||
return new SchedulerEvent(this.unit, deadline_obj.start_time, deadline_obj.delay, this.current_time);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public async add_data(data: any, nb_units_to_wait: number): Promise<void> {
|
||||
if (!(Number.isInteger(nb_units_to_wait) && nb_units_to_wait > 0)) {
|
||||
throw new NaturalIntegerNotNullException("nb_units_to_wait must be a positive integer.");
|
||||
}
|
||||
await this.lock.withLock(async () => {
|
||||
if (this.data_in_heap.has(data) || this.data_in_queue.has(data)) {
|
||||
throw new AlreadyScheduledException("Data is already scheduled.");
|
||||
}
|
||||
this.data_in_heap.set(data, new Deadline(this.current_time, nb_units_to_wait));
|
||||
});
|
||||
}
|
||||
|
||||
public async no_wait(data: any, nb_units_to_wait_after_first: number, processing_validator: boolean | ((d: any) => boolean)): Promise<void> {
|
||||
if (!(Number.isInteger(nb_units_to_wait_after_first) && nb_units_to_wait_after_first >= 0)) {
|
||||
throw new NaturalIntegerException("nb_units_to_wait_after_first must be a non-negative integer.");
|
||||
}
|
||||
await this.lock.withLock(async () => {
|
||||
if (this.data_in_heap.has(data)) {
|
||||
this.data_in_heap.delete(data); // Remove from heap
|
||||
this._enqueue(new ImmediateTask(data, nb_units_to_wait_after_first, processing_validator));
|
||||
} else if (this.data_in_queue.has(data)) {
|
||||
const task = this.data_in_queue.get(data)!;
|
||||
task.update_delay(nb_units_to_wait_after_first); // Update future delay
|
||||
} else {
|
||||
this._enqueue(new ImmediateTask(data, nb_units_to_wait_after_first, processing_validator));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async remove_data(data: any): Promise<void> {
|
||||
await this.lock.withLock(async () => {
|
||||
if (this.data_in_heap.has(data)) {
|
||||
this.data_in_heap.delete(data);
|
||||
} else if (this.data_in_queue.has(data)) {
|
||||
const task = this.data_in_queue.get(data)!;
|
||||
task.valid = false; // Mark as invalid, process_tasks will ignore it
|
||||
this.data_in_queue.delete(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _step(): Promise<void> {
|
||||
await this.lock.withLock(async () => {
|
||||
this.current_time += 1;
|
||||
while (!this.data_in_heap.isEmpty()) {
|
||||
const data = this.data_in_heap.peek(); // PriorityDict needs peek or similar
|
||||
const deadline_obj = this.data_in_heap.get(data!)!; // data cannot be undefined if not empty
|
||||
|
||||
if (deadline_obj.deadline > this.current_time) {
|
||||
break;
|
||||
}
|
||||
this.data_in_heap.pop(); // Removes and returns smallest, but we already have it
|
||||
this._enqueue(new Task(data, deadline_obj));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async schedule(): Promise<void> {
|
||||
if (this.scheduling) return; // Prevent multiple loops
|
||||
this.scheduling = true;
|
||||
console.log("Scheduler started.");
|
||||
// Kick off task processing loop if not already running
|
||||
if(!this.processing) {
|
||||
this.process_tasks().catch(err => {
|
||||
console.error("Error in process_tasks supervisor:", err);
|
||||
this.processing = false; // Allow restart
|
||||
});
|
||||
}
|
||||
|
||||
while (this.scheduling) {
|
||||
await sleep(this.unit * 1000);
|
||||
if (!this.scheduling) break; // Check again after sleep
|
||||
await this._step();
|
||||
}
|
||||
console.log("Scheduler stopped.");
|
||||
}
|
||||
|
||||
public async process_tasks(): Promise<void> {
|
||||
if (this.processing) return; // Prevent multiple loops
|
||||
this.processing = true;
|
||||
|
||||
while (this.scheduling || this.tasks_queue.length > 0) { // Process remaining tasks even if scheduling stops
|
||||
if (this.tasks_queue.length === 0) {
|
||||
if (!this.scheduling) break; // Exit if not scheduling and queue is empty
|
||||
await sleep(50); // Short sleep if queue is empty but still scheduling
|
||||
continue;
|
||||
}
|
||||
const task = this.tasks_queue.shift()!;
|
||||
|
||||
try {
|
||||
if (task.valid) { // Check if task was invalidated by remove_data
|
||||
let remove_data_flag = false;
|
||||
if (task instanceof ImmediateTask) {
|
||||
if (!task.can_still_process()) {
|
||||
await this.lock.withLock(async () => { // Ensure atomicity for map modification
|
||||
this.data_in_queue.delete(task.data);
|
||||
});
|
||||
continue; // Skip processing and rescheduling
|
||||
}
|
||||
}
|
||||
|
||||
const result = this.callback_process(task.data);
|
||||
if (typeof result === 'boolean') {
|
||||
remove_data_flag = result;
|
||||
} else {
|
||||
remove_data_flag = await result;
|
||||
}
|
||||
|
||||
remove_data_flag = remove_data_flag || !task.deadline.delay;
|
||||
|
||||
await this.lock.withLock(async () => {
|
||||
// Task might have been removed from data_in_queue by remove_data while callback was processing
|
||||
if (this.data_in_queue.get(task.data) === task) {
|
||||
this.data_in_queue.delete(task.data);
|
||||
}
|
||||
if (!remove_data_flag && task.valid) { // Reschedule if not done and still valid
|
||||
this.data_in_heap.set(task.data, new Deadline(this.current_time, task.deadline.delay));
|
||||
}
|
||||
});
|
||||
} else { // Task was invalidated (removed) while in queue
|
||||
await this.lock.withLock(async () => {
|
||||
this.data_in_queue.delete(task.data); // Ensure it's removed from tracking map
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing task:", task.data, error);
|
||||
// Decide if task should be rescheduled or dropped on error
|
||||
await this.lock.withLock(async () => { // Ensure atomicity for map modification
|
||||
this.data_in_queue.delete(task.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.processing = false;
|
||||
console.log("Task processing loop ended.");
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
console.log("Stopping scheduler...");
|
||||
this.scheduling = false;
|
||||
// Note: This will stop the schedule() loop.
|
||||
// The process_tasks() loop will continue until the tasks_queue is empty.
|
||||
}
|
||||
}
|
||||
316
diplomacy/server/server_game.ts
Normal file
316
diplomacy/server/server_game.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
// diplomacy/server/server_game.ts
|
||||
|
||||
import { DiplomacyGame, DiplomacyGameOptions } from '../../engine/game';
|
||||
import { Message, GLOBAL, OBSERVER, OMNISCIENT, SYSTEM } from '../../engine/message';
|
||||
import { Power } from '../../engine/power'; // Assuming PowerTs is exported as Power
|
||||
import {
|
||||
MODERATOR_USERNAMES, OBSERVER as OBSERVER_STRING_KEY, OMNISCIENT as OMNISCIENT_STRING_KEY,
|
||||
OMNISCIENT_USERNAMES, OBSERVER_TYPE, OMNISCIENT_TYPE, MASTER_TYPE, FORMING, NEUTRAL
|
||||
} from '../utils/strings'; // Adjust path as necessary
|
||||
import {
|
||||
update_model, DefaultValueType, SequenceType, OptionalValueType, JsonableClassType, ParserType
|
||||
} from '../utils/parsing'; // Adjust path as necessary
|
||||
import { GamePhaseData } from '../utils/game_phase_data'; // Adjust path as necessary
|
||||
import { DiplomacyException, ResponseException } from '../utils/exceptions'; // Adjust path as necessary
|
||||
import { JsonableModel } from '../utils/jsonable';
|
||||
|
||||
// Placeholder for the main Server type/interface, to avoid circular dependencies if Server imports ServerGame
|
||||
export interface DiplomacyServerInterface {
|
||||
users: { has_admin: (username: string) => boolean }; // Simplified Users interface for now
|
||||
get_daide_port: (game_id: string) => number | null;
|
||||
// Add other methods/properties of Server that ServerGame might interact with
|
||||
}
|
||||
|
||||
export interface ServerGameOptions extends DiplomacyGameOptions {
|
||||
server?: DiplomacyServerInterface;
|
||||
[MODERATOR_USERNAMES]?: Set<string>;
|
||||
[OBSERVER_STRING_KEY]?: Power;
|
||||
[OMNISCIENT_STRING_KEY]?: Power;
|
||||
[OMNISCIENT_USERNAMES]?: Set<string>;
|
||||
}
|
||||
|
||||
|
||||
export class ServerGame extends DiplomacyGame {
|
||||
public server?: DiplomacyServerInterface;
|
||||
public omniscient_usernames: Set<string>;
|
||||
public moderator_usernames: Set<string>;
|
||||
public observer: Power;
|
||||
public omniscient: Power;
|
||||
|
||||
public static override model: JsonableModel = update_model(DiplomacyGame.model, {
|
||||
[MODERATOR_USERNAMES]: new DefaultValueType(new SequenceType(ParserType.STR, () => new Set<string>()), () => new Set()),
|
||||
[OBSERVER_STRING_KEY]: new OptionalValueType(new JsonableClassType(Power)),
|
||||
[OMNISCIENT_STRING_KEY]: new OptionalValueType(new JsonableClassType(Power)),
|
||||
[OMNISCIENT_USERNAMES]: new DefaultValueType(new SequenceType(ParserType.STR, () => new Set<string>()), () => new Set()),
|
||||
});
|
||||
|
||||
constructor(options: ServerGameOptions = {}) {
|
||||
super(options); // Call base Game constructor
|
||||
this.server = options.server;
|
||||
this.omniscient_usernames = options[OMNISCIENT_USERNAMES] || new Set<string>();
|
||||
this.moderator_usernames = options[MODERATOR_USERNAMES] || new Set<string>();
|
||||
|
||||
// Initialize special powers.
|
||||
// The Power constructor in engine/power.ts needs to accept (game: DiplomacyGame, name: string)
|
||||
this.observer = options[OBSERVER_STRING_KEY] || new Power(this, OBSERVER_TYPE);
|
||||
this.omniscient = options[OMNISCIENT_STRING_KEY] || new Power(this, OMNISCIENT_TYPE);
|
||||
|
||||
// Ensure these powers are marked as controlled by the system/observer type
|
||||
// The set_controlled method in Power class needs to handle string arguments like OBSERVER_TYPE
|
||||
this.observer.set_controlled(OBSERVER_TYPE);
|
||||
this.omniscient.set_controlled(OMNISCIENT_TYPE); // Or perhaps OMNISCIENT_TYPE
|
||||
}
|
||||
|
||||
public is_server_game(): boolean {
|
||||
return true; // This class is ServerGame
|
||||
}
|
||||
|
||||
public get_related_power_names(power_name: string): string[] {
|
||||
if (this.powers[power_name]) {
|
||||
const related_power = this.powers[power_name];
|
||||
if (related_power.is_controlled()) { // is_controlled needs to be a method in Power
|
||||
return this.get_controlled_power_names(related_power.get_controller()!); // get_controller might return string | null
|
||||
}
|
||||
return [power_name];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public filter_phase_data(phase_data: GamePhaseData, role: string, is_current: boolean): GamePhaseData {
|
||||
if (role === OMNISCIENT_TYPE) {
|
||||
return phase_data;
|
||||
}
|
||||
if (role === OBSERVER_TYPE) {
|
||||
return new GamePhaseData({
|
||||
name: phase_data.name,
|
||||
state: phase_data.state,
|
||||
orders: phase_data.orders, // Observers see all orders in past phases
|
||||
results: phase_data.results,
|
||||
messages: this.filter_messages(phase_data.messages, role)
|
||||
});
|
||||
}
|
||||
|
||||
// Filter for power roles
|
||||
const related_power_names = this.get_related_power_names(role);
|
||||
const messages = this.filter_messages(phase_data.messages, related_power_names);
|
||||
let orders = phase_data.orders;
|
||||
if (is_current) {
|
||||
orders = {};
|
||||
for (const power_name of related_power_names) {
|
||||
if (phase_data.orders && phase_data.orders[power_name]) {
|
||||
orders[power_name] = phase_data.orders[power_name];
|
||||
}
|
||||
}
|
||||
}
|
||||
return new GamePhaseData({
|
||||
name: phase_data.name,
|
||||
state: phase_data.state,
|
||||
orders: orders,
|
||||
messages: messages,
|
||||
results: phase_data.results
|
||||
});
|
||||
}
|
||||
|
||||
public game_can_start(): boolean {
|
||||
return this.is_game_forming && !this.start_master && this.has_expected_controls_count();
|
||||
}
|
||||
|
||||
public get_messages(game_role: string, timestamp_from?: number, timestamp_to?: number): Message[] {
|
||||
return this.filter_messages(super.get_messages(), game_role, timestamp_from, timestamp_to); // super.get_messages() gets all current messages
|
||||
}
|
||||
|
||||
public get_message_history(game_role: string): Record<string, Message[]> {
|
||||
const filtered_history: Record<string, Message[]> = {};
|
||||
for (const short_phase in this.message_history) {
|
||||
filtered_history[short_phase] = this.filter_messages(this.message_history[short_phase], game_role);
|
||||
}
|
||||
return filtered_history;
|
||||
}
|
||||
|
||||
public get_user_power_names(username: string): string[] {
|
||||
return Object.values(this.powers)
|
||||
.filter(power => power.is_controlled_by(username))
|
||||
.map(power => power.name);
|
||||
}
|
||||
|
||||
public new_system_message(recipient: string, body: string): Message {
|
||||
if (!(recipient === GLOBAL || recipient === OBSERVER || recipient === OMNISCIENT || this.powers[recipient])) {
|
||||
throw new DiplomacyException(`Invalid recipient for system message: ${recipient}`);
|
||||
}
|
||||
const message = new Message({
|
||||
phase: this.current_short_phase,
|
||||
sender: SYSTEM,
|
||||
recipient: recipient,
|
||||
message: body,
|
||||
// timestamp will be set by add_message
|
||||
});
|
||||
this.add_message(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
// TODO: Implement as_power_game, as_omniscient_game, as_observer_game, cast
|
||||
// These require careful handling of game state copying and filtering.
|
||||
// For now, placeholder:
|
||||
public cast(role: string, for_username: string): DiplomacyGame {
|
||||
console.warn("ServerGame.cast() is a simplified placeholder.");
|
||||
// This should create a new Game instance, then filter its properties.
|
||||
const game_dict = this.to_dict(); // Uses Jsonable.to_dict
|
||||
const new_game_options: DiplomacyGameOptions = {
|
||||
...game_dict,
|
||||
// message_history and messages will be filtered below
|
||||
};
|
||||
const game = new DiplomacyGame(new_game_options); // Create a new game instance
|
||||
game.error = []; // Clear errors
|
||||
|
||||
if (role === OBSERVER_TYPE) {
|
||||
game.message_history = this.get_message_history(OBSERVER_TYPE);
|
||||
game.messages = this.get_messages(OBSERVER_TYPE);
|
||||
Object.values(game.powers).forEach(p => { p.vote = NEUTRAL; p.clear_orders(); });
|
||||
} else if (role === OMNISCIENT_TYPE) {
|
||||
game.message_history = this.get_message_history(OMNISCIENT_TYPE);
|
||||
game.messages = this.get_messages(OMNISCIENT_TYPE);
|
||||
} else if (this.powers[role]) { // Power role
|
||||
game.message_history = this.get_message_history(role);
|
||||
game.messages = this.get_messages(role);
|
||||
const related_power_names = this.get_related_power_names(role);
|
||||
Object.values(game.powers).forEach(p => {
|
||||
if (!related_power_names.includes(p.name)) {
|
||||
p.vote = NEUTRAL;
|
||||
p.clear_orders();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
game.role = role;
|
||||
// game.controlled_powers = this.get_controlled_power_names(for_username); // Needs get_controlled_power_names
|
||||
game.observer_level = this.get_observer_level(for_username);
|
||||
game.daide_port = this.server?.get_daide_port(this.game_id) ?? null;
|
||||
return game;
|
||||
}
|
||||
|
||||
|
||||
public is_controlled_by(power_name: string, username: string): boolean {
|
||||
const power = this.powers[power_name];
|
||||
return power ? power.is_controlled_by(username) : false;
|
||||
}
|
||||
|
||||
public get_observer_level(username: string): string | null {
|
||||
if ((this.server && this.server.users.has_admin(username)) || this.is_moderator(username)) {
|
||||
return MASTER_TYPE;
|
||||
}
|
||||
if (this.is_omniscient(username)) {
|
||||
return OMNISCIENT_TYPE;
|
||||
}
|
||||
if (!this.properties.no_observations) { // Assuming no_observations is a property from base Game
|
||||
return OBSERVER_TYPE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public *get_reception_addresses(): Generator<[string, string]> {
|
||||
for (const power of Object.values(this.powers)) {
|
||||
for (const token of power.tokens) yield [power.name, token];
|
||||
}
|
||||
for (const token of this.observer.tokens) yield [this.observer.name, token];
|
||||
for (const token of this.omniscient.tokens) yield [this.omniscient.name, token];
|
||||
}
|
||||
|
||||
// ... other token and permission methods ... (has_token, add_omniscient_token, etc.)
|
||||
// These will largely be translations of Python set/dict operations to TS Map/Set.
|
||||
|
||||
public has_token(token: string): boolean {
|
||||
if (this.omniscient.has_token(token)) return true;
|
||||
if (this.observer.has_token(token)) return true;
|
||||
for (const power of Object.values(this.powers)) {
|
||||
if (power.has_token(token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public add_omniscient_token(token: string): void {
|
||||
if (this.observer.has_token(token)) throw new ResponseException('Token already registered as observer.');
|
||||
if (this.has_player_token(token)) throw new ResponseException('Token already registered as player.');
|
||||
this.omniscient.add_token(token);
|
||||
}
|
||||
|
||||
public add_observer_token(token: string): void {
|
||||
if (this.omniscient.has_token(token)) throw new ResponseException('Token already registered as omniscient.');
|
||||
if (this.has_player_token(token)) throw new ResponseException('Token already registered as player.');
|
||||
this.observer.add_token(token);
|
||||
}
|
||||
|
||||
public control(power_name: string, username: string, token: string): void {
|
||||
if (this.observer.has_token(token)) throw new ResponseException('Token already registered as observer.');
|
||||
if (this.omniscient.has_token(token)) throw new ResponseException('Token already registered as omniscient.');
|
||||
|
||||
const power = this.powers[power_name];
|
||||
if (!power) throw new DiplomacyException(`Power ${power_name} not found in game ${this.game_id}`);
|
||||
|
||||
if (power.is_controlled() && !power.is_controlled_by(username)) {
|
||||
throw new ResponseException('Power already controlled by another user.');
|
||||
}
|
||||
power.set_controlled(username);
|
||||
power.add_token(token);
|
||||
}
|
||||
|
||||
public is_moderator(username: string): boolean {
|
||||
return this.moderator_usernames.has(username);
|
||||
}
|
||||
|
||||
public is_omniscient(username: string): boolean {
|
||||
return this.omniscient_usernames.has(username);
|
||||
}
|
||||
|
||||
public promote_moderator(username: string): void {
|
||||
this.moderator_usernames.add(username);
|
||||
}
|
||||
|
||||
public promote_omniscient(username: string): void {
|
||||
this.omniscient_usernames.add(username);
|
||||
}
|
||||
|
||||
// Stubs for remaining methods that require more complex logic or dependencies
|
||||
public has_player_token(token: string): boolean { /* ... */ return false; }
|
||||
public remove_observer_token(token: string): void { this.observer.remove_tokens([token]); }
|
||||
public remove_omniscient_token(token: string): void { this.omniscient.remove_tokens([token]); }
|
||||
public remove_token(token: string): void { /* ... */ }
|
||||
|
||||
|
||||
public override process(): { prev_phase_data: GamePhaseData | null, current_phase_data: GamePhaseData | null, kicked_powers: Record<string, Set<string>> | null } {
|
||||
if (!this.is_active) { // Assuming is_active getter from base Game
|
||||
return { prev_phase_data: null, current_phase_data: null, kicked_powers: null };
|
||||
}
|
||||
|
||||
const kicked_powers: Record<string, Set<string>> = {};
|
||||
const orderable_locations_all_powers = this.get_orderable_locations_all_powers(); // from base Game
|
||||
|
||||
if (!this.properties.civil_disorder_nodes) { // civil_disorder_nodes is a Rule
|
||||
Object.values(this.powers).forEach(power => {
|
||||
if (power.is_controlled() &&
|
||||
!power.is_order_set() && // power.order_is_set needs to be implemented in Power.ts
|
||||
orderable_locations_all_powers[power.name] &&
|
||||
orderable_locations_all_powers[power.name].length > 0) {
|
||||
|
||||
kicked_powers[power.name] = new Set(power.tokens);
|
||||
power.set_controlled(null); // Becomes AI/dummy
|
||||
power.clear_tokens();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(kicked_powers).length > 0) {
|
||||
this.set_status(FORMING); // Assuming set_status is available from base Game
|
||||
return { prev_phase_data: null, current_phase_data: null, kicked_powers };
|
||||
}
|
||||
|
||||
const previous_phase_data = super.process(); // Call base Game process
|
||||
|
||||
if (this.count_controlled_powers() < (this.properties.expected_controls_count || Object.keys(this.powers).length)) {
|
||||
// Compare with a reasonable default if expected_controls_count is not set
|
||||
this.set_status(FORMING);
|
||||
}
|
||||
|
||||
const current_phase_data = this.get_phase_data(this.current_short_phase); // from base Game
|
||||
return { prev_phase_data, current_phase_data, kicked_powers: null };
|
||||
}
|
||||
}
|
||||
63
diplomacy/server/user.ts
Normal file
63
diplomacy/server/user.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// diplomacy/server/user.ts
|
||||
|
||||
import { USERNAME, PASSWORD_HASH, CLIENT_NAME, CLIENT_VERSION, PASSCODE } from '../utils/strings'; // Assuming strings.ts path
|
||||
import { extend_model, OptionalValueType, ParserType } from '../utils/parsing'; // Assuming parsing.ts path
|
||||
import { is_valid_password } from '../utils/common'; // Assuming common.ts path and is_valid_password function
|
||||
import { Jsonable, JsonableModel } from '../utils/jsonable'; // Assuming jsonable.ts path
|
||||
|
||||
export class User extends Jsonable {
|
||||
public username: string | null = null;
|
||||
public password_hash: string | null = null;
|
||||
|
||||
public static override model: JsonableModel = {
|
||||
[USERNAME]: ParserType.STR,
|
||||
[PASSWORD_HASH]: ParserType.STR,
|
||||
};
|
||||
|
||||
constructor(kwargs: Partial<User> = {}) {
|
||||
super(kwargs);
|
||||
this.username = kwargs.username === undefined ? null : kwargs.username;
|
||||
this.password_hash = kwargs.password_hash === undefined ? null : kwargs.password_hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return True if given password matches user hashed password.
|
||||
*/
|
||||
public is_valid_password(password: string): boolean {
|
||||
if (!this.password_hash || !password) {
|
||||
return false;
|
||||
}
|
||||
return is_valid_password(password, this.password_hash);
|
||||
}
|
||||
|
||||
// Convenience method to set a new password (hashes and stores it)
|
||||
// This was not explicitly in the Python User class __init__ but is useful.
|
||||
// Requires hash_password to be available and imported from common.ts
|
||||
/*
|
||||
async set_password(password: string, commonUtils: { hash_password: (pwd: string) => Promise<string> }): Promise<void> {
|
||||
if (!is_strong_password(password)) { // Assuming is_strong_password also in common.ts
|
||||
throw new Error("Password is not strong enough.");
|
||||
}
|
||||
this.password_hash = await commonUtils.hash_password(password);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
export class DaideUser extends User {
|
||||
public client_name: string = '';
|
||||
public client_version: string = '';
|
||||
public passcode: number | null = 0; // Default to 0 as in Python, null if not present
|
||||
|
||||
public static override model: JsonableModel = extend_model(User.model, {
|
||||
[CLIENT_NAME]: ParserType.STR,
|
||||
[CLIENT_VERSION]: ParserType.STR,
|
||||
[PASSCODE]: new OptionalValueType(ParserType.INT),
|
||||
});
|
||||
|
||||
constructor(kwargs: Partial<DaideUser> = {}) {
|
||||
super(kwargs);
|
||||
this.client_name = kwargs.client_name === undefined ? '' : kwargs.client_name;
|
||||
this.client_version = kwargs.client_version === undefined ? '' : kwargs.client_version;
|
||||
this.passcode = kwargs.passcode === undefined ? 0 : kwargs.passcode;
|
||||
}
|
||||
}
|
||||
272
diplomacy/server/users.ts
Normal file
272
diplomacy/server/users.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// diplomacy/server/users.ts
|
||||
|
||||
import { User } from './user';
|
||||
import {
|
||||
USERS, ADMINISTRATORS, TOKEN_TIMESTAMP, TOKEN_TO_USERNAME, USERNAME_TO_TOKENS
|
||||
} from '../utils/strings'; // Assuming strings.ts path
|
||||
import {
|
||||
ParserType, DictType, SequenceType, JsonableClassType, DefaultValueType
|
||||
} from '../utils/parsing'; // Assuming parsing.ts path
|
||||
import { timestamp_microseconds, generate_token } from '../utils/common'; // Assuming common.ts path
|
||||
import { Jsonable, JsonableModel } from '../utils/jsonable'; // Assuming jsonable.ts path
|
||||
|
||||
const TOKEN_LIFETIME_SECONDS = 24 * 60 * 60;
|
||||
|
||||
// Placeholder for ConnectionHandler type.
|
||||
// In a real scenario, this would be a more specific type/interface from the networking layer.
|
||||
type ConnectionHandler = object;
|
||||
|
||||
export class Users extends Jsonable {
|
||||
public users: Map<string, User>;
|
||||
public administrators: Set<string>;
|
||||
public token_timestamp: Map<string, number>; // token -> timestamp
|
||||
public token_to_username: Map<string, string>; // token -> username
|
||||
public username_to_tokens: Map<string, Set<string>>; // username -> Set<token>
|
||||
|
||||
// In-memory only, not part of the persisted model
|
||||
public token_to_connection_handler: Map<string, ConnectionHandler>; // token -> ConnectionHandler
|
||||
public connection_handler_to_tokens: Map<ConnectionHandler, Set<string>>; // ConnectionHandler -> Set<token>
|
||||
|
||||
public static override model: JsonableModel = {
|
||||
[USERS]: new DefaultValueType(new DictType(ParserType.STR, new JsonableClassType(User)), () => new Map()),
|
||||
[ADMINISTRATORS]: new DefaultValueType(new SequenceType(ParserType.STR, () => new Set<string>()), () => new Set()),
|
||||
[TOKEN_TIMESTAMP]: new DefaultValueType(new DictType(ParserType.STR, ParserType.INT), () => new Map()),
|
||||
[TOKEN_TO_USERNAME]: new DefaultValueType(new DictType(ParserType.STR, ParserType.STR), () => new Map()),
|
||||
[USERNAME_TO_TOKENS]: new DefaultValueType(new DictType(ParserType.STR, new SequenceType(ParserType.STR, () => new Set<string>())), () => new Map()),
|
||||
};
|
||||
|
||||
constructor(kwargs: Partial<Users> = {}) {
|
||||
super(kwargs);
|
||||
this.users = kwargs.users === undefined ? new Map() : kwargs.users;
|
||||
this.administrators = kwargs.administrators === undefined ? new Set() : kwargs.administrators;
|
||||
this.token_timestamp = kwargs.token_timestamp === undefined ? new Map() : kwargs.token_timestamp;
|
||||
this.token_to_username = kwargs.token_to_username === undefined ? new Map() : kwargs.token_to_username;
|
||||
this.username_to_tokens = kwargs.username_to_tokens === undefined ? new Map() : kwargs.username_to_tokens;
|
||||
|
||||
this.token_to_connection_handler = new Map<string, ConnectionHandler>();
|
||||
this.connection_handler_to_tokens = new Map<ConnectionHandler, Set<string>>();
|
||||
}
|
||||
|
||||
public has_username(username: string): boolean {
|
||||
return this.users.has(username);
|
||||
}
|
||||
|
||||
public has_user(username: string, password?: string): boolean {
|
||||
const user = this.users.get(username);
|
||||
if (!user) return false;
|
||||
return password ? user.is_valid_password(password) : this.has_username(username);
|
||||
}
|
||||
|
||||
public has_admin(username: string): boolean {
|
||||
return this.administrators.has(username);
|
||||
}
|
||||
|
||||
public has_token(token: string): boolean {
|
||||
return this.token_to_username.has(token);
|
||||
}
|
||||
|
||||
public token_is_alive(token: string): boolean {
|
||||
if (this.has_token(token)) {
|
||||
const currentTime = timestamp_microseconds();
|
||||
const tokenTime = this.token_timestamp.get(token) || 0;
|
||||
const elapsedTimeSeconds = (currentTime - tokenTime) / 1000000;
|
||||
return elapsedTimeSeconds <= TOKEN_LIFETIME_SECONDS;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public relaunch_token(token: string): void {
|
||||
if (this.has_token(token)) {
|
||||
this.token_timestamp.set(token, timestamp_microseconds());
|
||||
}
|
||||
}
|
||||
|
||||
public token_is_admin(token: string): boolean {
|
||||
const username = this.get_name(token);
|
||||
return username ? this.has_admin(username) : false;
|
||||
}
|
||||
|
||||
public count_connections(): number {
|
||||
return this.connection_handler_to_tokens.size;
|
||||
}
|
||||
|
||||
public get_tokens(username: string): Set<string> {
|
||||
return new Set(this.username_to_tokens.get(username) || []);
|
||||
}
|
||||
|
||||
public get_name(token: string): string | undefined {
|
||||
return this.token_to_username.get(token);
|
||||
}
|
||||
|
||||
public get_user(username: string): User | undefined {
|
||||
return this.users.get(username);
|
||||
}
|
||||
|
||||
public get_connection_handler(token: string): ConnectionHandler | undefined {
|
||||
return this.token_to_connection_handler.get(token);
|
||||
}
|
||||
|
||||
public add_admin(username: string): void {
|
||||
if (!this.users.has(username)) {
|
||||
// Or throw error: console.error(`Cannot add admin: User ${username} does not exist.`);
|
||||
return;
|
||||
}
|
||||
this.administrators.add(username);
|
||||
}
|
||||
|
||||
public remove_admin(username: string): void {
|
||||
this.administrators.delete(username);
|
||||
}
|
||||
|
||||
public create_token(): string {
|
||||
let token = generate_token();
|
||||
while (this.has_token(token)) {
|
||||
token = generate_token();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
public add_user(username: string, password_hash: string): User {
|
||||
// TODO: Consider if password should be hashed here or if pre-hashed is always expected.
|
||||
// Python version expects pre-hashed.
|
||||
if (this.users.has(username)) {
|
||||
throw new Error(`User ${username} already exists.`);
|
||||
}
|
||||
const user = new User({ username, password_hash });
|
||||
this.users.set(username, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
public replace_user(username: string, new_user: User): void {
|
||||
if (!this.users.has(username)) {
|
||||
throw new Error(`User ${username} does not exist to be replaced.`);
|
||||
}
|
||||
this.users.set(username, new_user);
|
||||
}
|
||||
|
||||
public remove_user(username: string): void {
|
||||
const user = this.users.get(username);
|
||||
if (!user) return;
|
||||
|
||||
this.users.delete(username);
|
||||
this.remove_admin(username);
|
||||
|
||||
const tokens_to_remove = this.username_to_tokens.get(username) || new Set();
|
||||
tokens_to_remove.forEach(token => {
|
||||
this.token_timestamp.delete(token);
|
||||
this.token_to_username.delete(token);
|
||||
const connection_handler = this.token_to_connection_handler.get(token);
|
||||
if (connection_handler) {
|
||||
this.token_to_connection_handler.delete(token);
|
||||
const handler_tokens = this.connection_handler_to_tokens.get(connection_handler);
|
||||
if (handler_tokens) {
|
||||
handler_tokens.delete(token);
|
||||
if (handler_tokens.size === 0) {
|
||||
this.connection_handler_to_tokens.delete(connection_handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
this.username_to_tokens.delete(username);
|
||||
}
|
||||
|
||||
public remove_connection(connection_handler: ConnectionHandler, remove_tokens: boolean = true): Set<string> | null {
|
||||
const tokens = this.connection_handler_to_tokens.get(connection_handler);
|
||||
if (tokens) {
|
||||
this.connection_handler_to_tokens.delete(connection_handler);
|
||||
tokens.forEach(token => {
|
||||
this.token_to_connection_handler.delete(token);
|
||||
if (remove_tokens) {
|
||||
const username = this.token_to_username.get(token);
|
||||
this.token_timestamp.delete(token);
|
||||
this.token_to_username.delete(token);
|
||||
if (username) {
|
||||
const user_tokens = this.username_to_tokens.get(username);
|
||||
if (user_tokens) {
|
||||
user_tokens.delete(token);
|
||||
if (user_tokens.size === 0) {
|
||||
this.username_to_tokens.delete(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public connect_user(username: string, connection_handler: ConnectionHandler): string {
|
||||
const user = this.users.get(username);
|
||||
if (!user) {
|
||||
throw new Error(`User ${username} not found.`);
|
||||
}
|
||||
const token = this.create_token();
|
||||
|
||||
this.connection_handler_to_tokens.set(connection_handler,
|
||||
(this.connection_handler_to_tokens.get(connection_handler) || new Set()).add(token)
|
||||
);
|
||||
this.username_to_tokens.set(user.username!,
|
||||
(this.username_to_tokens.get(user.username!) || new Set()).add(token)
|
||||
);
|
||||
|
||||
this.token_to_username.set(token, user.username!);
|
||||
this.token_to_connection_handler.set(token, connection_handler);
|
||||
this.token_timestamp.set(token, timestamp_microseconds());
|
||||
return token;
|
||||
}
|
||||
|
||||
public attach_connection_handler(token: string, connection_handler: ConnectionHandler): void {
|
||||
if (this.has_token(token)) {
|
||||
const previous_connection = this.get_connection_handler(token);
|
||||
if (previous_connection && previous_connection !== connection_handler) {
|
||||
// In Python, this was an assert. Throwing an error is more idiomatic in TS for such conditions.
|
||||
throw new Error("A new connection handler cannot be attached to a token already connected to another handler.");
|
||||
}
|
||||
if (!previous_connection) {
|
||||
console.warn('Attaching a new connection handler to a token.'); // Matched Python's LOGGER.warning
|
||||
let handler_tokens = this.connection_handler_to_tokens.get(connection_handler);
|
||||
if (!handler_tokens) {
|
||||
handler_tokens = new Set<string>();
|
||||
this.connection_handler_to_tokens.set(connection_handler, handler_tokens);
|
||||
}
|
||||
handler_tokens.add(token);
|
||||
this.token_to_connection_handler.set(token, connection_handler);
|
||||
}
|
||||
// Always update timestamp, even if handler was already attached or is the same.
|
||||
this.token_timestamp.set(token, timestamp_microseconds());
|
||||
} else {
|
||||
console.warn(`Attempted to attach connection handler to unknown token: ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnect_token(token: string): void {
|
||||
if (!this.has_token(token)) return;
|
||||
|
||||
this.token_timestamp.delete(token);
|
||||
const username = this.token_to_username.get(token);
|
||||
this.token_to_username.delete(token);
|
||||
|
||||
if (username) {
|
||||
const user_tokens = this.username_to_tokens.get(username);
|
||||
if (user_tokens) {
|
||||
user_tokens.delete(token);
|
||||
if (user_tokens.size === 0) {
|
||||
this.username_to_tokens.delete(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connection_handler = this.token_to_connection_handler.get(token);
|
||||
if (connection_handler) {
|
||||
this.token_to_connection_handler.delete(token);
|
||||
const handler_tokens = this.connection_handler_to_tokens.get(connection_handler);
|
||||
if (handler_tokens) {
|
||||
handler_tokens.delete(token);
|
||||
if (handler_tokens.size === 0) {
|
||||
this.connection_handler_to_tokens.delete(connection_handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
302
diplomacy/utils/common.ts
Normal file
302
diplomacy/utils/common.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
// diplomacy/utils/common.ts
|
||||
// Common utils symbols used in diplomacy network code.
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { Buffer } from 'buffer'; // Needed for Base64 operations
|
||||
|
||||
// Placeholder for exceptions - will be imported from a dedicated exceptions file later
|
||||
class CommonKeyException extends Error {
|
||||
constructor(key: string) {
|
||||
super(`Common key found: ${key}`);
|
||||
this.name = 'CommonKeyException';
|
||||
}
|
||||
}
|
||||
|
||||
// Datetime since timestamp 0.
|
||||
export const EPOCH = new Date(Date.UTC(1970, 0, 1, 0, 0, 0));
|
||||
|
||||
// Regex used for conversion from camel case to snake case.
|
||||
const REGEX_CONSECUTIVE_UPPER_CASES = /[A-Z]{2,}/g; // Adjusted for JS: removed compile, added g
|
||||
const REGEX_LOWER_THEN_UPPER_CASES = /([a-z0-9])([A-Z])/g; // Adjusted for JS: removed compile, added g
|
||||
const REGEX_UNDERSCORE_THEN_LETTER = /_([a-z])/g; // Adjusted for JS: removed compile, added g
|
||||
const REGEX_START_BY_LOWERCASE = /^[a-z]/; // Adjusted for JS: removed compile
|
||||
|
||||
/**
|
||||
* Hash long password to allow bcrypt to handle password longer than 72 characters.
|
||||
* Module private method.
|
||||
* @param password - password to hash.
|
||||
* @returns The hashed password, base64 encoded.
|
||||
*/
|
||||
function _sub_hash_password(password: string): string {
|
||||
// Bcrypt only handles passwords up to 72 characters. We use this hashing method as a work around.
|
||||
// Suggested in bcrypt PyPI page (2018/02/08 12:36 EST): https://pypi.python.org/pypi/bcrypt/3.1.0
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(password, 'utf-8');
|
||||
return hash.digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if password matches hashed.
|
||||
* NOTE: This is a STUB and NOT a secure bcrypt replacement.
|
||||
* @param password - password to check.
|
||||
* @param hashed - a password hashed with hash_password().
|
||||
* @returns Indicates if the password matches the hash.
|
||||
*/
|
||||
export function is_valid_password(password: string, hashed: string): boolean {
|
||||
console.warn("`is_valid_password` is using a STUB comparison and is NOT SECURE.");
|
||||
// In a real scenario, use a library like bcrypt.compareSync()
|
||||
// This stub assumes `hashed` is the output of our stubbed `hash_password`.
|
||||
const sub_hashed_password = _sub_hash_password(password);
|
||||
// Example: if hash_password just appended a salt, check that.
|
||||
// This is highly insecure and just for structure.
|
||||
return hashed.startsWith(sub_hashed_password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password. Accepts password longer than 72 characters. Public method.
|
||||
* NOTE: This is a STUB and NOT a secure bcrypt replacement.
|
||||
* @param password - The password to hash
|
||||
* @returns The hashed password.
|
||||
*/
|
||||
export function hash_password(password: string): string {
|
||||
console.warn("`hash_password` is a STUB and is NOT SECURE. It does not use bcrypt salts.");
|
||||
// In a real scenario, use a library like bcrypt.hashSync()
|
||||
const sub_hashed = _sub_hash_password(password);
|
||||
// This is just a placeholder, not a real salt process.
|
||||
return `${sub_hashed}$INSECURE_STUB_SALT`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a token with 2 * n_bytes characters (n_bytes bytes encoded in hexadecimal).
|
||||
*/
|
||||
export function generate_token(n_bytes: number = 128): string {
|
||||
return crypto.randomBytes(n_bytes).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if given variable is a dictionary-like object (plain object in JS/TS).
|
||||
* @param dict_to_check - Object to check.
|
||||
* @returns Indicates if the object is a plain object.
|
||||
*/
|
||||
export function is_dictionary(dict_to_check: any): boolean {
|
||||
return typeof dict_to_check === 'object' && dict_to_check !== null && !Array.isArray(dict_to_check) && !(dict_to_check instanceof Date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if given variable is a sequence-like object (array in JS/TS).
|
||||
* Note that strings will not be considered as sequences.
|
||||
* @param seq_to_check - Sequence-like object to check.
|
||||
* @returns Indicates if the object is array-like.
|
||||
*/
|
||||
export function is_sequence(seq_to_check: any): boolean {
|
||||
// Strings and dicts are not valid sequences.
|
||||
if (typeof seq_to_check === 'string' || is_dictionary(seq_to_check)) {
|
||||
return false;
|
||||
}
|
||||
return Array.isArray(seq_to_check) || (typeof seq_to_check === 'object' && seq_to_check !== null && typeof (seq_to_check as any)[Symbol.iterator] === 'function');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string (expected to be in camel case) to snake case.
|
||||
* @param name - string to convert.
|
||||
* @returns snake case version of given name.
|
||||
*/
|
||||
export function camel_case_to_snake_case(name: string): string {
|
||||
if (name === '') {
|
||||
return name;
|
||||
}
|
||||
// Python: separated_consecutive_uppers = REGEX_CONSECUTIVE_UPPER_CASES.sub(lambda m: '_'.join(c for c in m.group(0)), name)
|
||||
// JS:
|
||||
let separated_consecutive_uppers = name.replace(REGEX_CONSECUTIVE_UPPER_CASES, (match) => {
|
||||
return Array.from(match).join('_');
|
||||
});
|
||||
// Python: return REGEX_LOWER_THEN_UPPER_CASES.sub(r'\1_\2', separated_consecutive_uppers).lower()
|
||||
// JS:
|
||||
return separated_consecutive_uppers.replace(REGEX_LOWER_THEN_UPPER_CASES, '$1_$2').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string (expected to be in snake case) to camel case and convert first letter
|
||||
* to upper case if it's in lowercase.
|
||||
* @param name - string to convert.
|
||||
* @returns camel case version of given name.
|
||||
*/
|
||||
export function snake_case_to_upper_camel_case(name: string): string {
|
||||
if (name === '') {
|
||||
return name;
|
||||
}
|
||||
// Python: first_lower_case_to_upper = REGEX_START_BY_LOWERCASE.sub(lambda m: m.group(0).upper(), name)
|
||||
// JS:
|
||||
let first_lower_case_to_upper = name.replace(REGEX_START_BY_LOWERCASE, (match) => match.toUpperCase());
|
||||
// Python: return REGEX_UNDERSCORE_THEN_LETTER.sub(lambda m: m.group(1).upper(), first_lower_case_to_upper)
|
||||
// JS:
|
||||
return first_lower_case_to_upper.replace(REGEX_UNDERSCORE_THEN_LETTER, (match, charAfterUnderscore) => charAfterUnderscore.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that dictionaries does not share keys.
|
||||
*/
|
||||
export function assert_no_common_keys(dict1: object, dict2: object): void {
|
||||
const keys1 = Object.keys(dict1);
|
||||
const keys2 = Object.keys(dict2);
|
||||
|
||||
let smallest_dict_keys: string[], biggest_dict: object;
|
||||
if (keys1.length < keys2.length) {
|
||||
smallest_dict_keys = keys1;
|
||||
biggest_dict = dict2;
|
||||
} else {
|
||||
smallest_dict_keys = keys2;
|
||||
biggest_dict = dict1;
|
||||
}
|
||||
for (const key of smallest_dict_keys) {
|
||||
if (key in biggest_dict) {
|
||||
throw new CommonKeyException(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current timestamp with microsecond resolution.
|
||||
* Note: JavaScript's Date.now() is milliseconds. For microseconds, multiply by 1000.
|
||||
* For more precise microsecond timing, process.hrtime() could be used in Node.js,
|
||||
* but this simple multiplication matches the Python version's intent if not its exact precision source.
|
||||
*/
|
||||
export function timestamp_microseconds(): number {
|
||||
// Python: delta = datetime.now() - EPOCH
|
||||
// return (delta.days * 24 * 60 * 60 + delta.seconds) * 1000000 + delta.microseconds
|
||||
return Date.now() * 1000;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return a new class to be used as string comparator for sorting.
|
||||
*/
|
||||
export function str_cmp_class<T extends string>(compare_function: (a: T, b: T) => number): { new(value: T): { value: T, toString(): string } } {
|
||||
class StringComparator {
|
||||
public value: T;
|
||||
private cmp_fn: (a: T, b: T) => number;
|
||||
|
||||
constructor(value: T) {
|
||||
this.value = value; // Already a string in TS context generally
|
||||
this.cmp_fn = compare_function;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
// These methods are what Array.prototype.sort() would look for if comparing instances directly.
|
||||
// However, usually, you pass a compare function to sort.
|
||||
// For compatibility with Python's use (e.g. storing these wrapped objects in a list and calling sort()),
|
||||
// one might need a custom sort that extracts .value or uses these.
|
||||
// For direct use in JS sort: list.sort((a, b) => a.cmp_fn(a.value, b.value))
|
||||
|
||||
// Not directly used by Array.sort in JS, but good for completeness if objects are compared.
|
||||
equals(other: StringComparator | string): boolean {
|
||||
const otherValue = (typeof other === 'string') ? other : other.value;
|
||||
return this.cmp_fn(this.value, otherValue as T) === 0;
|
||||
}
|
||||
|
||||
lessThan(other: StringComparator | string): boolean {
|
||||
const otherValue = (typeof other === 'string') ? other : other.value;
|
||||
return this.cmp_fn(this.value, otherValue as T) < 0;
|
||||
}
|
||||
}
|
||||
// To make it somewhat unique like Python's id()-based naming, though not strictly necessary in TS.
|
||||
// Object.defineProperty(StringComparator, 'name', { value: `StringComparator${Date.now()}${Math.random()}` });
|
||||
return StringComparator;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert element to a string and make sure string is wrapped in either simple quotes
|
||||
* (if contains double quotes) or double quotes (if contains simple quotes).
|
||||
* If no quotes, or both types of quotes, defaults to double quotes.
|
||||
*/
|
||||
export function to_string_with_quoting(element: any): string {
|
||||
const s_element = String(element);
|
||||
const hasDouble = s_element.includes('"');
|
||||
const hasSingle = s_element.includes("'");
|
||||
|
||||
if (hasDouble && !hasSingle) {
|
||||
return `'${s_element}'`;
|
||||
}
|
||||
// Default to double quotes if no quotes, or if both types are present (escaping would be needed for correctness in that case)
|
||||
return `"${s_element}"`;
|
||||
}
|
||||
|
||||
|
||||
export class StringableCode {
|
||||
public readonly code: number | null;
|
||||
public readonly message: string;
|
||||
|
||||
constructor(code: number | string, message?: string) {
|
||||
if (typeof code === 'string' && message === undefined) {
|
||||
const message_parts = code.split(':');
|
||||
if (message_parts.length > 1 && /^\d+$/.test(message_parts[0]!)) {
|
||||
this.code = parseInt(message_parts[0]!, 10);
|
||||
this.message = message_parts.slice(1).join(':');
|
||||
} else {
|
||||
this.code = null;
|
||||
this.message = code;
|
||||
}
|
||||
} else if (typeof code === 'number' && message !== undefined) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
} else if (typeof code === 'number' && message === undefined) { // Only code provided
|
||||
this.code = code;
|
||||
this.message = String(code);
|
||||
}
|
||||
else { // Fallback or error
|
||||
this.code = null;
|
||||
this.message = String(code); // Treat code as message if types are unexpected
|
||||
}
|
||||
}
|
||||
|
||||
equals(other: StringableCode | string | number): boolean {
|
||||
if (other instanceof StringableCode) {
|
||||
return this.code === other.code;
|
||||
}
|
||||
// In Python, it compared self._message == str(other).
|
||||
// For more robustness, we might compare code if other is number, message if string.
|
||||
if (typeof other === 'number' && this.code !== null) {
|
||||
return this.code === other;
|
||||
}
|
||||
return this.message === String(other);
|
||||
}
|
||||
|
||||
// __hash__ is not directly applicable in JS/TS objects for Map keys in the same way.
|
||||
// If used as Map keys, the object reference is used, or toString() for string-keyed maps.
|
||||
|
||||
// Python's __mod__ was for string formatting like 'Error: %s' % value
|
||||
format(...values: any[]): StringableCode {
|
||||
let formatted_message = this.message;
|
||||
for (const value of values) {
|
||||
formatted_message = formatted_message.replace(/%s/, String(value)); // Simple %s replacement
|
||||
}
|
||||
// More sophisticated formatting would require a proper sprintf-js or similar.
|
||||
return new StringableCode(this.code, formatted_message);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.message;
|
||||
}
|
||||
|
||||
get repr(): string { // Getter for representation
|
||||
return `${this.code}:${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Stub for Tornado utilities, as Tornado is Python-specific.
|
||||
export class Tornado {
|
||||
/**
|
||||
* Modify exception handler method of given IO loop so that IO loop stops and raises
|
||||
* as soon as an exception is thrown from a callback.
|
||||
* @param io_loop - IO loop (Tornado specific, type any for TS)
|
||||
*/
|
||||
static stop_loop_on_callback_error(io_loop: any): void {
|
||||
console.warn("Tornado.stop_loop_on_callback_error is a STUB and not applicable in typical Node.js environments.");
|
||||
// In a Node.js/TS environment, unhandled exceptions in async operations
|
||||
// would typically be caught by process.on('uncaughtException') or promise .catch() handlers.
|
||||
}
|
||||
}
|
||||
52
diplomacy/utils/constants.ts
Normal file
52
diplomacy/utils/constants.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// diplomacy/utils/constants.ts
|
||||
// Some constant / config values used in Diplomacy package.
|
||||
|
||||
// Number of times to try to connect before throwing an exception.
|
||||
export const NB_CONNECTION_ATTEMPTS = 12;
|
||||
|
||||
// Time to wait between to connection trials.
|
||||
export const ATTEMPT_DELAY_SECONDS = 5;
|
||||
|
||||
// Time to wait between to server backups.
|
||||
export const DEFAULT_BACKUP_DELAY_SECONDS = 10 * 60; // 10 minutes.
|
||||
|
||||
// Default server ping interval. // Used for sockets ping.
|
||||
export const DEFAULT_PING_SECONDS = 30;
|
||||
|
||||
// Time to wait to receive a response for a request sent to server.
|
||||
export const REQUEST_TIMEOUT_SECONDS = 30;
|
||||
|
||||
// Default host name for a server to connect to.
|
||||
export const DEFAULT_HOST = 'localhost';
|
||||
|
||||
// Default port for normal non-securized server.
|
||||
export const DEFAULT_PORT = 8432;
|
||||
|
||||
// Default port for secure SSL server (not yet used).
|
||||
export const DEFAULT_SSL_PORT = 8433;
|
||||
|
||||
// Special username and password to use to connect as a bot recognized by diplomacy module.
|
||||
// This bot is called "private bot".
|
||||
export const PRIVATE_BOT_USERNAME = '#bot@2e723r43tr70fh2239-qf3947-3449-21128-9dh1321d12dm13d83820d28-9dm,xw201=ed283994f4n832483';
|
||||
export const PRIVATE_BOT_PASSWORD = '#bot:password:28131821--mx1fh5g7hg5gg5g´[],s222222223djdjje399333x93901deedd|e[[[]{{|@S{@244f';
|
||||
|
||||
// Time to wait to let a bot set orders for a dummy power.
|
||||
export const PRIVATE_BOT_TIMEOUT_SECONDS = 60;
|
||||
|
||||
// Default rules used to construct a Game object when no rules are provided.
|
||||
export const DEFAULT_GAME_RULES: string[] = ['SOLITAIRE', 'NO_PRESS', 'IGNORE_ERRORS', 'POWER_CHOICE'];
|
||||
|
||||
/**
|
||||
* Constants to define flags for attribute Power.order_is_set.
|
||||
*/
|
||||
export enum OrderSettings {
|
||||
ORDER_NOT_SET = 0,
|
||||
ORDER_SET_EMPTY = 1,
|
||||
ORDER_SET = 2,
|
||||
}
|
||||
|
||||
// Python's OrderSettings.ALL_SETTINGS was primarily for runtime validation.
|
||||
// In TypeScript, the enum itself serves as the definition of possible values.
|
||||
// If specific runtime checks against a set of these values are needed elsewhere,
|
||||
// it can be reconstructed there, e.g., new Set(Object.values(OrderSettings).filter(v => typeof v === 'number'))
|
||||
// For now, ALL_SETTINGS is omitted as it's not directly translatable or idiomatic in the same way for enums.
|
||||
272
diplomacy/utils/exceptions.ts
Normal file
272
diplomacy/utils/exceptions.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// diplomacy/utils/exceptions.ts
|
||||
// Exceptions used in diplomacy code.
|
||||
|
||||
export class DiplomacyException extends Error {
|
||||
constructor(message: string = 'Diplomacy network code exception.') {
|
||||
// Clean up message similar to Python's self.__doc__.strip() if message is empty
|
||||
const finalMessage = message || (new.target.prototype.constructor as any).__doc__?.trim() || 'Diplomacy network code exception.';
|
||||
super(finalMessage);
|
||||
this.name = new.target.name; // Sets the error name to the class name
|
||||
// This is important for `instanceof` checks and for more descriptive error logging.
|
||||
Object.setPrototypeOf(this, new.target.prototype); // Maintain prototype chain
|
||||
}
|
||||
}
|
||||
|
||||
export class AlreadyScheduledException extends DiplomacyException {
|
||||
static __doc__ = "Cannot add a data already scheduled.";
|
||||
constructor(message?: string) {
|
||||
super(message || AlreadyScheduledException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class CommonKeyException extends DiplomacyException {
|
||||
static __doc__ = "Common key error.";
|
||||
constructor(key: string) {
|
||||
super(`Forbidden common key in two dicts (${key})`);
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyException extends DiplomacyException {
|
||||
static __doc__ = "Key error.";
|
||||
constructor(key: string) {
|
||||
super(`Key error: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LengthException extends DiplomacyException {
|
||||
static __doc__ = "Length error.";
|
||||
constructor(expected_length: number, given_length: number) {
|
||||
super(`Expected length ${expected_length}, got ${given_length}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export class NaturalIntegerException extends DiplomacyException {
|
||||
static __doc__ = "Expected a positive integer (int >= 0).";
|
||||
constructor(integer_name: string = '') {
|
||||
super(integer_name ? `Integer error: ${integer_name}. ${NaturalIntegerException.__doc__}` : NaturalIntegerException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class NaturalIntegerNotNullException extends NaturalIntegerException {
|
||||
static __doc__ = "Expected a strictly positive integer (int > 0).";
|
||||
constructor(integer_name: string = '') {
|
||||
super(integer_name ? `Integer error: ${integer_name}. ${NaturalIntegerNotNullException.__doc__}` : NaturalIntegerNotNullException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class RandomPowerException extends DiplomacyException {
|
||||
static __doc__ = "No enough playable powers to select random powers.";
|
||||
constructor(nb_powers: number, nb_available_powers: number) {
|
||||
super(`Cannot randomly select ${nb_powers} power(s) in ${nb_available_powers} available power(s).`);
|
||||
}
|
||||
}
|
||||
|
||||
export class TypeException extends DiplomacyException {
|
||||
static __doc__ = "Type error.";
|
||||
constructor(expected_type: string, given_type: string) {
|
||||
super(`Expected type ${expected_type}, got type ${given_type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValueException extends DiplomacyException {
|
||||
static __doc__ = "Value error.";
|
||||
constructor(expected_values: any[], given_value: any) {
|
||||
super(`Forbidden value ${given_value}, expected: ${expected_values.map(v => String(v)).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationException extends DiplomacyException {
|
||||
static __doc__ = "Unknown notification.";
|
||||
constructor(message?: string) {
|
||||
super(message || NotificationException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseException extends DiplomacyException {
|
||||
static __doc__ = "Unknown response.";
|
||||
constructor(message?: string) {
|
||||
super(message || ResponseException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestException extends ResponseException {
|
||||
static __doc__ = "Unknown request.";
|
||||
constructor(message?: string) {
|
||||
super(message || RequestException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class AdminTokenException extends ResponseException {
|
||||
static __doc__ = "Invalid token for admin operations.";
|
||||
constructor(message?: string) {
|
||||
super(message || AdminTokenException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class DaidePortException extends ResponseException {
|
||||
static __doc__ = "Daide server not started for the game";
|
||||
constructor(message?: string) {
|
||||
super(message || DaidePortException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameCanceledException extends ResponseException {
|
||||
static __doc__ = "Game was cancelled.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameCanceledException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameCreationException extends ResponseException {
|
||||
static __doc__ = "Cannot create more games on that server.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameCreationException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameFinishedException extends ResponseException {
|
||||
static __doc__ = "This game is finished.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameFinishedException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameIdException extends ResponseException {
|
||||
static __doc__ = "Invalid game ID.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameIdException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameJoinRoleException extends ResponseException {
|
||||
static __doc__ = "A token can have only one role inside a game: player, observer or omniscient.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameJoinRoleException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameRoleException extends ResponseException {
|
||||
static __doc__ = "Game role does not accepts this action.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameRoleException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameMasterTokenException extends ResponseException {
|
||||
static __doc__ = "Invalid token for master operations.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameMasterTokenException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameNotPlayingException extends ResponseException {
|
||||
static __doc__ = "Game not playing.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameNotPlayingException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameObserverException extends ResponseException {
|
||||
static __doc__ = "Disallowed observation for non-master users.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameObserverException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GamePhaseException extends ResponseException {
|
||||
static __doc__ = "Data does not match current game phase.";
|
||||
constructor(expected?: string | null, given?: string | null, message?: string) {
|
||||
let constructedMessage = message || GamePhaseException.__doc__;
|
||||
if (expected !== undefined && expected !== null) { // Allow expected to be null but explicitly passed
|
||||
constructedMessage += ` Expected: ${expected}`;
|
||||
}
|
||||
if (given !== undefined && given !== null) { // Allow given to be null but explicitly passed
|
||||
constructedMessage += ` Given: ${given}`;
|
||||
}
|
||||
super(constructedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export class GamePlayerException extends ResponseException {
|
||||
static __doc__ = "Invalid player.";
|
||||
constructor(message?: string) {
|
||||
super(message || GamePlayerException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameRegistrationPasswordException extends ResponseException {
|
||||
static __doc__ = "Invalid game registration password.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameRegistrationPasswordException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameSolitaireException extends ResponseException {
|
||||
static __doc__ = "A solitaire game does not accepts players.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameSolitaireException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class GameTokenException extends ResponseException {
|
||||
static __doc__ = "Invalid token for this game.";
|
||||
constructor(message?: string) {
|
||||
super(message || GameTokenException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class MapIdException extends ResponseException {
|
||||
static __doc__ = "Invalid map ID.";
|
||||
constructor(message?: string) {
|
||||
super(message || MapIdException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class MapPowerException extends ResponseException {
|
||||
static __doc__ = "Invalid map power.";
|
||||
constructor(power_name: string) {
|
||||
super(`Invalid map power ${power_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class FolderException extends ResponseException {
|
||||
static __doc__ = "Given folder not available in server.";
|
||||
constructor(folder_path: string) {
|
||||
super(`Given folder not available in server: ${folder_path}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerRegistrationException extends ResponseException {
|
||||
static __doc__ = "Registration currently not allowed on this server.";
|
||||
constructor(message?: string) {
|
||||
super(message || ServerRegistrationException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class TokenException extends ResponseException {
|
||||
static __doc__ = "Invalid token.";
|
||||
constructor(message?: string) {
|
||||
super(message || TokenException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserException extends ResponseException {
|
||||
static __doc__ = "Invalid user.";
|
||||
constructor(message?: string) {
|
||||
super(message || UserException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordException extends ResponseException {
|
||||
static __doc__ = "Password must not be empty.";
|
||||
constructor(message?: string) {
|
||||
super(message || PasswordException.__doc__);
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerDirException extends ResponseException {
|
||||
static __doc__ = "Error with working folder.";
|
||||
constructor(server_dir: string) {
|
||||
super(`No server directory available at path ${server_dir}`);
|
||||
}
|
||||
}
|
||||
202
diplomacy/utils/export.ts
Normal file
202
diplomacy/utils/export.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// diplomacy/utils/export.ts
|
||||
// Responsible for exporting games in a standardized format to disk
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { DiplomacyGame } from '../engine/game';
|
||||
import { DiplomacyMap } from '../engine/map';
|
||||
import { GamePhaseData, GamePhaseDataData } from './game_phase_data'; // Assuming GamePhaseDataData is the interface for toDict()
|
||||
// import * as strings from './strings'; // Placeholder
|
||||
// import * as parsing from './parsing'; // Placeholder
|
||||
|
||||
// Temporary placeholders for imported string constants if not yet available
|
||||
const tempStrings = {
|
||||
MAP_NAME: 'map_name',
|
||||
RULES: 'rules',
|
||||
};
|
||||
|
||||
const logger = {
|
||||
warn: (message: string, ...args: any[]) => console.warn('[Export]', message, ...args),
|
||||
error: (message: string, ...args: any[]) => console.error('[Export]', message, ...args),
|
||||
};
|
||||
|
||||
const RULES_TO_SKIP: string[] = ['SOLITAIRE', 'NO_DEADLINE', 'CD_DUMMIES', 'ALWAYS_WAIT', 'IGNORE_ERRORS'];
|
||||
|
||||
export interface SavedGameFormat {
|
||||
id: string;
|
||||
map: string;
|
||||
rules: string[];
|
||||
phases: GamePhaseDataData[]; // Assuming GamePhaseDataData is the output of phase.toDict()
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a game to a standardized JSON format
|
||||
* @param game - game to convert.
|
||||
* @param output_path - Optional path to file. If set, the JSON.stringify() of the saved_game is written to that file.
|
||||
* @param output_mode - Optional. The mode to use to write to the output_path (if provided). Defaults to 'a' (append).
|
||||
* @returns A game in the standard format, ready for JSON serialization.
|
||||
*/
|
||||
export function to_saved_game_format(game: DiplomacyGame, output_path?: string, output_mode: string = 'a'): SavedGameFormat {
|
||||
// In Python: phases = Game.get_phase_history(game)
|
||||
// Assuming game instance has getPhaseHistory()
|
||||
const phaseHistoryData: GamePhaseData[] = game.getPhaseHistory ? game.getPhaseHistory() : [];
|
||||
|
||||
// In Python: phases.append(Game.get_phase_data(game))
|
||||
// Assuming game instance has getCurrentPhaseData()
|
||||
const currentPhaseData: GamePhaseData | null = game.getPhaseData ? game.getPhaseData() : null;
|
||||
|
||||
const allPhases: GamePhaseData[] = [...phaseHistoryData];
|
||||
if (currentPhaseData) {
|
||||
allPhases.push(currentPhaseData);
|
||||
}
|
||||
|
||||
const rules = game.rules.filter(rule => !RULES_TO_SKIP.includes(rule));
|
||||
|
||||
const phases_to_dict: GamePhaseDataData[] = allPhases.map(phase => {
|
||||
const phaseDict = phase.toDict(); // Assumes GamePhaseData has toDict()
|
||||
// Extend states fields as in Python
|
||||
if (phaseDict.state) {
|
||||
phaseDict.state.game_id = game.game_id;
|
||||
phaseDict.state.map = game.map_name; // map_name from game instance
|
||||
phaseDict.state.rules = [...rules];
|
||||
}
|
||||
return phaseDict;
|
||||
});
|
||||
|
||||
const saved_game: SavedGameFormat = {
|
||||
id: game.game_id,
|
||||
map: game.map_name, // map_name from game instance
|
||||
rules: rules,
|
||||
phases: phases_to_dict
|
||||
};
|
||||
|
||||
if (output_path) {
|
||||
try {
|
||||
const fileExists = fs.existsSync(output_path);
|
||||
if (output_mode === 'a' && fileExists) {
|
||||
fs.appendFileSync(output_path, JSON.stringify(saved_game) + '\n', 'utf-8');
|
||||
} else {
|
||||
fs.writeFileSync(output_path, JSON.stringify(saved_game) + '\n', 'utf-8');
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(`Error writing saved game to disk: ${e.message}`);
|
||||
// Decide if to throw or just log
|
||||
}
|
||||
}
|
||||
return saved_game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a DiplomacyGame object from the saved game format.
|
||||
* @param saved_game - The saved game object.
|
||||
* @returns The game object restored from the saved game.
|
||||
*/
|
||||
export function from_saved_game_format(saved_game: SavedGameFormat): DiplomacyGame {
|
||||
const game_id = saved_game.id || null; // game_id can be null in Python version
|
||||
const kwargs: Partial<DiplomacyGame> = { // Use Partial for constructor if it accepts object
|
||||
map_name: saved_game.map || 'standard',
|
||||
rules: saved_game.rules || [],
|
||||
};
|
||||
|
||||
// Assuming DiplomacyGame constructor can handle these, or we use setters.
|
||||
// The Python version directly passes kwargs to Game(game_id=game_id, **kwargs)
|
||||
const game = new DiplomacyGame(game_id, kwargs); // This matches Python's Game(game_id, **kwargs)
|
||||
|
||||
const phase_history: GamePhaseData[] = [];
|
||||
for (const phase_dct of saved_game.phases || []) {
|
||||
// Assumes GamePhaseData has a static fromDict method
|
||||
phase_history.push(GamePhaseData.fromDict(phase_dct));
|
||||
}
|
||||
|
||||
// Assumes game has setPhaseData method
|
||||
if (game.setPhaseData) {
|
||||
game.setPhaseData(phase_history, true);
|
||||
} else {
|
||||
logger.warn("DiplomacyGame.setPhaseData method not found. Phase history not fully restored.");
|
||||
}
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds multiple DiplomacyGame objects from each line in a .jsonl file.
|
||||
* @param input_path - The path to the input file. Expected content is one saved_game json per line.
|
||||
* @param on_error - Optional. What to do if a game conversion fails. Either 'raise', 'warn', 'ignore'.
|
||||
* @returns A list of DiplomacyGame objects.
|
||||
*/
|
||||
export function load_saved_games_from_disk(input_path: string, on_error: 'raise' | 'warn' | 'ignore' = 'raise'): DiplomacyGame[] {
|
||||
const loaded_games: DiplomacyGame[] = [];
|
||||
if (on_error !== 'raise' && on_error !== 'warn' && on_error !== 'ignore') {
|
||||
throw new Error("Expected values for on_error are 'raise', 'warn', 'ignore'.");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(input_path)) {
|
||||
logger.warn(`File ${input_path} does not exist. Aborting.`);
|
||||
return loaded_games;
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(input_path, 'utf-8');
|
||||
const lines = fileContent.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
try {
|
||||
const saved_game: SavedGameFormat = JSON.parse(line.trim());
|
||||
const game = from_saved_game_format(saved_game);
|
||||
loaded_games.push(game);
|
||||
} catch (exc: any) {
|
||||
if (on_error === 'raise') {
|
||||
throw exc;
|
||||
}
|
||||
if (on_error === 'warn') {
|
||||
logger.warn(String(exc));
|
||||
}
|
||||
// If 'ignore', do nothing.
|
||||
}
|
||||
}
|
||||
return loaded_games;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the saved game is valid.
|
||||
* This is an expensive operation because it replays the game.
|
||||
* @param saved_game - The saved game (from to_saved_game_format)
|
||||
* @returns A boolean that indicates if the game is valid
|
||||
*/
|
||||
export function is_valid_saved_game(saved_game: SavedGameFormat): boolean {
|
||||
// This is a complex validation and relies heavily on a fully functional Game and Map class.
|
||||
// It will be stubbed for now and marked as needing full review once Game/Map are more complete.
|
||||
logger.warn("is_valid_saved_game is a STUB and only performs basic structural checks.");
|
||||
|
||||
if (!saved_game || typeof saved_game !== 'object') return false;
|
||||
if (!saved_game.id || typeof saved_game.id !== 'string') return false;
|
||||
if (!saved_game.map || typeof saved_game.map !== 'string') return false;
|
||||
|
||||
try {
|
||||
// Try to create a map object to see if map name is valid at least structurally
|
||||
const map_object = new DiplomacyMap(saved_game.map);
|
||||
if (map_object.name !== saved_game.map && map_object.root_map !== saved_game.map.split('.')[0]) { // Simple check
|
||||
logger.warn(`is_valid_saved_game: Map name mismatch - ${map_object.name} vs ${saved_game.map}`);
|
||||
// return false; // This might be too strict if map names have aliases not yet loaded
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`is_valid_saved_game: Error instantiating map ${saved_game.map}: ${e}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(saved_game.rules)) return false;
|
||||
if (!Array.isArray(saved_game.phases) || saved_game.phases.length === 0) return false;
|
||||
|
||||
// TODO: Implement full game replay validation as in Python:
|
||||
// - Create a game instance.
|
||||
// - For each phase:
|
||||
// - Set phase data.
|
||||
// - Set orders.
|
||||
// - Validate orders against possible_orders.
|
||||
// - Process game.
|
||||
// - Compare resulting state (phase name, hash, units, centers) with next phase in saved_game.
|
||||
// - Handle DIFFERENT_ADJUDICATION rule.
|
||||
// - Validate message history constraints.
|
||||
|
||||
return true; // Placeholder, actual validation is complex.
|
||||
}
|
||||
112
diplomacy/utils/game_phase_data.ts
Normal file
112
diplomacy/utils/game_phase_data.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// diplomacy/utils/game_phase_data.ts
|
||||
/**
|
||||
* Utility class to save all data related to one game phase (phase name, state, messages and orders).
|
||||
*/
|
||||
|
||||
import { Jsonable } from './jsonable';
|
||||
import * as parsing from './parsing';
|
||||
import { StringableCode } from './common';
|
||||
// Placeholder for Message class, assuming it will extend Jsonable
|
||||
// import { Message } from '../engine/message';
|
||||
|
||||
// --- Placeholder for Message ---
|
||||
// This would normally be imported from '../engine/message'
|
||||
// For now, let's define a minimal placeholder that extends Jsonable
|
||||
// to allow `JsonableClassType` to work.
|
||||
class MessagePlaceholder extends Jsonable {
|
||||
static model = {
|
||||
time_sent: parsing.PrimitiveType(Number), // Assuming 'time_sent' is part of Message model for IndexedSequenceType
|
||||
// ... other message fields
|
||||
};
|
||||
public time_sent: number = 0;
|
||||
// Add other properties as needed for Message if they are accessed by GamePhaseData or its model
|
||||
constructor(kwargs: any) {
|
||||
super(kwargs);
|
||||
this.time_sent = kwargs.time_sent || 0;
|
||||
}
|
||||
}
|
||||
// --- End Placeholder for Message ---
|
||||
|
||||
|
||||
// Placeholder for string constants, replace with actual imports or definitions later
|
||||
const STRINGS = {
|
||||
NAME: 'name',
|
||||
STATE: 'state',
|
||||
ORDERS: 'orders',
|
||||
RESULTS: 'results',
|
||||
MESSAGES: 'messages',
|
||||
SUMMARY: 'summary', // Added based on python model keys
|
||||
STATISTICAL_SUMMARY: 'statistical_summary' // Added
|
||||
};
|
||||
|
||||
// MESSAGES_TYPE from Python:
|
||||
// parsing.IndexedSequenceType(
|
||||
// parsing.DictType(int, parsing.JsonableClassType(Message), SortedDict.builder(int, Message)),
|
||||
// 'time_sent'
|
||||
// )
|
||||
// For SortedDict.builder, we'll use a Map which preserves insertion order.
|
||||
// If strict sorting by key is needed beyond that, it has to be handled during processing.
|
||||
const MESSAGES_DICT_TYPE = new parsing.DictType(
|
||||
Number, // Keys are timestamps (int in Python)
|
||||
new parsing.JsonableClassType(MessagePlaceholder as any), // Values are Message objects
|
||||
(mapData: Record<string | number, MessagePlaceholder>) => new Map(Object.entries(mapData).map(([k, v]) => [Number(k), v])) // Builder to ensure Map
|
||||
);
|
||||
|
||||
const MESSAGES_TYPE_PARSER = new parsing.IndexedSequenceType(MESSAGES_DICT_TYPE, 'time_sent');
|
||||
export { MESSAGES_TYPE_PARSER as MESSAGES_TYPE_PLACEHOLDER }; // For game.ts if it uses this alias
|
||||
|
||||
export interface GamePhaseDataData {
|
||||
name: string;
|
||||
state: Record<string, any>;
|
||||
orders: Record<string, string[] | null>;
|
||||
results: Record<string, StringableCode[]>;
|
||||
messages: MessagePlaceholder[]; // Serialized as an array by IndexedSequenceType
|
||||
summary?: string | null;
|
||||
statistical_summary?: string | null;
|
||||
}
|
||||
|
||||
|
||||
export class GamePhaseData extends Jsonable {
|
||||
public name: string;
|
||||
public state: Record<string, any>; // Generic dictionary for game state
|
||||
public orders: Record<string, string[] | null>; // PowerName -> list of order strings or null
|
||||
public results: Record<string, StringableCode[]>; // UnitName -> list of StringableCode results
|
||||
public messages: Map<number, MessagePlaceholder>; // Timestamp -> Message object (Map from MESSAGES_DICT_TYPE builder)
|
||||
public summary: string | null;
|
||||
public statistical_summary: string | null;
|
||||
|
||||
static model: Record<string, any> = {
|
||||
[STRINGS.NAME]: new parsing.PrimitiveType(String),
|
||||
[STRINGS.STATE]: new parsing.PrimitiveType(Object), // Validates as a plain object
|
||||
[STRINGS.ORDERS]: new parsing.DictType(String, new parsing.OptionalValueType(new parsing.SequenceType(String))),
|
||||
[STRINGS.RESULTS]: new parsing.DictType(String, new parsing.SequenceType(new parsing.StringableType(StringableCode))),
|
||||
[STRINGS.MESSAGES]: MESSAGES_TYPE_PARSER,
|
||||
[STRINGS.SUMMARY]: new parsing.OptionalValueType(new parsing.PrimitiveType(String)),
|
||||
[STRINGS.STATISTICAL_SUMMARY]: new parsing.OptionalValueType(new parsing.PrimitiveType(String)),
|
||||
};
|
||||
|
||||
constructor(data: Partial<GamePhaseDataData> = {}) {
|
||||
// Initialize properties to default values first
|
||||
this.name = '';
|
||||
this.state = {};
|
||||
this.orders = {};
|
||||
this.results = {};
|
||||
this.messages = new Map<number, MessagePlaceholder>();
|
||||
this.summary = null;
|
||||
this.statistical_summary = null;
|
||||
|
||||
// Let Jsonable constructor handle kwargs based on the model
|
||||
super(data);
|
||||
|
||||
// Ensure correct types after super call, especially for those with builders or complex initializations
|
||||
// The Jsonable constructor with parsing.update_data should handle defaults from the model.
|
||||
// For MESSAGES_TYPE_PARSER, the `to_type` within IndexedSequenceType (which calls DictType's to_type)
|
||||
// should use the Map builder.
|
||||
if (data.messages && !(this.messages instanceof Map) && Array.isArray(data.messages)) {
|
||||
// If super didn't correctly make it a Map due to parsing_to_type not being fully recursive with builders yet
|
||||
this.messages = MESSAGES_TYPE_PARSER.to_type(data.messages) as Map<number, MessagePlaceholder>;
|
||||
}
|
||||
}
|
||||
// toDict will be inherited from Jsonable, using the static model.
|
||||
// fromDict will be inherited from Jsonable, using the static model.
|
||||
}
|
||||
21
diplomacy/utils/index.ts
Normal file
21
diplomacy/utils/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// diplomacy/utils/index.ts
|
||||
// This file will re-export symbols from other .ts files in this directory
|
||||
|
||||
export * from './common';
|
||||
export * from './constants';
|
||||
export * from './exceptions';
|
||||
export * from './game_phase_data';
|
||||
export * from './jsonable';
|
||||
export * from './keywords';
|
||||
export * from './order_results';
|
||||
export * from './parsing';
|
||||
export * from './priority_dict';
|
||||
export * from './scheduler_event';
|
||||
export * from './splitter';
|
||||
export * from './strings';
|
||||
export * from './time';
|
||||
|
||||
// Files not translated or intentionally skipped:
|
||||
// export.py - Python-specific export mechanisms
|
||||
// sorted_dict.py - Python's OrderedDict; use Map and manage order if critical
|
||||
// sorted_set.py - Python's SortedSet; use Set and sort array if critical
|
||||
152
diplomacy/utils/jsonable.ts
Normal file
152
diplomacy/utils/jsonable.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// diplomacy/utils/jsonable.ts
|
||||
/**
|
||||
* Abstract Jsonable class with automatic attributes checking and conversion to/from JSON dict.
|
||||
* To write a Jsonable sub-class:
|
||||
*
|
||||
* - Define a static `model` with expected attribute names and their types/validation logic.
|
||||
* - Override the constructor:
|
||||
* - Initialize each attribute defined in the model with a default value (e.g., null, undefined).
|
||||
* - Call `super(kwargs)` to have attributes checked and filled.
|
||||
* - Add further initialization code after the call to the parent constructor if needed.
|
||||
*/
|
||||
|
||||
import * as exceptions from './exceptions';
|
||||
import {
|
||||
validate_data as parsing_validate_data,
|
||||
update_data as parsing_update_data,
|
||||
to_json as parsing_to_json,
|
||||
to_type as parsing_to_type
|
||||
} from './parsing'; // Import actual functions
|
||||
|
||||
const logger = {
|
||||
error: (message: string, ...args: any[]) => console.error('[Jsonable]', message, ...args),
|
||||
warn: (message: string, ...args: any[]) => console.warn('[Jsonable]', message, ...args),
|
||||
};
|
||||
|
||||
export abstract class Jsonable {
|
||||
// Descendant classes should define this static model.
|
||||
// Example: static model = { my_attribute: 'string', count: 'number' };
|
||||
// For more complex types, the string could be a key to a parsing function or class.
|
||||
static model: Record<string, any> = {};
|
||||
|
||||
constructor(kwargs: Record<string, any> = {}) {
|
||||
const model = (this.constructor as typeof Jsonable).getModel();
|
||||
|
||||
// Initialize attributes defined in the model to null or undefined first
|
||||
for (const model_key in model) {
|
||||
if (model.hasOwnProperty(model_key)) {
|
||||
(this as any)[model_key] = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const updated_kwargs: Record<string, any> = {};
|
||||
for (const model_key in model) {
|
||||
updated_kwargs[model_key] = undefined; // Or a default from model if specified
|
||||
}
|
||||
Object.assign(updated_kwargs, kwargs);
|
||||
|
||||
|
||||
try {
|
||||
// Placeholder for Python's parsing.validate_data
|
||||
// In TS, this would often be handled by constructor types or dedicated validation methods.
|
||||
parsing_validate_data(updated_kwargs, model);
|
||||
} catch (exception: any) {
|
||||
logger.error(`Error occurred while building class ${this.constructor.name}`);
|
||||
throw exception;
|
||||
}
|
||||
|
||||
// Placeholder for Python's parsing.update_data (e.g. for default values)
|
||||
const final_attrs = parsing_update_data(updated_kwargs, model);
|
||||
|
||||
for (const model_key in model) {
|
||||
if (final_attrs.hasOwnProperty(model_key)) {
|
||||
(this as any)[model_key] = final_attrs[model_key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toJsonString(): string {
|
||||
return JSON.stringify(this.toDict());
|
||||
}
|
||||
|
||||
toDict(): Record<string, any> {
|
||||
const model = (this.constructor as typeof Jsonable).getModel();
|
||||
const dict: Record<string, any> = {};
|
||||
for (const key in model) {
|
||||
if (model.hasOwnProperty(key) && typeof (this as any)[key] !== 'function') {
|
||||
// Ensure the property exists on 'this' before trying to access it
|
||||
if (Object.prototype.hasOwnProperty.call(this, key)) {
|
||||
dict[key] = parsing_to_json((this as any)[key], model[key]);
|
||||
} else {
|
||||
// Handle cases where model key might not be an actual property (e.g. if using getters without direct props)
|
||||
// Or if it was initialized to undefined and should be represented as null/undefined in dict.
|
||||
dict[key] = parsing_to_json(undefined, model[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional hook for subclasses to update/process the JSON dictionary
|
||||
* before it's used to create an instance.
|
||||
* @param jsonDict The JSON dictionary.
|
||||
*/
|
||||
static updateJsonDict?(jsonDict: Record<string, any>): void;
|
||||
|
||||
static fromDict<T extends Jsonable>(
|
||||
this: {
|
||||
new (...args: any[]): T;
|
||||
getModel(): Record<string, any>;
|
||||
updateJsonDict?(jsonDict: Record<string, any>): void;
|
||||
},
|
||||
jsonDict: Record<string, any>
|
||||
): T {
|
||||
if (typeof jsonDict !== 'object' || jsonDict === null) {
|
||||
throw new exceptions.TypeException('object', typeof jsonDict);
|
||||
}
|
||||
|
||||
const model = this.getModel();
|
||||
const default_json_dict: Record<string, any> = {};
|
||||
for (const key in model) {
|
||||
default_json_dict[key] = null; // Or handle defaults from model more explicitly
|
||||
}
|
||||
Object.assign(default_json_dict, jsonDict);
|
||||
|
||||
if (typeof this.updateJsonDict === 'function') {
|
||||
this.updateJsonDict(default_json_dict); // Allow class to modify dict before parsing
|
||||
}
|
||||
|
||||
const kwargs: Record<string, any> = {};
|
||||
for (const key in model) {
|
||||
if (model.hasOwnProperty(key)) {
|
||||
// Ensure key exists in default_json_dict before parsing, even if it's null/undefined
|
||||
kwargs[key] = parsing_to_type(default_json_dict[key], model[key]);
|
||||
}
|
||||
}
|
||||
return new this(kwargs);
|
||||
}
|
||||
|
||||
static fromJsonString<T extends Jsonable>(
|
||||
this: {
|
||||
new (...args: any[]): T;
|
||||
fromDict(jsonDict: Record<string, any>): T;
|
||||
getModel(): Record<string, any>;
|
||||
updateJsonDict?(jsonDict: Record<string, any>): void;
|
||||
},
|
||||
jsonStr: string
|
||||
): T {
|
||||
return this.fromDict(JSON.parse(jsonStr));
|
||||
}
|
||||
|
||||
/**
|
||||
* Descendant classes must implement this to provide their specific model.
|
||||
* Or they can override the static `model` property directly.
|
||||
*/
|
||||
static getModel(): Record<string, any> {
|
||||
// This default implementation relies on `static model` being defined on the subclass.
|
||||
// Caching logic from Python's get_model is omitted for simplicity here,
|
||||
// as direct static property access is common in TS/JS.
|
||||
return this.model || {};
|
||||
}
|
||||
}
|
||||
98
diplomacy/utils/keywords.ts
Normal file
98
diplomacy/utils/keywords.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// diplomacy/utils/keywords.ts
|
||||
// Contains aliases and keywords
|
||||
// Keywords are always single words
|
||||
// Aliases are only converted in a second pass, so if they contain a keyword, you should replace
|
||||
// the keyword with its abbreviation.
|
||||
|
||||
export const KEYWORDS: Record<string, string> = {
|
||||
'>': '',
|
||||
'-': '-',
|
||||
'ARMY': 'A',
|
||||
'FLEET': 'F',
|
||||
'WING': 'W', // Note: WING 'W' is not a standard DATC unit type, might be specific to a variant
|
||||
'THE': '',
|
||||
'NC': '/NC',
|
||||
'SC': '/SC',
|
||||
'EC': '/EC',
|
||||
'WC': '/WC',
|
||||
'MOVE': '',
|
||||
'MOVES': '',
|
||||
'MOVING': '',
|
||||
'ATTACK': '',
|
||||
'ATTACKS': '',
|
||||
'ATTACKING': '',
|
||||
'RETREAT': 'R',
|
||||
'RETREATS': 'R',
|
||||
'RETREATING': 'R',
|
||||
'SUPPORT': 'S',
|
||||
'SUPPORTS': 'S',
|
||||
'SUPPORTING': 'S',
|
||||
'CONVOY': 'C',
|
||||
'CONVOYS': 'C',
|
||||
'CONVOYING': 'C',
|
||||
'HOLD': 'H',
|
||||
'HOLDS': 'H',
|
||||
'HOLDING': 'H',
|
||||
'BUILD': 'B',
|
||||
'BUILDS': 'B',
|
||||
'BUILDING': 'B',
|
||||
'DISBAND': 'D',
|
||||
'DISBANDS': 'D',
|
||||
'DISBANDING': 'D',
|
||||
'DESTROY': 'D',
|
||||
'DESTROYS': 'D',
|
||||
'DESTROYING': 'D',
|
||||
'REMOVE': 'D',
|
||||
'REMOVES': 'D',
|
||||
'REMOVING': 'D',
|
||||
'WAIVE': 'V', // Note: 'V' for Waive is unusual; standard is often just WAIVE or no build/disband order
|
||||
'WAIVES': 'V',
|
||||
'WAIVING': 'V',
|
||||
'WAIVED': 'V',
|
||||
'KEEP': 'K', // Note: 'K' for Keep is non-standard
|
||||
'KEEPS': 'K',
|
||||
'KEEPING': 'K',
|
||||
'PROXY': 'P', // Non-standard
|
||||
'PROXIES': 'P',
|
||||
'PROXYING': 'P',
|
||||
'IS': '',
|
||||
'WILL': '',
|
||||
'IN': '',
|
||||
'AT': '',
|
||||
'ON': '',
|
||||
'TO': '',
|
||||
'OF': '\\',
|
||||
'FROM': '\\',
|
||||
'WITH': '?',
|
||||
'TSR': '=', // Trans-Siberian Railroad?
|
||||
'VIA': 'VIA',
|
||||
'THROUGH': '~',
|
||||
'OVER': '~',
|
||||
'BY': '~',
|
||||
'OR': '|',
|
||||
'BOUNCE': '|',
|
||||
'CUT': '|',
|
||||
'VOID': '?',
|
||||
'DISLODGED': '~',
|
||||
'DESTROYED': '*'
|
||||
};
|
||||
|
||||
export const ALIASES: Record<string, string> = {
|
||||
'NORTH COAST \\': '/NC \\',
|
||||
'SOUTH COAST \\': '/SC \\',
|
||||
'EAST COAST \\': '/EC \\',
|
||||
'WEST COAST \\': '/WC \\',
|
||||
'AN A': 'A',
|
||||
'A F': 'F',
|
||||
'A W': 'W',
|
||||
'NO C': '?',
|
||||
'~ C': '^',
|
||||
'~ =': '=',
|
||||
'? =': '=',
|
||||
'~ LAND': '_',
|
||||
'~ WATER': '_',
|
||||
'~ SEA': '_',
|
||||
'VIA C': 'VIA',
|
||||
'TRANS SIBERIAN RAILROAD': '=',
|
||||
'V B': 'B V' // Waive Build? This alias seems unusual.
|
||||
};
|
||||
54
diplomacy/utils/order_results.ts
Normal file
54
diplomacy/utils/order_results.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// diplomacy/utils/order_results.ts
|
||||
// Contains the results labels and code used by the engine
|
||||
|
||||
import { StringableCode } from './common';
|
||||
|
||||
// Constants
|
||||
const ORDER_RESULT_OFFSET = 10000;
|
||||
|
||||
export class OrderResult extends StringableCode {
|
||||
/**
|
||||
* Represents an order result
|
||||
* @param code - int code of the order result
|
||||
* @param message - human readable string message associated to the order result
|
||||
*/
|
||||
constructor(code: number | null, message: string) { // Allow code to be null as in StringableCode
|
||||
super(code, message);
|
||||
}
|
||||
}
|
||||
|
||||
export const OK = new OrderResult(0, '');
|
||||
/**Order result OK, printed as ``''``*/
|
||||
|
||||
export const NO_CONVOY = new OrderResult(ORDER_RESULT_OFFSET + 1, 'no convoy');
|
||||
/**Order result NO_CONVOY, printed as ``'no convoy'``*/
|
||||
|
||||
export const BOUNCE = new OrderResult(ORDER_RESULT_OFFSET + 2, 'bounce');
|
||||
/**Order result BOUNCE, printed as ``'bounce'``*/
|
||||
|
||||
export const VOID = new OrderResult(ORDER_RESULT_OFFSET + 3, 'void');
|
||||
/**Order result VOID, printed as ``'void'``*/
|
||||
|
||||
export const CUT = new OrderResult(ORDER_RESULT_OFFSET + 4, 'cut');
|
||||
/**Order result CUT, printed as ``'cut'``*/
|
||||
|
||||
export const DISLODGED = new OrderResult(ORDER_RESULT_OFFSET + 5, 'dislodged');
|
||||
/**Order result DISLODGED, printed as ``'dislodged'``*/
|
||||
|
||||
export const DISRUPTED = new OrderResult(ORDER_RESULT_OFFSET + 6, 'disrupted');
|
||||
/**Order result DISRUPTED, printed as ``'disrupted'``*/
|
||||
|
||||
export const DISBAND = new OrderResult(ORDER_RESULT_OFFSET + 7, 'disband');
|
||||
/**Order result DISBAND, printed as ``'disband'``*/
|
||||
|
||||
export const MAYBE = new OrderResult(ORDER_RESULT_OFFSET + 8, 'maybe');
|
||||
/**Order result MAYBE, printed as ``'maybe'``*/
|
||||
|
||||
// Note: The enum OrderResult in diplomacy/engine/interfaces.ts
|
||||
// currently defines these as string enum values (e.g., OrderResult.NO_CONVOY = 'no convoy').
|
||||
// This new OrderResult class provides richer objects with codes and messages.
|
||||
// These two representations will need to be reconciled in a future step.
|
||||
// For instance, game logic might use these class instances, but expose
|
||||
// only `OrderResult.message` to align with the string enum values if needed elsewhere.
|
||||
// Or, the string enum in interfaces.ts might be replaced by using these constants' messages.
|
||||
// Example: `import { NO_CONVOY } from '../utils/order_results'; console.log(NO_CONVOY.message)` -> 'no convoy'
|
||||
542
diplomacy/utils/parsing.ts
Normal file
542
diplomacy/utils/parsing.ts
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
// diplomacy/utils/parsing.ts
|
||||
|
||||
import { DiplomacyException, TypeException, ValueException, CommonKeyException } from './exceptions';
|
||||
import { is_dictionary, is_sequence } from './common';
|
||||
import { Jsonable } from './jsonable'; // Import for JsonableClassType; may cause circular dependency if not handled carefully at runtime
|
||||
|
||||
const logger = {
|
||||
error: (message: string, ...args: any[]) => console.error('[Parsing]', message, ...args),
|
||||
warn: (message: string, ...args: any[]) => console.warn('[Parsing]', message, ...args),
|
||||
};
|
||||
|
||||
// --- Base ParserType Class ---
|
||||
export abstract class ParserType {
|
||||
// JS primitives: string, number, boolean, object (for dicts), null, undefined.
|
||||
// Python also had bytes. We'll map Python's int/float to number.
|
||||
// `object` is a loose term here; more specific checks happen in subclasses.
|
||||
protected static primitives = [String, Number, Boolean, Object, Array, Set, Map, Date];
|
||||
|
||||
abstract validate(element: any): void;
|
||||
|
||||
update(element: any): any {
|
||||
return element;
|
||||
}
|
||||
|
||||
to_type(json_value: any): any {
|
||||
return json_value;
|
||||
}
|
||||
|
||||
to_json(raw_value: any): any {
|
||||
if (raw_value instanceof Date) return raw_value.toISOString();
|
||||
if (raw_value instanceof Set) return Array.from(raw_value);
|
||||
if (raw_value instanceof Map) return Object.fromEntries(raw_value);
|
||||
if (raw_value && typeof (raw_value as any).toDict === 'function') {
|
||||
return (raw_value as any).toDict(); // For Jsonable instances
|
||||
}
|
||||
if (raw_value && typeof (raw_value as any).toJSON === 'function') {
|
||||
return (raw_value as any).toJSON(); // For classes with toJSON (like Date, but handled above)
|
||||
}
|
||||
return raw_value;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Concrete ParserType Subclasses ---
|
||||
|
||||
export class PrimitiveType extends ParserType {
|
||||
constructor(public element_type: Function) { // e.g., String, Number, Boolean, Object (for dict)
|
||||
super();
|
||||
if (![String, Number, Boolean, Object].includes(element_type)) {
|
||||
throw new DiplomacyException(`Expected a JS primitive constructor (String, Number, Boolean, Object), got ${element_type}`);
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.element_type.name;
|
||||
}
|
||||
|
||||
validate(element: any): void {
|
||||
if (this.element_type === String && typeof element !== 'string') {
|
||||
throw new TypeException(this.element_type.name, typeof element);
|
||||
} else if (this.element_type === Number && typeof element !== 'number') {
|
||||
throw new TypeException(this.element_type.name, typeof element);
|
||||
} else if (this.element_type === Boolean && typeof element !== 'boolean') {
|
||||
throw new TypeException(this.element_type.name, typeof element);
|
||||
} else if (this.element_type === Object && (typeof element !== 'object' || element === null || Array.isArray(element))) {
|
||||
// Basic check for "dictionary-like" plain objects
|
||||
throw new TypeException('object (plain)', Array.isArray(element) ? 'array' : typeof element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultValueType extends ParserType {
|
||||
public element_type_parser: ParserType;
|
||||
|
||||
constructor(element_type: any, public default_json_value: any) {
|
||||
super();
|
||||
this.element_type_parser = get_type(element_type);
|
||||
if (this.element_type_parser instanceof DefaultValueType || this.element_type_parser instanceof OptionalValueType) {
|
||||
throw new DiplomacyException("DefaultValueType cannot wrap another DefaultValueType or OptionalValueType.");
|
||||
}
|
||||
// Validate the default value itself at construction time
|
||||
if (default_json_value !== null && default_json_value !== undefined) {
|
||||
const typed_default = this.element_type_parser.to_type(default_json_value);
|
||||
this.element_type_parser.validate(typed_default);
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.element_type_parser.toString()} (default ${JSON.stringify(this.default_json_value)})`;
|
||||
}
|
||||
|
||||
validate(element: any): void {
|
||||
if (element !== null && element !== undefined) {
|
||||
this.element_type_parser.validate(element);
|
||||
}
|
||||
}
|
||||
|
||||
update(element: any): any {
|
||||
if (element !== null && element !== undefined) {
|
||||
return this.element_type_parser.update(element);
|
||||
}
|
||||
return (this.default_json_value === null || this.default_json_value === undefined)
|
||||
? this.default_json_value
|
||||
: this.element_type_parser.to_type(this.default_json_value);
|
||||
}
|
||||
|
||||
to_type(json_value: any): any {
|
||||
const value_to_convert = (json_value === null || json_value === undefined) ? this.default_json_value : json_value;
|
||||
if (value_to_convert === null || value_to_convert === undefined) return value_to_convert;
|
||||
return this.element_type_parser.to_type(value_to_convert);
|
||||
}
|
||||
|
||||
to_json(raw_value: any): any {
|
||||
if (raw_value === null || raw_value === undefined) {
|
||||
// If default_json_value is also null/undefined, return that.
|
||||
// Otherwise, this implies the raw_value represents the default, so serialize default.
|
||||
// This logic can be tricky. Python's copy(self.default_json_value) is safer.
|
||||
return this.default_json_value === null || this.default_json_value === undefined ? this.default_json_value : JSON.parse(JSON.stringify(this.default_json_value));
|
||||
}
|
||||
return this.element_type_parser.to_json(raw_value);
|
||||
}
|
||||
}
|
||||
|
||||
export class OptionalValueType extends DefaultValueType {
|
||||
constructor(element_type: any) {
|
||||
super(element_type, null); // Default is null for optional values
|
||||
}
|
||||
toString(): string {
|
||||
return `${this.element_type_parser.toString()} | null`;
|
||||
}
|
||||
}
|
||||
|
||||
export class SequenceType extends ParserType {
|
||||
public element_type_parser: ParserType;
|
||||
|
||||
constructor(element_type: any, public sequence_builder: ((seq: any[]) => any) | null = null) {
|
||||
super();
|
||||
this.element_type_parser = get_type(element_type);
|
||||
this.sequence_builder = sequence_builder || ((seq) => seq);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Array<${this.element_type_parser.toString()}>`;
|
||||
}
|
||||
|
||||
validate(element: any): void {
|
||||
if (!is_sequence(element)) { // is_sequence from common.ts should check for Array.isArray
|
||||
throw new TypeException('sequence (Array)', typeof element);
|
||||
}
|
||||
for (const seq_element of element) {
|
||||
this.element_type_parser.validate(seq_element);
|
||||
}
|
||||
}
|
||||
|
||||
update(element: any): any {
|
||||
const sequence = (element as any[]).map(seq_element => this.element_type_parser.update(seq_element));
|
||||
return this.sequence_builder!(sequence);
|
||||
}
|
||||
|
||||
to_type(json_value: any[]): any {
|
||||
if (!Array.isArray(json_value)) throw new TypeException('array', typeof json_value);
|
||||
const sequence = json_value.map(seq_element => this.element_type_parser.to_type(seq_element));
|
||||
return this.sequence_builder!(sequence);
|
||||
}
|
||||
|
||||
to_json(raw_value: any[]): any[] {
|
||||
if (!Array.isArray(raw_value)) throw new TypeException('array', typeof raw_value);
|
||||
return raw_value.map(seq_element => this.element_type_parser.to_json(seq_element));
|
||||
}
|
||||
}
|
||||
|
||||
export class JsonableClassType extends ParserType {
|
||||
constructor(public element_type: typeof Jsonable) { // Expects a constructor of a Jsonable subclass
|
||||
super();
|
||||
if (!(typeof element_type === 'function' && element_type.prototype instanceof Jsonable)) {
|
||||
throw new DiplomacyException(`Expected a class extending Jsonable, got ${element_type}`);
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.element_type.name;
|
||||
}
|
||||
|
||||
validate(element: any): void {
|
||||
if (!(element instanceof this.element_type)) {
|
||||
throw new TypeException(this.element_type.name, element?.constructor?.name || typeof element);
|
||||
}
|
||||
}
|
||||
|
||||
to_type(json_value: Record<string, any>): Jsonable {
|
||||
if (typeof json_value !== 'object' || json_value === null) {
|
||||
throw new TypeException('object (for Jsonable)', typeof json_value);
|
||||
}
|
||||
return (this.element_type as any).fromDict(json_value);
|
||||
}
|
||||
|
||||
to_json(raw_value: Jsonable): Record<string, any> {
|
||||
if (!(raw_value instanceof this.element_type)) {
|
||||
throw new TypeException(this.element_type.name, raw_value?.constructor?.name || typeof raw_value);
|
||||
}
|
||||
return raw_value.toDict();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
export function get_type(desired_type: any): ParserType {
|
||||
if (desired_type instanceof ParserType) {
|
||||
return desired_type;
|
||||
}
|
||||
if (desired_type === String || desired_type === Number || desired_type === Boolean || desired_type === Object) {
|
||||
return new PrimitiveType(desired_type);
|
||||
}
|
||||
// Basic check for Jsonable subclasses (constructor)
|
||||
if (typeof desired_type === 'function' && desired_type.prototype instanceof Jsonable) {
|
||||
return new JsonableClassType(desired_type as typeof Jsonable);
|
||||
}
|
||||
// Add more sophisticated checks if needed, e.g., for Array to map to SequenceType
|
||||
// This part would need to be more robust to match Python's dynamic get_type fully.
|
||||
// For now, assuming explicit ParserType instances (e.g., new SequenceType(String)) are used in models.
|
||||
|
||||
// Fallback for unhandled types - this indicates an issue with model definition or get_type itself
|
||||
logger.warn(`get_type: Unhandled desired_type: ${desired_type}. Defaulting to PrimitiveType(Object) if it's a constructor, else error.`);
|
||||
if (typeof desired_type === 'function') return new PrimitiveType(Object); // A guess for unknown classes
|
||||
throw new DiplomacyException(`Cannot determine ParserType for: ${desired_type}`);
|
||||
}
|
||||
|
||||
|
||||
export function to_type(json_value: any, parser_type_input: any): any {
|
||||
return get_type(parser_type_input).to_type(json_value);
|
||||
}
|
||||
|
||||
export function to_json(raw_value: any, parser_type_input: any): any {
|
||||
return get_type(parser_type_input).to_json(raw_value);
|
||||
}
|
||||
|
||||
export function validate_data(data: Record<string, any>, model: Record<string, any>): void {
|
||||
if (!is_dictionary(data)) throw new TypeException("object", typeof data);
|
||||
if (!is_dictionary(model)) throw new TypeException("object", typeof model);
|
||||
|
||||
for (const model_key in model) {
|
||||
if (model.hasOwnProperty(model_key)) {
|
||||
const model_type_descriptor = model[model_key];
|
||||
try {
|
||||
get_type(model_type_descriptor).validate(data[model_key]);
|
||||
} catch (exception: any) {
|
||||
logger.error(`Error occurred while checking key ${model_key}`);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function update_data(data: Record<string, any>, model: Record<string, any>): Record<string, any> {
|
||||
const updatedData = { ...data };
|
||||
for (const model_key in model) {
|
||||
if (model.hasOwnProperty(model_key)) {
|
||||
const model_type_descriptor = model[model_key];
|
||||
const data_value = data.hasOwnProperty(model_key) ? data[model_key] : undefined; // Use undefined if key missing for DefaultValueType
|
||||
updatedData[model_key] = get_type(model_type_descriptor).update(data_value);
|
||||
}
|
||||
}
|
||||
return updatedData;
|
||||
}
|
||||
|
||||
// --- Remaining ParserType Subclasses ---
|
||||
|
||||
export class ConverterType extends ParserType {
|
||||
public element_type_parser: ParserType;
|
||||
|
||||
constructor(
|
||||
element_type: any,
|
||||
public converter_function: (value: any) => any,
|
||||
public json_converter_function?: (json_value: any) => any
|
||||
) {
|
||||
super();
|
||||
this.element_type_parser = get_type(element_type);
|
||||
if (this.element_type_parser instanceof ConverterType) {
|
||||
throw new DiplomacyException("ConverterType cannot wrap another ConverterType.");
|
||||
}
|
||||
if (typeof converter_function !== 'function') {
|
||||
throw new DiplomacyException("converter_function must be a function.");
|
||||
}
|
||||
this.json_converter_function = json_converter_function || converter_function;
|
||||
}
|
||||
|
||||
validate(element: any): void {
|
||||
this.element_type_parser.validate(this.converter_function(element));
|
||||
}
|
||||
|
||||
update(element: any): any {
|
||||
return this.element_type_parser.update(this.converter_function(element));
|
||||
}
|
||||
|
||||
to_type(json_value: any): any {
|
||||
return this.element_type_parser.to_type(this.json_converter_function!(json_value));
|
||||
}
|
||||
|
||||
to_json(raw_value: any): any {
|
||||
// Raw value is already of the target type after conversion by the main class using this.
|
||||
// So, we directly pass it to the wrapped parser's to_json.
|
||||
// The converter_function is used when setting/updating the attribute on the object.
|
||||
return this.element_type_parser.to_json(raw_value);
|
||||
}
|
||||
}
|
||||
|
||||
export class StringableType extends ParserType {
|
||||
private use_from_string: boolean;
|
||||
|
||||
constructor(public element_type: { new(...args: any[]): any; from_string?: (s: string) => any; name: string }) {
|
||||
super();
|
||||
this.use_from_string = typeof this.element_type.from_string === 'function';
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.element_type.name;
|
||||
}
|
||||
|
||||
validate(element: any): void {
|
||||
if (!(element instanceof this.element_type)) {
|
||||
try {
|
||||
const element_to_str = this.to_json(element); // Converts to string via element.toString()
|
||||
const element_from_str = this.to_type(element_to_str); // Converts back via new() or from_string()
|
||||
const element_from_str_to_str = this.to_json(element_from_str);
|
||||
if (element_to_str !== element_from_str_to_str) {
|
||||
throw new TypeException(this.element_type.name, typeof element, "Value not consistently stringable/parsable.");
|
||||
}
|
||||
} catch (e) {
|
||||
throw new TypeException(this.element_type.name, typeof element, `Validation failed: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
to_type(json_value: string): any {
|
||||
if (typeof json_value !== 'string') throw new TypeException('string', typeof json_value);
|
||||
if (this.use_from_string) {
|
||||
return this.element_type.from_string!(json_value);
|
||||
}
|
||||
return new this.element_type(json_value);
|
||||
}
|
||||
|
||||
to_json(raw_value: any): string {
|
||||
if (!(raw_value instanceof this.element_type) && typeof raw_value?.toString !== 'function') {
|
||||
throw new TypeException(this.element_type.name, typeof raw_value);
|
||||
}
|
||||
return String(raw_value);
|
||||
}
|
||||
}
|
||||
|
||||
export class DictType extends ParserType {
|
||||
public key_type_parser: StringableType; // Keys must be stringable
|
||||
public val_type_parser: ParserType;
|
||||
|
||||
constructor(
|
||||
key_type: any, // Should be a constructor for a stringable type (String, Number, or class for StringableType)
|
||||
val_type: any,
|
||||
public dict_builder: ((dict: Record<string, any> | Map<any,any>) => any) | null = null
|
||||
) {
|
||||
super();
|
||||
this.key_type_parser = (key_type instanceof StringableType) ? key_type : new StringableType(key_type);
|
||||
this.val_type_parser = get_type(val_type);
|
||||
this.dict_builder = dict_builder || ((dict) => dict);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Record<${this.key_type_parser.toString()}, ${this.val_type_parser.toString()}>`;
|
||||
}
|
||||
|
||||
validate(element: any): void {
|
||||
if (!is_dictionary(element) && !(element instanceof Map)) {
|
||||
throw new TypeException('dictionary or Map', typeof element);
|
||||
}
|
||||
const entries = (element instanceof Map) ? element.entries() : Object.entries(element);
|
||||
for (const [key, value] of entries) {
|
||||
this.key_type_parser.validate(key); // Key should be validated against its original type before to_json for Map keys
|
||||
this.val_type_parser.validate(value);
|
||||
}
|
||||
}
|
||||
|
||||
update(element: any): any {
|
||||
const result_dict: Record<string, any> = {};
|
||||
const entries = (element instanceof Map) ? element.entries() : Object.entries(element);
|
||||
for (const [key, value] of entries) {
|
||||
result_dict[this.key_type_parser.update(key)] = this.val_type_parser.update(value);
|
||||
}
|
||||
return this.dict_builder!(result_dict);
|
||||
}
|
||||
|
||||
to_type(json_value: Record<string, any>): any {
|
||||
if (!is_dictionary(json_value)) throw new TypeException('object (dictionary)', typeof json_value);
|
||||
const result_dict: Record<string, any> = {};
|
||||
for (const key in json_value) {
|
||||
if (json_value.hasOwnProperty(key)) {
|
||||
result_dict[this.key_type_parser.to_type(key)] = this.val_type_parser.to_type(json_value[key]);
|
||||
}
|
||||
}
|
||||
return this.dict_builder!(result_dict);
|
||||
}
|
||||
|
||||
to_json(raw_value: Record<string, any> | Map<any,any>): Record<string, any> {
|
||||
if (!is_dictionary(raw_value) && !(raw_value instanceof Map)) {
|
||||
throw new TypeException('dictionary or Map', typeof raw_value);
|
||||
}
|
||||
const result_json_dict: Record<string, any> = {};
|
||||
const entries = (raw_value instanceof Map) ? raw_value.entries() : Object.entries(raw_value);
|
||||
for (const [key, value] of entries) {
|
||||
result_json_dict[this.key_type_parser.to_json(key)] = this.val_type_parser.to_json(value);
|
||||
}
|
||||
return result_json_dict;
|
||||
}
|
||||
}
|
||||
|
||||
export class IndexedSequenceType extends ParserType {
|
||||
public dict_type_parser: DictType;
|
||||
public sequence_type_parser: SequenceType;
|
||||
|
||||
constructor(dict_type: DictType, public key_name: string) {
|
||||
super();
|
||||
if (!(dict_type instanceof DictType)) {
|
||||
throw new DiplomacyException("IndexedSequenceType requires a DictType instance.");
|
||||
}
|
||||
this.dict_type_parser = dict_type;
|
||||
// The elements of the sequence are the values of the dictionary
|
||||
this.sequence_type_parser = new SequenceType(this.dict_type_parser.val_type_parser);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `IndexedSequence<${this.dict_type_parser.val_type_parser.toString()}, key: ${this.key_name}>`;
|
||||
}
|
||||
|
||||
validate(element: any): void { // Element in memory is a Map/object (dictionary)
|
||||
this.dict_type_parser.validate(element);
|
||||
}
|
||||
|
||||
update(element: any): any { // Element in memory is a Map/object
|
||||
return this.dict_type_parser.update(element);
|
||||
}
|
||||
|
||||
to_json(raw_value: Record<string, any> | Map<any, any>): any[] { // raw_value is a Map or object
|
||||
const values = (raw_value instanceof Map) ? Array.from(raw_value.values()) : Object.values(raw_value);
|
||||
return this.sequence_type_parser.to_json(values);
|
||||
}
|
||||
|
||||
to_type(json_value: any[]): any { // json_value is an array from JSON
|
||||
if (!Array.isArray(json_value)) throw new TypeException('array', typeof json_value);
|
||||
const loaded_sequence = this.sequence_type_parser.to_type(json_value);
|
||||
const result_dict: Record<string, any> = {};
|
||||
for (const element of loaded_sequence) {
|
||||
if (element && typeof element === 'object' && this.key_name in element) {
|
||||
const key = (element as any)[this.key_name];
|
||||
result_dict[this.key_type_parser.to_type(String(key))] = element; // Element is already to_type'd by sequence_type_parser
|
||||
} else {
|
||||
logger.warn(`IndexedSequenceType: Element missing key_name '${this.key_name}' or not an object.`, element);
|
||||
}
|
||||
}
|
||||
return this.dict_type_parser.dict_builder!(result_dict); // Apply dict_builder if any
|
||||
}
|
||||
}
|
||||
|
||||
export class EnumerationType extends ParserType {
|
||||
public enum_values: Set<any>;
|
||||
|
||||
constructor(enum_values: any[]) {
|
||||
super();
|
||||
if (!Array.isArray(enum_values) || enum_values.length === 0) {
|
||||
throw new DiplomacyException("EnumerationType requires a non-empty array of values.");
|
||||
}
|
||||
// Ensure all enum_values are primitives for reliable comparison
|
||||
enum_values.forEach(val => {
|
||||
if (typeof val !== 'string' && typeof val !== 'number' && typeof val !== 'boolean') {
|
||||
throw new DiplomacyException("EnumerationType values must be primitives (string, number, boolean).");
|
||||
}
|
||||
});
|
||||
this.enum_values = new Set(enum_values);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Enum<${Array.from(this.enum_values).map(String).join(" | ")}>`;
|
||||
}
|
||||
|
||||
validate(element: any): void {
|
||||
if (!this.enum_values.has(element)) {
|
||||
throw new ValueException(Array.from(this.enum_values), element);
|
||||
}
|
||||
}
|
||||
// to_type and to_json are default from ParserType (identity)
|
||||
}
|
||||
|
||||
export class SequenceOfPrimitivesType extends ParserType {
|
||||
public allowed_primitive_constructors: Function[]; // e.g. [String, Number]
|
||||
|
||||
constructor(seq_of_primitives: Function[]) {
|
||||
super();
|
||||
if (!Array.isArray(seq_of_primitives) || seq_of_primitives.length === 0) {
|
||||
throw new DiplomacyException("SequenceOfPrimitivesType requires a non-empty array of primitive constructors.");
|
||||
}
|
||||
seq_of_primitives.forEach(p => {
|
||||
if (![String, Number, Boolean, Object].includes(p)) {
|
||||
throw new DiplomacyException(`Invalid primitive constructor in SequenceOfPrimitivesType: ${p}`);
|
||||
}
|
||||
});
|
||||
this.allowed_primitive_constructors = seq_of_primitives;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Primitives<${this.allowed_primitive_constructors.map(p => p.name).join(" | ")}>`;
|
||||
}
|
||||
|
||||
validate(element: any): void {
|
||||
const typeOfElement = typeof element;
|
||||
const constructorOfElement = element?.constructor;
|
||||
|
||||
const isValid = this.allowed_primitive_constructors.some(primitiveConstructor => {
|
||||
if (primitiveConstructor === String && typeOfElement === 'string') return true;
|
||||
if (primitiveConstructor === Number && typeOfElement === 'number') return true;
|
||||
if (primitiveConstructor === Boolean && typeOfElement === 'boolean') return true;
|
||||
if (primitiveConstructor === Object && typeOfElement === 'object' && element !== null && !Array.isArray(element)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
throw new TypeException(this.allowed_primitive_constructors.map(p => p.name).join(' or '), constructorOfElement?.name || typeOfElement);
|
||||
}
|
||||
}
|
||||
// to_type and to_json are default from ParserType (identity)
|
||||
}
|
||||
|
||||
|
||||
// --- Helper Functions (update_model, extend_model) ---
|
||||
export function update_model(model: Record<string, any>, additional_keys: Record<string, any>, allow_duplicate_keys: boolean = true): Record<string, any> {
|
||||
if (!is_dictionary(model)) throw new TypeException("object", typeof model);
|
||||
if (!is_dictionary(additional_keys)) throw new TypeException("object", typeof additional_keys);
|
||||
|
||||
if (!allow_duplicate_keys) {
|
||||
assert_no_common_keys(model, additional_keys);
|
||||
}
|
||||
return { ...model, ...additional_keys };
|
||||
}
|
||||
|
||||
export function extend_model(model: Record<string, any>, additional_keys: Record<string, any>): Record<string, any> {
|
||||
return update_model(model, additional_keys, false);
|
||||
}
|
||||
194
diplomacy/utils/priority_dict.ts
Normal file
194
diplomacy/utils/priority_dict.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// diplomacy/utils/priority_dict.ts
|
||||
|
||||
type HeapEntry<K, V extends number> = [V, K, boolean]; // [priority, key, isValid]
|
||||
|
||||
export class PriorityDict<K, V extends number> {
|
||||
private heap: Array<HeapEntry<K, V>> = [];
|
||||
private entries: Map<K, HeapEntry<K, V>> = new Map();
|
||||
|
||||
constructor(initial?: Record<string | number, V> | Array<[K, V]>) {
|
||||
if (initial) {
|
||||
if (Array.isArray(initial)) { // Array of [key, priority] tuples
|
||||
for (const [key, priority] of initial) {
|
||||
this.set(key, priority);
|
||||
}
|
||||
} else { // Record object
|
||||
for (const key in initial) {
|
||||
if (Object.prototype.hasOwnProperty.call(initial, key)) {
|
||||
// We need to be careful about key type if K is not string or number
|
||||
this.set(key as any as K, initial[key]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _siftup(index: number): void {
|
||||
let parent = Math.floor((index - 1) / 2);
|
||||
while (index > 0 && this.heap[index]![0] < this.heap[parent]![0]) {
|
||||
[this.heap[index], this.heap[parent]] = [this.heap[parent]!, this.heap[index]!];
|
||||
index = parent;
|
||||
parent = Math.floor((index - 1) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
private _siftdown(index: number): void {
|
||||
const N = this.heap.length;
|
||||
while (true) {
|
||||
let leftChildIdx = 2 * index + 1;
|
||||
let rightChildIdx = 2 * index + 2;
|
||||
let smallest = index;
|
||||
|
||||
if (leftChildIdx < N && this.heap[leftChildIdx]![0] < this.heap[smallest]![0]) {
|
||||
smallest = leftChildIdx;
|
||||
}
|
||||
if (rightChildIdx < N && this.heap[rightChildIdx]![0] < this.heap[smallest]![0]) {
|
||||
smallest = rightChildIdx;
|
||||
}
|
||||
|
||||
if (smallest !== index) {
|
||||
[this.heap[index], this.heap[smallest]] = [this.heap[smallest]!, this.heap[index]!];
|
||||
index = smallest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _heappush(entry: HeapEntry<K, V>): void {
|
||||
this.heap.push(entry);
|
||||
this._siftup(this.heap.length - 1);
|
||||
}
|
||||
|
||||
private _heappop(): HeapEntry<K, V> | undefined {
|
||||
if (this.heap.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.heap.length === 1) {
|
||||
return this.heap.pop();
|
||||
}
|
||||
const top = this.heap[0];
|
||||
this.heap[0] = this.heap.pop()!;
|
||||
this._siftdown(0);
|
||||
return top;
|
||||
}
|
||||
|
||||
set(key: K, priority: V): void {
|
||||
if (this.entries.has(key)) {
|
||||
const oldEntry = this.entries.get(key)!;
|
||||
oldEntry[2] = false; // Mark old entry as invalid
|
||||
}
|
||||
const newEntry: HeapEntry<K, V> = [priority, key, true];
|
||||
this.entries.set(key, newEntry);
|
||||
this._heappush(newEntry);
|
||||
}
|
||||
|
||||
delete(key: K): boolean {
|
||||
if (this.entries.has(key)) {
|
||||
const entry = this.entries.get(key)!;
|
||||
entry[2] = false; // Mark as invalid
|
||||
this.entries.delete(key);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
// Python's __getitem__ raises KeyError if key not found or entry invalid.
|
||||
// Here, we return undefined for simplicity, typical in JS/TS Map.get.
|
||||
// To strictly match Python, one might throw an error if !entry || !entry[2].
|
||||
return (entry && entry[2]) ? entry[0] : undefined;
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
const entry = this.entries.get(key);
|
||||
return !!(entry && entry[2]);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.entries.size === 0;
|
||||
}
|
||||
|
||||
smallest(): [V, K] | null {
|
||||
while (this.heap.length > 0 && !this.heap[0]![2]) { // Check isValid flag
|
||||
this._heappop(); // Remove invalid entries from the top
|
||||
}
|
||||
if (this.heap.length > 0) {
|
||||
return [this.heap[0]![0], this.heap[0]![1]]; // [priority, key]
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
popSmallest(): [V, K] | null {
|
||||
let smallestEntry: HeapEntry<K,V> | undefined;
|
||||
do {
|
||||
smallestEntry = this._heappop();
|
||||
} while (smallestEntry && !smallestEntry[2]); // Loop until a valid entry is found or heap is empty
|
||||
|
||||
if (smallestEntry) {
|
||||
this.entries.delete(smallestEntry[1]);
|
||||
return [smallestEntry[0], smallestEntry[1]];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setDefault(key: K, defaultPriority: V): V {
|
||||
if (!this.has(key)) {
|
||||
this.set(key, defaultPriority);
|
||||
}
|
||||
return this.get(key)!; // Should exist now
|
||||
}
|
||||
|
||||
copy(): PriorityDict<K, V> {
|
||||
const newDict = new PriorityDict<K, V>();
|
||||
// Iterate over valid entries in the current dict to maintain priorities
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry[2]) { // if valid
|
||||
newDict.set(key, entry[0]); // entry[0] is priority
|
||||
}
|
||||
}
|
||||
return newDict;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.heap = [];
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
/** Iterate over keys in priority order. */
|
||||
*keys(): IterableIterator<K> {
|
||||
const tempCopy = this.copy(); // Use a copy to not modify the original during iteration
|
||||
while (!tempCopy.isEmpty()) {
|
||||
const smallest = tempCopy.popSmallest();
|
||||
if (smallest) {
|
||||
yield smallest[1]; // Yield key
|
||||
} else {
|
||||
break; // Should not happen if isEmpty is false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterate over values (priorities) in priority order. */
|
||||
*values(): IterableIterator<V> {
|
||||
for (const key of this.keys()) {
|
||||
yield this.get(key)!; // get(key) returns priority
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterate over [key, value (priority)] pairs in priority order. */
|
||||
*items(): IterableIterator<[K, V]> {
|
||||
for (const key of this.keys()) {
|
||||
yield [key, this.get(key)!];
|
||||
}
|
||||
}
|
||||
|
||||
// For compatibility with for...of loops directly on the PriorityDict instance
|
||||
[Symbol.iterator](): IterableIterator<K> {
|
||||
return this.keys();
|
||||
}
|
||||
}
|
||||
38
diplomacy/utils/scheduler_event.ts
Normal file
38
diplomacy/utils/scheduler_event.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// diplomacy/utils/scheduler_event.ts
|
||||
// Scheduler event describing scheduler state for a specific data.
|
||||
|
||||
import { Jsonable } from './jsonable';
|
||||
import { PrimitiveType } from './parsing'; // Using PrimitiveType for clarity in model
|
||||
|
||||
export interface SchedulerEventData {
|
||||
time_unit: number;
|
||||
time_added: number;
|
||||
delay: number;
|
||||
current_time: number;
|
||||
}
|
||||
|
||||
export class SchedulerEvent extends Jsonable {
|
||||
public time_unit: number;
|
||||
public time_added: number;
|
||||
public delay: number;
|
||||
public current_time: number;
|
||||
|
||||
// Define the model for Jsonable serialization/deserialization
|
||||
static model: Record<string, any> = {
|
||||
'time_unit': new PrimitiveType(Number),
|
||||
'time_added': new PrimitiveType(Number),
|
||||
'delay': new PrimitiveType(Number),
|
||||
'current_time': new PrimitiveType(Number)
|
||||
};
|
||||
|
||||
constructor(kwargs: Partial<SchedulerEventData> = {}) {
|
||||
// Initialize properties to default values first
|
||||
this.time_unit = 0;
|
||||
this.time_added = 0;
|
||||
this.delay = 0;
|
||||
this.current_time = 0;
|
||||
|
||||
// Let Jsonable constructor handle kwargs based on the model
|
||||
super(kwargs);
|
||||
}
|
||||
}
|
||||
309
diplomacy/utils/sorted_dict.ts
Normal file
309
diplomacy/utils/sorted_dict.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
// diplomacy/utils/sorted_dict.ts
|
||||
// Helper class to provide a dict with sorted keys.
|
||||
|
||||
import { is_dictionary } from './common';
|
||||
import { SortedSet } from './sorted_set';
|
||||
import { TypeException } from './exceptions';
|
||||
|
||||
// Define a type for the comparison function for SortedSet keys
|
||||
export type CompareFn<K> = (a: K, b: K) => number;
|
||||
|
||||
export class SortedDict<K, V> {
|
||||
private _keys: SortedSet<K>;
|
||||
private _couples: Map<K, V>;
|
||||
private _keyTypeConstructor: (new (...args: any[]) => K) | Function | undefined; // For potential runtime checks
|
||||
private _valueTypeConstructor: (new (...args: any[]) => V) | Function;
|
||||
|
||||
/**
|
||||
* Initialize a typed SortedDict.
|
||||
* @param keyTypeOrCompareFn Expected type constructor for keys (e.g., String, Number) or a custom compare function for keys.
|
||||
* @param valueTypeConstructor Expected type constructor for values.
|
||||
* @param initial Optional dictionary-like object or iterable of [K,V] pairs to initialize.
|
||||
*/
|
||||
constructor(
|
||||
keyTypeOrCompareFn: (new (...args: any[]) => K) | CompareFn<K> | Function, // Function as a broader type for constructors
|
||||
valueTypeConstructor: (new (...args: any[]) => V) | Function,
|
||||
initial?: Record<string | number, V> | Map<K, V> | Array<[K, V]> | null
|
||||
) {
|
||||
this._valueTypeConstructor = valueTypeConstructor;
|
||||
|
||||
let compareFn: CompareFn<K> | undefined;
|
||||
if (typeof keyTypeOrCompareFn === 'function' && keyTypeOrCompareFn.length === 2) {
|
||||
// It's likely a compare function if it takes two arguments
|
||||
compareFn = keyTypeOrCompareFn as CompareFn<K>;
|
||||
this._keyTypeConstructor = undefined; // No specific constructor if compareFn is given
|
||||
} else {
|
||||
this._keyTypeConstructor = keyTypeOrCompareFn as (new (...args: any[]) => K) | Function;
|
||||
// Default compareFn will be used by SortedSet if keyType is primitive
|
||||
}
|
||||
|
||||
this._keys = new SortedSet<K>([], compareFn); // Pass compareFn if provided
|
||||
this._couples = new Map<K, V>();
|
||||
|
||||
if (initial) {
|
||||
if (initial instanceof Map) {
|
||||
for (const [key, value] of initial) {
|
||||
this.put(key, value);
|
||||
}
|
||||
} else if (Array.isArray(initial)) { // Array of [K,V] pairs
|
||||
for (const [key, value] of initial) {
|
||||
this.put(key, value);
|
||||
}
|
||||
} else if (is_dictionary(initial)) { // Record<string|number, V>
|
||||
for (const key in initial) {
|
||||
if (Object.prototype.hasOwnProperty.call(initial, key)) {
|
||||
// This type assertion is tricky. If K is not string/number, this might be problematic.
|
||||
// User needs to ensure `initial` keys are compatible with K or provide a Map/Array of pairs.
|
||||
this.put(key as any as K, (initial as Record<string|number,V>)[key]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static builder<K, V>(
|
||||
keyTypeOrCompareFn: (new (...args: any[]) => K) | CompareFn<K> | Function,
|
||||
valueTypeConstructor: (new (...args: any[]) => V) | Function
|
||||
): (dictionary?: Record<string | number, V> | Map<K,V> | Array<[K,V]> | null) => SortedDict<K, V> {
|
||||
return (dictionary?: Record<string | number, V> | Map<K,V> | Array<[K,V]> | null) =>
|
||||
new SortedDict<K, V>(keyTypeOrCompareFn, valueTypeConstructor, dictionary);
|
||||
}
|
||||
|
||||
get keyType(): Function | undefined {
|
||||
return this._keyTypeConstructor;
|
||||
}
|
||||
|
||||
get valueType(): Function {
|
||||
return this._valueTypeConstructor;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const items = Array.from(this.items()).map(([k, v]) => `${String(k)}:${String(v)}`).join(', ');
|
||||
return `SortedDict{${items}}`;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._keys.size;
|
||||
}
|
||||
|
||||
public [Symbol.iterator](): IterableIterator<K> {
|
||||
return this._keys[Symbol.iterator]();
|
||||
}
|
||||
|
||||
equals(other: SortedDict<K, V>): boolean {
|
||||
if (!(other instanceof SortedDict)) return false;
|
||||
if (this.keyType !== other.keyType || this.valueType !== other.valueType) return false;
|
||||
if (this.size !== other.size) return false;
|
||||
|
||||
for (const key of this._keys) {
|
||||
if (!other.has(key) || this.get(key) !== other.get(key)) { // Simple equality check for values
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
get(key: K, defaultValue?: V): V | undefined {
|
||||
return this._couples.get(key) ?? defaultValue;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
// Runtime type check for value (optional, TypeScript handles compile-time)
|
||||
// if (this._valueTypeConstructor && !(value instanceof (this._valueTypeConstructor as any))) {
|
||||
// throw new TypeException(this._valueTypeConstructor.name, typeof value);
|
||||
// }
|
||||
this._keys.add(key);
|
||||
this._couples.set(key, value);
|
||||
}
|
||||
|
||||
// To align with Map interface and Python's __setitem__
|
||||
put(key: K, value: V): void {
|
||||
this.set(key, value);
|
||||
}
|
||||
|
||||
delete(key: K): V | null {
|
||||
if (this._couples.has(key)) {
|
||||
this._keys.remove(key);
|
||||
const value = this._couples.get(key)!;
|
||||
this._couples.delete(key);
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// To align with Python's remove
|
||||
remove(key: K): V | null {
|
||||
return this.delete(key);
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this._couples.has(key);
|
||||
}
|
||||
|
||||
firstKey(): K | undefined {
|
||||
return this._keys.at(0);
|
||||
}
|
||||
|
||||
firstValue(): V | undefined {
|
||||
const firstK = this.firstKey();
|
||||
return firstK !== undefined ? this._couples.get(firstK) : undefined;
|
||||
}
|
||||
|
||||
lastKey(): K | undefined {
|
||||
return this._keys.at(this._keys.size - 1);
|
||||
}
|
||||
|
||||
lastValue(): V | undefined {
|
||||
const lastK = this.lastKey();
|
||||
return lastK !== undefined ? this._couples.get(lastK) : undefined;
|
||||
}
|
||||
|
||||
lastItem(): [K, V] | undefined {
|
||||
const lastK = this.lastKey();
|
||||
if (lastK !== undefined) {
|
||||
return [lastK, this._couples.get(lastK)!];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
keys(): IterableIterator<K> {
|
||||
return this._keys[Symbol.iterator]();
|
||||
}
|
||||
|
||||
*values(): IterableIterator<V> {
|
||||
for (const key of this._keys) {
|
||||
yield this._couples.get(key)!;
|
||||
}
|
||||
}
|
||||
|
||||
*reversedValues(): IterableIterator<V> {
|
||||
for (const key of this._keys.reversed()) {
|
||||
yield this._couples.get(key)!;
|
||||
}
|
||||
}
|
||||
|
||||
*items(): IterableIterator<[K, V]> {
|
||||
for (const key of this._keys) {
|
||||
yield [key, this._couples.get(key)!];
|
||||
}
|
||||
}
|
||||
|
||||
*reversedItems(): IterableIterator<[K,V]> {
|
||||
for (const key of this._keys.reversed()) {
|
||||
yield [key, this._couples.get(key)!];
|
||||
}
|
||||
}
|
||||
|
||||
private _get_keys_interval(key_from?: K | null, key_to?: K | null): [number, number] {
|
||||
if (this.size === 0) return [0, -1];
|
||||
|
||||
let position_from: number | null;
|
||||
if (key_from === null || key_from === undefined) {
|
||||
position_from = 0;
|
||||
} else {
|
||||
position_from = this._keys.indexOf(key_from);
|
||||
if (position_from === null) { // key_from not in dict, find next
|
||||
const nextKey = this._keys.getNextValue(key_from);
|
||||
if (nextKey === null) return [0, -1]; // No key greater than key_from
|
||||
position_from = this._keys.indexOf(nextKey);
|
||||
}
|
||||
}
|
||||
|
||||
let position_to: number | null;
|
||||
if (key_to === null || key_to === undefined) {
|
||||
position_to = this.size - 1;
|
||||
} else {
|
||||
position_to = this._keys.indexOf(key_to);
|
||||
if (position_to === null) { // key_to not in dict, find previous
|
||||
const prevKey = this._keys.getPreviousValue(key_to);
|
||||
if (prevKey === null) return [0, -1]; // No key smaller than key_to
|
||||
position_to = this._keys.indexOf(prevKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (position_from === null || position_to === null || position_from > position_to) {
|
||||
return [0, -1]; // Invalid interval
|
||||
}
|
||||
return [position_from, position_to];
|
||||
}
|
||||
|
||||
|
||||
sub_keys(key_from?: K | null, key_to?: K | null): K[] {
|
||||
const [pos_from, pos_to] = this._get_keys_interval(key_from, key_to);
|
||||
if (pos_from > pos_to) return [];
|
||||
|
||||
const resultKeys: K[] = [];
|
||||
let currentIndex = 0;
|
||||
for (const key of this._keys) { // Iterate through SortedSet
|
||||
if (currentIndex >= pos_from && currentIndex <= pos_to) {
|
||||
resultKeys.push(key);
|
||||
}
|
||||
if (currentIndex > pos_to) break;
|
||||
currentIndex++;
|
||||
}
|
||||
return resultKeys;
|
||||
}
|
||||
|
||||
sub(key_from?: K | null, key_to?: K | null): V[] {
|
||||
const keys_in_interval = this.sub_keys(key_from, key_to);
|
||||
return keys_in_interval.map(key => this._couples.get(key)!);
|
||||
}
|
||||
|
||||
remove_sub(key_from?: K | null, key_to?: K | null): void {
|
||||
const keys_to_remove = this.sub_keys(key_from, key_to);
|
||||
for (const key of keys_to_remove) {
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
key_from_index(index: number): K | undefined {
|
||||
return this._keys.at(index);
|
||||
}
|
||||
|
||||
get_previous_key(key: K): K | null {
|
||||
return this._keys.getPreviousValue(key);
|
||||
}
|
||||
|
||||
get_next_key(key: K): K | null {
|
||||
return this._keys.getNextValue(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._keys.clear();
|
||||
this._couples.clear();
|
||||
}
|
||||
|
||||
fill(dict?: Record<string | number, V> | Map<K,V> | Array<[K,V]> | null): void {
|
||||
if (dict) {
|
||||
if (dict instanceof Map) {
|
||||
for (const [key, value] of dict) {
|
||||
this.put(key, value);
|
||||
}
|
||||
} else if (Array.isArray(dict)) {
|
||||
for (const [key, value] of dict) {
|
||||
this.put(key, value);
|
||||
}
|
||||
} else if (is_dictionary(dict)) {
|
||||
for (const key in dict) {
|
||||
if (Object.prototype.hasOwnProperty.call(dict, key)) {
|
||||
this.put(key as any as K, (dict as Record<string|number,V>)[key]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copy(): SortedDict<K, V> {
|
||||
// The constructor of SortedSet needs a compareFn if K is not primitive.
|
||||
// This copy method should ideally pass the original compareFn.
|
||||
// For now, assuming SortedSet handles this or K is primitive.
|
||||
const newDict = new SortedDict<K,V>(
|
||||
(this._keys as any).compareFn || this._keyTypeConstructor || String, // Pass compareFn or keyType
|
||||
this._valueTypeConstructor
|
||||
);
|
||||
for (const [key, value] of this.items()) {
|
||||
newDict.put(key, value);
|
||||
}
|
||||
return newDict;
|
||||
}
|
||||
}
|
||||
207
diplomacy/utils/sorted_set.ts
Normal file
207
diplomacy/utils/sorted_set.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// diplomacy/utils/sorted_set.ts
|
||||
// Sorted set implementation.
|
||||
|
||||
import { TypeException } from './exceptions'; // Assuming TypeException is defined
|
||||
import { is_sequence } from './common'; // Assuming is_sequence is defined
|
||||
|
||||
export class SortedSet<T> {
|
||||
private list: T[] = [];
|
||||
private compareFn: (a: T, b: T) => number;
|
||||
|
||||
/**
|
||||
* Initialize a typed sorted set.
|
||||
* @param content Optional. Sequence of values to initialize sorted set with.
|
||||
* @param compareFn Optional. A custom comparison function.
|
||||
* Defaults to basic <, >, === comparison suitable for numbers and strings.
|
||||
* For objects, a custom compareFn is highly recommended.
|
||||
*/
|
||||
constructor(content?: Iterable<T>, compareFn?: (a: T, b: T) => number) {
|
||||
this.compareFn = compareFn || SortedSet.defaultCompareFn;
|
||||
|
||||
if (content) {
|
||||
if (!is_sequence(content) && typeof (content as any)[Symbol.iterator] !== 'function') {
|
||||
throw new TypeException('Iterable', typeof content);
|
||||
}
|
||||
for (const element of content) {
|
||||
this.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static defaultCompareFn<T>(a: T, b: T): number {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find insertion point for x in a to maintain sorted order.
|
||||
* If x is already present in a, the insertion point will be before (to the left of) any existing entries.
|
||||
* @param value The value to find insertion point for.
|
||||
* @returns The index where value should be inserted.
|
||||
*/
|
||||
private _bisect_left(value: T): number {
|
||||
let low = 0;
|
||||
let high = this.list.length;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (this.compareFn(this.list[mid]!, value) < 0) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find insertion point for x in a to maintain sorted order.
|
||||
* If x is already present in a, the insertion point will be after (to the right of) any existing entries.
|
||||
* @param value The value to find insertion point for.
|
||||
* @returns The index where value should be inserted.
|
||||
*/
|
||||
private _bisect_right(value: T): number {
|
||||
let low = 0;
|
||||
let high = this.list.length;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (this.compareFn(value, this.list[mid]!) < 0) {
|
||||
high = mid;
|
||||
} else {
|
||||
low = mid + 1;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
static builder<T>(compareFn?: (a: T, b: T) => number): (iterable?: Iterable<T>) => SortedSet<T> {
|
||||
return (iterable?: Iterable<T>) => new SortedSet<T>(iterable, compareFn);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `SortedSet(${this.list.map(String).join(', ')})`;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.list.length;
|
||||
}
|
||||
|
||||
equals(other: SortedSet<T>): boolean {
|
||||
if (!(other instanceof SortedSet)) return false;
|
||||
if (this.size !== other.size) return false;
|
||||
// Assuming same compareFn implies structural equality for this check.
|
||||
// A stricter check might involve comparing compareFn references if that's critical.
|
||||
for (let i = 0; i < this.list.length; i++) {
|
||||
if (this.compareFn(this.list[i]!, other.list[i]!) !== 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
at(index: number): T | undefined {
|
||||
return this.list[index];
|
||||
}
|
||||
|
||||
[Symbol.iterator](): IterableIterator<T> {
|
||||
return this.list[Symbol.iterator]();
|
||||
}
|
||||
|
||||
*reversed(): IterableIterator<T> {
|
||||
for (let i = this.list.length - 1; i >= 0; i--) {
|
||||
yield this.list[i]!;
|
||||
}
|
||||
}
|
||||
|
||||
has(element: T): boolean {
|
||||
if (this.list.length === 0) return false;
|
||||
const position = this._bisect_left(element);
|
||||
return position < this.list.length && this.compareFn(this.list[position]!, element) === 0;
|
||||
}
|
||||
|
||||
add(element: T): number | undefined {
|
||||
const position = this._bisect_left(element);
|
||||
if (position === this.list.length || this.compareFn(this.list[position]!, element) !== 0) {
|
||||
this.list.splice(position, 0, element);
|
||||
return position;
|
||||
}
|
||||
return undefined; // Element already exists
|
||||
}
|
||||
|
||||
getNextValue(element: T): T | null {
|
||||
if (this.list.length === 0) return null;
|
||||
const position = this._bisect_right(element);
|
||||
if (position < this.list.length) {
|
||||
// If element itself is present, bisect_right gives the index after it.
|
||||
// If element is not present, bisect_right gives the index where it would be inserted (i.e., the next greater element).
|
||||
return this.list[position]!;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getPreviousValue(element: T): T | null {
|
||||
if (this.list.length === 0) return null;
|
||||
const position = this._bisect_left(element);
|
||||
if (position > 0) {
|
||||
return this.list[position - 1]!;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pop(index: number): T | undefined {
|
||||
if (index < 0 || index >= this.list.length) return undefined;
|
||||
return this.list.splice(index, 1)[0];
|
||||
}
|
||||
|
||||
remove(element: T): T | null {
|
||||
const position = this._bisect_left(element);
|
||||
if (position < this.list.length && this.compareFn(this.list[position]!, element) === 0) {
|
||||
return this.list.splice(position, 1)[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
indexOf(element: T): number | null {
|
||||
const position = this._bisect_left(element);
|
||||
if (position < this.list.length && this.compareFn(this.list[position]!, element) === 0) {
|
||||
return position;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.list = [];
|
||||
}
|
||||
|
||||
// --- Set operations ---
|
||||
// These will return new SortedSet instances. They assume 'other' is also a SortedSet.
|
||||
// For simplicity, using the default or same compareFn.
|
||||
|
||||
union(other: SortedSet<T>): SortedSet<T> {
|
||||
const newSet = new SortedSet<T>([...this.list], this.compareFn);
|
||||
for (const elem of other) {
|
||||
newSet.add(elem);
|
||||
}
|
||||
return newSet;
|
||||
}
|
||||
|
||||
intersection(other: SortedSet<T>): SortedSet<T> {
|
||||
const newSet = new SortedSet<T>([], this.compareFn);
|
||||
for (const elem of this.list) {
|
||||
if (other.has(elem)) {
|
||||
newSet.add(elem);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
}
|
||||
|
||||
difference(other: SortedSet<T>): SortedSet<T> {
|
||||
const newSet = new SortedSet<T>([], this.compareFn);
|
||||
for (const elem of this.list) {
|
||||
if (!other.has(elem)) {
|
||||
newSet.add(elem);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
}
|
||||
}
|
||||
209
diplomacy/utils/splitter.ts
Normal file
209
diplomacy/utils/splitter.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
// diplomacy/utils/splitter.ts
|
||||
// Contains utils to retrieve splitted subjects fields
|
||||
|
||||
export abstract class AbstractStringSplitter {
|
||||
protected _input_str: string;
|
||||
protected _parts: (string | null)[];
|
||||
protected _last_index: number = 0;
|
||||
private _length: number;
|
||||
|
||||
constructor(input: string | string[], length: number) {
|
||||
this._input_str = Array.isArray(input) ? input.join(' ') : input;
|
||||
this._parts = new Array(length).fill(null);
|
||||
this._length = length; // Store length for internal use if needed, though _parts.length serves this.
|
||||
this._split();
|
||||
}
|
||||
|
||||
get input_str(): string {
|
||||
return this._input_str;
|
||||
}
|
||||
|
||||
get parts(): (string | null)[] {
|
||||
return this._parts.slice(0, this._last_index);
|
||||
}
|
||||
|
||||
join(): string {
|
||||
return this.parts.filter(p => p !== null).join(' ');
|
||||
}
|
||||
|
||||
protected abstract _split(): void;
|
||||
|
||||
public getLength(): number { // Equivalent to Python's __len__
|
||||
return this._last_index;
|
||||
}
|
||||
}
|
||||
|
||||
export class OrderSplitter extends AbstractStringSplitter {
|
||||
private _unit_index: number | null = null;
|
||||
private _order_type_index: number | null = null;
|
||||
private _supported_unit_index: number | null = null;
|
||||
private _support_order_type_index: number | null = null;
|
||||
private _destination_index: number | null = null;
|
||||
private _via_flag_index: number | null = null;
|
||||
|
||||
constructor(input: string | string[]) {
|
||||
super(input, 6); // Max 6 parts for an order based on Python example
|
||||
}
|
||||
|
||||
get unit(): string | null {
|
||||
return this._unit_index !== null ? this._parts[this._unit_index] : null;
|
||||
}
|
||||
set unit(value: string | null) {
|
||||
if (this._unit_index === null) {
|
||||
this._unit_index = this._last_index++;
|
||||
}
|
||||
this._parts[this._unit_index] = value;
|
||||
}
|
||||
|
||||
get order_type(): string | null {
|
||||
return this._order_type_index !== null ? this._parts[this._order_type_index] : null;
|
||||
}
|
||||
set order_type(value: string | null) {
|
||||
if (this._order_type_index === null) {
|
||||
this._order_type_index = this._last_index++;
|
||||
}
|
||||
this._parts[this._order_type_index] = value;
|
||||
}
|
||||
|
||||
get supported_unit(): string | null {
|
||||
return this._supported_unit_index !== null ? this._parts[this._supported_unit_index] : null;
|
||||
}
|
||||
set supported_unit(value: string | null) {
|
||||
if (this._supported_unit_index === null) {
|
||||
this._supported_unit_index = this._last_index++;
|
||||
}
|
||||
this._parts[this._supported_unit_index] = value;
|
||||
}
|
||||
|
||||
get support_order_type(): string | null {
|
||||
return this._support_order_type_index !== null ? this._parts[this._support_order_type_index] : null;
|
||||
}
|
||||
set support_order_type(value: string | null) {
|
||||
if (this._support_order_type_index === null) {
|
||||
this._support_order_type_index = this._last_index++;
|
||||
}
|
||||
this._parts[this._support_order_type_index] = value;
|
||||
}
|
||||
|
||||
get destination(): string | null {
|
||||
return this._destination_index !== null ? this._parts[this._destination_index] : null;
|
||||
}
|
||||
set destination(value: string | null) {
|
||||
if (this._destination_index === null) {
|
||||
this._destination_index = this._last_index++;
|
||||
}
|
||||
this._parts[this._destination_index] = value;
|
||||
}
|
||||
|
||||
get via_flag(): string | null {
|
||||
return this._via_flag_index !== null ? this._parts[this._via_flag_index] : null;
|
||||
}
|
||||
set via_flag(value: string | null) {
|
||||
if (this._via_flag_index === null) {
|
||||
this._via_flag_index = this._last_index++;
|
||||
}
|
||||
this._parts[this._via_flag_index] = value;
|
||||
}
|
||||
|
||||
protected _split(): void {
|
||||
const words = typeof this._input_str === 'string' ? this._input_str.trim().split(/\s+/) : [...this._input_str];
|
||||
|
||||
if (words.length === 1) {
|
||||
this.order_type = words.pop()!;
|
||||
return;
|
||||
}
|
||||
if (words.length < 2 && words.length !==1) return; // Not enough parts for a unit
|
||||
|
||||
this.unit = `${words.shift()} ${words.shift()}`;
|
||||
if (words.length === 0) { // Implicit hold, e.g. "A PAR"
|
||||
this.order_type = "H"; // Default to Hold
|
||||
return;
|
||||
}
|
||||
|
||||
this.order_type = words.shift()!;
|
||||
|
||||
if (this.order_type === '-' || this.order_type === 'R') { // Move or Retreat
|
||||
if (words.length > 0) this.destination = words.shift()!; // Python used pop, which takes from end. Here shift from start.
|
||||
// Order syntax usually is Unit Loc Op Dest ...
|
||||
} else if (this.order_type === 'S' || this.order_type === 'C') { // Support or Convoy
|
||||
if (words.length >= 2) {
|
||||
this.supported_unit = `${words.shift()} ${words.shift()}`;
|
||||
}
|
||||
if (words.length > 0) {
|
||||
this.support_order_type = words.shift()!; // This is actually the '-' for move or target for hold
|
||||
if (this.support_order_type === '-') { // Support to move or Convoy
|
||||
if (words.length > 0) this.destination = words.shift()!;
|
||||
} else { // Support to Hold, support_order_type is actually the destination
|
||||
this.destination = this.support_order_type;
|
||||
this.support_order_type = null; // No separate support_order_type for S H
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build 'B' and Disband 'D' orders are simpler: Unit Loc Op
|
||||
// e.g. A PAR B. Unit: A PAR, Op: B. No other parts needed typically by this splitter.
|
||||
// The Python version's examples show this structure.
|
||||
|
||||
if (words.length > 0 && words[words.length -1 ] === 'VIA') { // Check last remaining element
|
||||
this.via_flag = words.pop()!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PhaseSplitter extends AbstractStringSplitter {
|
||||
private _season_index: number | null = null;
|
||||
private _year_index: number | null = null;
|
||||
private _phase_type_index: number | null = null;
|
||||
|
||||
constructor(input: string) { // Phase string like S1901M
|
||||
super(input, 3);
|
||||
}
|
||||
|
||||
get season(): string | null {
|
||||
return this._season_index !== null ? this._parts[this._season_index] : null;
|
||||
}
|
||||
set season(value: string | null) {
|
||||
if (this._season_index === null) this._season_index = this._last_index++;
|
||||
this._parts[this._season_index] = value;
|
||||
}
|
||||
|
||||
get year(): number | null {
|
||||
const val = this._year_index !== null ? this._parts[this._year_index] : null;
|
||||
return val !== null ? parseInt(val, 10) : null;
|
||||
}
|
||||
set year(value: number | null) {
|
||||
if (this._year_index === null) this._year_index = this._last_index++;
|
||||
this._parts[this._year_index] = value !== null ? String(value) : null;
|
||||
}
|
||||
|
||||
get phase_type(): string | null {
|
||||
return this._phase_type_index !== null ? this._parts[this._phase_type_index] : null;
|
||||
}
|
||||
set phase_type(value: string | null) {
|
||||
if (this._phase_type_index === null) this._phase_type_index = this._last_index++;
|
||||
this._parts[this._phase_type_index] = value;
|
||||
}
|
||||
|
||||
protected _split(): void {
|
||||
if (this._input_str && this._input_str.length >= 4) { // e.g. S1901M (min length)
|
||||
this.season = this._input_str[0];
|
||||
// Year can be 2 digits (01) or 4 digits (1901)
|
||||
// The Python code `int(self._input_str[1:-1])` implies year is everything between first and last char.
|
||||
const yearMatch = this._input_str.substring(1, this._input_str.length -1).match(/\d+/);
|
||||
if (yearMatch) {
|
||||
this.year = parseInt(yearMatch[0], 10);
|
||||
} else {
|
||||
// Handle error or default if year is not found, e.g. for "FORMING"
|
||||
this.year = null;
|
||||
}
|
||||
this.phase_type = this._input_str[this._input_str.length - 1];
|
||||
} else {
|
||||
// Handle cases like "FORMING", "COMPLETED" or invalid short strings
|
||||
// For "FORMING" or "COMPLETED", specific setters might not be called.
|
||||
// This splitter is primarily for seasonal phases.
|
||||
// To make it robust for "FORMING", etc., we could set one part:
|
||||
if (this._input_str === "FORMING" || this._input_str === "COMPLETED") {
|
||||
this.phase_type = this._input_str; // Or season, depending on desired output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
diplomacy/utils/time.ts
Normal file
165
diplomacy/utils/time.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// ==============================================================================
|
||||
// Copyright (C) 2019 - Philip Paquette
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify it under
|
||||
// the terms of the GNU Affero General Public License as published by the Free
|
||||
// Software Foundation, either version 3 of the License, or (at your option) any
|
||||
// later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
// details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License along
|
||||
// with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* Time functions
|
||||
* - Contains generic time functions (e.g. to calculate deadlines)
|
||||
*/
|
||||
import { utcToZonedTime, zonedToUtc } from 'date-fns-tz';
|
||||
import { getUnixTime, fromUnixTime, getHours, getMinutes, getSeconds, differenceInSeconds } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Converts a time in format 00W00D00H00M00S in number of seconds
|
||||
* @param offsetStr The string to convert (e.g. '20D')
|
||||
* @returns Its equivalent in seconds = 1728000
|
||||
*/
|
||||
export function strToSeconds(offsetStr: string): number {
|
||||
const mult: { [key: string]: number } = { 'W': 7 * 24 * 60 * 60, 'D': 24 * 60 * 60, 'H': 60 * 60, 'M': 60, 'S': 1, ' ': 1 };
|
||||
let buffer = 0;
|
||||
let currentSum = 0;
|
||||
const str = String(offsetStr);
|
||||
|
||||
for (const char of str) {
|
||||
if (char >= '0' && char <= '9') {
|
||||
buffer = buffer * 10 + parseInt(char, 10);
|
||||
} else if (char.toUpperCase() in mult) {
|
||||
currentSum += buffer * mult[char.toUpperCase()];
|
||||
buffer = 0;
|
||||
} else {
|
||||
buffer = 0;
|
||||
}
|
||||
}
|
||||
currentSum += buffer;
|
||||
return currentSum;
|
||||
}
|
||||
|
||||
function getMidnightTsReference(): number {
|
||||
const serverNow = new Date();
|
||||
const midnightUtcOfServerCurrentDay = new Date(Date.UTC(
|
||||
serverNow.getUTCFullYear(),
|
||||
serverNow.getUTCMonth(),
|
||||
serverNow.getUTCDate(),
|
||||
0, 0, 0, 0
|
||||
));
|
||||
return getUnixTime(midnightUtcOfServerCurrentDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates time at a specific interval (e.g. 20M) (i.e. Rounds to the next :20, :40, :60)
|
||||
*
|
||||
* Note: The reference "day" for truncation (midnight_ts) is based on the server's current UTC day,
|
||||
* matching the behavior of Python's `datetime.date.today()` in the original script.
|
||||
*
|
||||
* @param timestamp The unix epoch to truncate (e.g. 1498746120)
|
||||
* @param truncInterval The truncation interval (e.g. 60*60 or '1H')
|
||||
* @param timeZone The time to use for conversion (defaults to GMT otherwise, which is UTC)
|
||||
* @returns A timestamp truncated to the nearest (future) interval
|
||||
*/
|
||||
export function truncTime(timestamp: number, truncInterval: string | number, timeZone: string = 'GMT'): number {
|
||||
const intervalSeconds = typeof truncInterval === 'string' ? strToSeconds(truncInterval) : truncInterval;
|
||||
if (intervalSeconds === 0) return timestamp;
|
||||
|
||||
const originalDateUtc = fromUnixTime(timestamp);
|
||||
const zonedDate = timeZone === 'GMT' ? originalDateUtc : utcToZonedTime(originalDateUtc, timeZone);
|
||||
|
||||
const zonedDateAsUtc = zonedToUtc(zonedDate, timeZone);
|
||||
const tzOffsetSeconds = differenceInSeconds(zonedDate, zonedDateAsUtc);
|
||||
|
||||
const midnightTsRef = getMidnightTsReference();
|
||||
const rawMidnightOffset = timestamp - midnightTsRef;
|
||||
const midnightOffset = ((rawMidnightOffset % (24 * 3600)) + (24 * 3600)) % (24 * 3600); // JS modulo fix for negatives
|
||||
|
||||
const truncOffset = Math.ceil((midnightOffset + tzOffsetSeconds) / intervalSeconds) * intervalSeconds;
|
||||
const truncTs = timestamp - midnightOffset + truncOffset - tzOffsetSeconds;
|
||||
|
||||
return Math.floor(truncTs);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the next timestamp at a specific 'hh:mm'
|
||||
*
|
||||
* Note: The reference "day" (midnight_ts) is based on the server's current UTC day,
|
||||
* matching the behavior of Python's `datetime.date.today()` in the original script.
|
||||
*
|
||||
* @param timestamp The unix timestamp to convert
|
||||
* @param timeAt The next 'hh:mm' to have the time rounded to (e.g., "14:30"), or a string compatible with strToSeconds, or 0 to skip
|
||||
* @param timeZone The time to use for conversion (defaults to GMT otherwise, which is UTC)
|
||||
* @returns A timestamp at the nearest (future) hh:mm
|
||||
*/
|
||||
export function nextTimeAt(timestamp: number, timeAt: string | number, timeZone: string = 'GMT'): number {
|
||||
if (!timeAt && timeAt !== 0) { // Allow 0 as a valid input for timeAt if it means 0 seconds past midnight
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
let targetSecondsPastMidnightRef: number;
|
||||
if (typeof timeAt === 'string' && timeAt.includes(':')) {
|
||||
const parts = timeAt.split(':');
|
||||
const hours = parseInt(parts[0], 10);
|
||||
const minutes = parseInt(parts[1], 10);
|
||||
targetSecondsPastMidnightRef = hours * 3600 + minutes * 60;
|
||||
} else if (typeof timeAt === 'string') {
|
||||
targetSecondsPastMidnightRef = strToSeconds(timeAt);
|
||||
} else { // number
|
||||
targetSecondsPastMidnightRef = timeAt;
|
||||
}
|
||||
|
||||
// Normalize targetSecondsPastMidnightRef to be within a 0 to 24*3600 range
|
||||
// This mirrors the Python logic's effective behavior with modulo in `at_offset` calculation.
|
||||
if (targetSecondsPastMidnightRef < 0 || targetSecondsPastMidnightRef >= 24 * 3600) {
|
||||
targetSecondsPastMidnightRef = ((targetSecondsPastMidnightRef % (24 * 3600)) + (24 * 3600)) % (24 * 3600);
|
||||
}
|
||||
|
||||
const originalDateUtc = fromUnixTime(timestamp);
|
||||
const zonedDate = timeZone === 'GMT' ? originalDateUtc : utcToZonedTime(originalDateUtc, timeZone);
|
||||
|
||||
const zonedDateAsUtc = zonedToUtc(zonedDate, timeZone);
|
||||
const tzOffsetSeconds = differenceInSeconds(zonedDate, zonedDateAsUtc);
|
||||
|
||||
const midnightTsRef = getMidnightTsReference();
|
||||
const rawMidnightOffset = timestamp - midnightTsRef;
|
||||
const midnightOffset = ((rawMidnightOffset % (24 * 3600)) + (24 * 3600)) % (24 * 3600); // JS modulo fix
|
||||
|
||||
// Python: at_offset = (-midnight_offset + interval - tz_offset) % (24 * 3600)
|
||||
// interval here is targetSecondsPastMidnightRef
|
||||
let atOffset = (-midnightOffset + targetSecondsPastMidnightRef - tzOffsetSeconds);
|
||||
atOffset = ((atOffset % (24*3600)) + (24*3600)) % (24*3600);
|
||||
|
||||
// at_ts = timestamp + at_offset (Python)
|
||||
// The at_offset is the duration to add to the current timestamp's position *within the server's current day structure*
|
||||
// to reach the target time.
|
||||
// The Python code is: timestamp + ((- ( (timestamp - midnight_UTC_server_day_start) % 86400 ) + target_time_of_day_seconds - tz_offset_seconds) % 86400 )
|
||||
// This seems to calculate an offset to add to the original timestamp to hit the target time of day.
|
||||
// The crucial part is that `at_offset` is calculated based on `midnight_offset` which is `timestamp` relative to `midnightTsRef` (server's current day).
|
||||
// So, `at_offset` is the duration to add to `timestamp` to get it to the desired `time_at` in the context of the `time_zone`,
|
||||
// ensuring it's the *next* occurrence.
|
||||
|
||||
const atTs = timestamp + atOffset;
|
||||
|
||||
// If atTs is still behind timestamp (e.g. target time was 10:00, current is 11:00, at_offset might be -1h)
|
||||
// then we need to add a day. The modulo arithmetic in Python's at_offset handles this implicitly.
|
||||
// If (target_seconds_in_day - current_seconds_in_day_for_timestamp) is negative, it means target is "earlier" today.
|
||||
// Python's % (24*3600) makes it positive, effectively pushing it to the "next day" if needed.
|
||||
// My atOffset calculation with ((X % M) + M) % M ensures it's a positive offset to add.
|
||||
// Let's test:
|
||||
// timestamp = 11:00. target_time_of_day = 10:00. tz_offset=0. midnight_offset for 11:00 is 11*3600.
|
||||
// at_offset = (-(11*3600) + (10*3600) - 0) = -3600.
|
||||
// at_offset_mod = ((-3600 % 86400) + 86400) % 86400 = (-3600 + 86400) % 86400 = 82800. (23 hours)
|
||||
// So, 11:00 + 23 hours = 10:00 next day. This is correct.
|
||||
|
||||
return Math.floor(atTs);
|
||||
}
|
||||
1
jest.config.js
Normal file
1
jest.config.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
module.exports = { preset: 'ts-jest', testEnvironment: 'node' };
|
||||
3908
package-lock.json
generated
3908
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -2,13 +2,24 @@
|
|||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.53.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@xmldom/xmldom": "^0.9.8",
|
||||
"cheerio": "^1.1.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"csv-writer": "^1.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"openai": "^5.1.1",
|
||||
"tough-cookie": "^5.1.2",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.18.1"
|
||||
"@types/cheerio": "^1.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/tough-cookie": "^4.0.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/xmldom": "^0.1.34",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.3.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue