From 9385d18cb7cad5b25d511345771cae45afb73915 Mon Sep 17 00:00:00 2001 From: Mohit Kulkarni Date: Thu, 22 Oct 2026 15:34:06 -0400 Subject: [PATCH] Complete simultaneous-order simulator and enforce game invariants --- docs/development/full-order-interface.md | 60 ++ pufferlib/ocean/diplomacy/__init__.py | 4 +- pufferlib/ocean/diplomacy/binding.c | 76 ++- pufferlib/ocean/diplomacy/diplomacy.c | 157 ++++-- pufferlib/ocean/diplomacy/diplomacy.py | 26 +- pufferlib/ocean/diplomacy/diplomacy_map.c | 1 + pufferlib/ocean/diplomacy/game.py | 257 +++++++++ tests/diplomacy/c_tests/test_c_binding.py | 10 +- tests/diplomacy/c_tests/test_c_game_state.py | 27 +- tests/diplomacy/c_tests/test_c_map.py | 30 +- tests/diplomacy/c_tests/test_c_welfare.py | 78 +-- tests/diplomacy/c_tests/test_complete_game.py | 92 ++++ .../c_tests/test_simple_adjudication.py | 3 - tests/diplomacy/reference/README.md | 6 + tests/diplomacy/reference/standard.map | 213 +++++++ tests/test_diplomacy.py | 521 +++++++----------- 16 files changed, 1108 insertions(+), 453 deletions(-) create mode 100644 docs/development/full-order-interface.md create mode 100644 pufferlib/ocean/diplomacy/game.py create mode 100644 tests/diplomacy/c_tests/test_complete_game.py create mode 100644 tests/diplomacy/reference/README.md create mode 100644 tests/diplomacy/reference/standard.map diff --git a/docs/development/full-order-interface.md b/docs/development/full-order-interface.md new file mode 100644 index 0000000000..d45fdb10fb --- /dev/null +++ b/docs/development/full-order-interface.md @@ -0,0 +1,60 @@ +# Complete-game interface + +`pufferlib.ocean.diplomacy.Game` is the public simultaneous-order simulator. +It uses the existing C adjudicator, not a second Python implementation. A step +accepts `{POWER: [order, ...]}` for all seven powers. Missing movement orders +hold, missing retreats disband, and empty Winter lists waive builds. Supply +centers transfer after Fall movement/retreats. Welfare is accumulated only after +Winter, following paper section 2.2 and the local rule-source priority. + +Observations are complete structured public state, including all 82 internal map +entries, actor identity, exact unit slots, owners, homes, and dislodged units with +retreat destinations. This avoids silently changing the older numeric ABI. +`possible_orders(power)` returns local candidates. Simultaneous supports and +convoys can fail depending on other orders; Winter order sets still need to obey +build counts and choose either builds or disbands. Syntax errors and duplicate +unit/province orders are rejected before any pending orders are modified. Other +illegal game orders are adjudicated as VOID; they are not Python exceptions. + +The legacy `Diplomacy` / PufferLib 4 CPU `Discrete(1088)` interface is retained +for compatibility and trainer integration tests. It selects one unit per power +and has incomplete order expressivity and observations. It is not the complete +research action interface. Do not label its MLP smoke a learned baseline. + +Game API: + +```python +from pufferlib.ocean.diplomacy import Game +with Game(max_years=10, seed=42) as game: + while not game.state['terminated']: + orders = game.scripted_orders(seed=game.state['year']) + observations, rewards, terminated, truncated, info = game.step(orders) + print(game.render()) + game.save_replay('game.json') +with Game.from_replay('game.json') as restored: + print(restored.state_hash()) +``` + +Replays record initial configuration and complete submitted orders, then verify +the final state hash on loading. They do not capture private changes made through +raw test/debug bindings. No press or negotiation layer is supplied. The example +scripted policy demonstrates the interface and is not a strong opponent. + +## Verification and test corrections + +The 46 previously skipped cases were audited. Twenty-six were empty `pass` +placeholders; others assumed an obsolete dictionary observation space, incorrectly +expected three Russian units, or exercised a no-op renderer. They did not provide +coverage. Their intended behaviors now have executable checks in the C and public +Game suites. The three previously skipped support/circular tests were enabled +unchanged after independent reproduction. Existing DATC expectations were not +changed. The numeric ABI tests retain their existing shapes. + +The unchanged pinned `standard.map` fixture (reference commit +60520aaa4661e4c95abfd30ce6483990bb127567) verifies every direct army base-province +and fleet edge. Additional regressions cover actual Fall capture/reset, legal +home/coast builds, support reachability to destination, convoy endpoints, typed +disbands and retreats, occupied retreat destinations, opposite-coast retreat +conflicts, result mapping with reversed order submission, mixed adjustment sets, +complete seeded games and replay. Passing tests are evidence for these cases, +not a proof of all Diplomacy paradoxes or performance at scale. diff --git a/pufferlib/ocean/diplomacy/__init__.py b/pufferlib/ocean/diplomacy/__init__.py index 45a35e36df..aa76903bfc 100644 --- a/pufferlib/ocean/diplomacy/__init__.py +++ b/pufferlib/ocean/diplomacy/__init__.py @@ -7,4 +7,6 @@ from pufferlib.ocean.diplomacy.diplomacy import Diplomacy -__all__ = ['Diplomacy'] +__all__ = ['Diplomacy', 'Game', 'POWERS'] + +from .game import Game, POWERS diff --git a/pufferlib/ocean/diplomacy/binding.c b/pufferlib/ocean/diplomacy/binding.c index 426b71a23b..14e7905663 100644 --- a/pufferlib/ocean/diplomacy/binding.c +++ b/pufferlib/ocean/diplomacy/binding.c @@ -55,6 +55,14 @@ static int my_log(PyObject* dict, Log* log) { // Custom Python Methods for Game State Queries // ============================================================================ +// Dict insertion retains its value; these helpers consume newly-created references. +static int set_owned(PyObject* dict, const char* key, PyObject* value) { + if (!value) return -1; + int result = PyDict_SetItemString(dict, key, value); + Py_DECREF(value); + return result; +} + static PyObject* query_game_state(PyObject* self, PyObject* args) { PyObject* handle_obj; if (!PyArg_ParseTuple(args, "O", &handle_obj)) { @@ -71,16 +79,17 @@ static PyObject* query_game_state(PyObject* self, PyObject* args) { // Return dict with game state info PyObject* state_dict = PyDict_New(); - PyDict_SetItemString(state_dict, "year", PyLong_FromLong(get_current_year(game))); - PyDict_SetItemString(state_dict, "phase", PyLong_FromLong((long)get_current_phase(game))); + set_owned(state_dict, "year", PyLong_FromLong(get_current_year(game))); + set_owned(state_dict, "phase", PyLong_FromLong((long)get_current_phase(game))); + set_owned(state_dict, "is_game_over", PyBool_FromLong(game->is_game_over)); // Add power info PyObject* powers_list = PyList_New(MAX_POWERS); for (int p = 0; p < MAX_POWERS; p++) { PyObject* power_dict = PyDict_New(); - PyDict_SetItemString(power_dict, "num_units", PyLong_FromLong(get_num_units(game, p))); - PyDict_SetItemString(power_dict, "num_centers", PyLong_FromLong(get_num_centers(game, p))); - PyDict_SetItemString(power_dict, "welfare_points", PyLong_FromLong(get_welfare_points(game, p))); + set_owned(power_dict, "num_units", PyLong_FromLong(get_num_units(game, p))); + set_owned(power_dict, "num_centers", PyLong_FromLong(get_num_centers(game, p))); + set_owned(power_dict, "welfare_points", PyLong_FromLong(get_welfare_points(game, p))); // Add unit locations PyObject* units_list = PyList_New(get_num_units(game, p)); @@ -89,11 +98,12 @@ static PyObject* query_game_state(PyObject* self, PyObject* args) { int location; get_unit_info(game, p, u, &type, &location); PyObject* unit_dict = PyDict_New(); - PyDict_SetItemString(unit_dict, "type", PyLong_FromLong((long)type)); - PyDict_SetItemString(unit_dict, "location", PyLong_FromLong(location)); + set_owned(unit_dict, "type", PyLong_FromLong((long)type)); + set_owned(unit_dict, "location", PyLong_FromLong(location)); PyList_SetItem(units_list, u, unit_dict); } PyDict_SetItemString(power_dict, "units", units_list); + Py_DECREF(units_list); // Add center locations int centers[MAX_UNITS]; @@ -104,10 +114,12 @@ static PyObject* query_game_state(PyObject* self, PyObject* args) { PyList_SetItem(centers_list, c, PyLong_FromLong(centers[c])); } PyDict_SetItemString(power_dict, "centers", centers_list); + Py_DECREF(centers_list); PyList_SetItem(powers_list, p, power_dict); } PyDict_SetItemString(state_dict, "powers", powers_list); + Py_DECREF(powers_list); return state_dict; } @@ -127,18 +139,22 @@ static PyObject* query_map_info(PyObject* self, PyObject* args) { Map* map = env->game->map; PyObject* map_dict = PyDict_New(); - PyDict_SetItemString(map_dict, "num_locations", PyLong_FromLong(get_num_locations(map))); + set_owned(map_dict, "num_locations", PyLong_FromLong(get_num_locations(map))); // Add location info PyObject* locations_list = PyList_New(get_num_locations(map)); for (int i = 0; i < get_num_locations(map); i++) { PyObject* loc_dict = PyDict_New(); - PyDict_SetItemString(loc_dict, "name", PyUnicode_FromString(get_location_name(map, i))); - PyDict_SetItemString(loc_dict, "type", PyLong_FromLong((long)get_location_type(map, i))); - PyDict_SetItemString(loc_dict, "is_supply_center", PyLong_FromLong(is_supply_center(map, i))); + set_owned(loc_dict, "name", PyUnicode_FromString(get_location_name(map, i))); + set_owned(loc_dict, "type", PyLong_FromLong((long)get_location_type(map, i))); + set_owned(loc_dict, "is_supply_center", PyLong_FromLong(is_supply_center(map, i))); + set_owned(loc_dict, "owner_power", PyLong_FromLong(map->locations[i].owner_power)); + set_owned(loc_dict, "home_power", PyLong_FromLong(map->locations[i].is_home_center)); + set_owned(loc_dict, "parent", PyLong_FromLong(map->locations[i].parent_location)); PyList_SetItem(locations_list, i, loc_dict); } PyDict_SetItemString(map_dict, "locations", locations_list); + Py_DECREF(locations_list); return map_dict; } @@ -166,12 +182,12 @@ static PyObject* test_parse_order(PyObject* self, PyObject* args) { // Return parsed order as dict PyObject* order_dict = PyDict_New(); - PyDict_SetItemString(order_dict, "type", PyLong_FromLong((long)order.type)); - PyDict_SetItemString(order_dict, "unit_type", PyLong_FromLong((long)order.unit_type)); - PyDict_SetItemString(order_dict, "unit_location", PyLong_FromLong(order.unit_location)); - PyDict_SetItemString(order_dict, "target_location", PyLong_FromLong(order.target_location)); - PyDict_SetItemString(order_dict, "target_unit_location", PyLong_FromLong(order.target_unit_location)); - PyDict_SetItemString(order_dict, "dest_location", PyLong_FromLong(order.dest_location)); + set_owned(order_dict, "type", PyLong_FromLong((long)order.type)); + set_owned(order_dict, "unit_type", PyLong_FromLong((long)order.unit_type)); + set_owned(order_dict, "unit_location", PyLong_FromLong(order.unit_location)); + set_owned(order_dict, "target_location", PyLong_FromLong(order.target_location)); + set_owned(order_dict, "target_unit_location", PyLong_FromLong(order.target_unit_location)); + set_owned(order_dict, "dest_location", PyLong_FromLong(order.dest_location)); return order_dict; } @@ -645,13 +661,13 @@ static PyObject* get_orders(PyObject* self, PyObject* args) { Order* order = &power->orders[i]; PyObject* order_dict = PyDict_New(); - PyDict_SetItemString(order_dict, "type", PyLong_FromLong((long)order->type)); - PyDict_SetItemString(order_dict, "unit_type", PyLong_FromLong((long)order->unit_type)); - PyDict_SetItemString(order_dict, "unit_location", PyLong_FromLong(order->unit_location)); - PyDict_SetItemString(order_dict, "target_location", PyLong_FromLong(order->target_location)); - PyDict_SetItemString(order_dict, "target_unit_location", PyLong_FromLong(order->target_unit_location)); - PyDict_SetItemString(order_dict, "dest_location", PyLong_FromLong(order->dest_location)); - PyDict_SetItemString(order_dict, "result", PyLong_FromLong((long)order->result)); + set_owned(order_dict, "type", PyLong_FromLong((long)order->type)); + set_owned(order_dict, "unit_type", PyLong_FromLong((long)order->unit_type)); + set_owned(order_dict, "unit_location", PyLong_FromLong(order->unit_location)); + set_owned(order_dict, "target_location", PyLong_FromLong(order->target_location)); + set_owned(order_dict, "target_unit_location", PyLong_FromLong(order->target_unit_location)); + set_owned(order_dict, "dest_location", PyLong_FromLong(order->dest_location)); + set_owned(order_dict, "result", PyLong_FromLong((long)order->result)); PyList_SetItem(orders_list, i, order_dict); } @@ -780,11 +796,15 @@ static PyObject* get_dislodged_units(PyObject* self, PyObject* args) { DislodgedUnit* d = &game->dislodged[i]; PyObject* d_dict = PyDict_New(); - PyDict_SetItemString(d_dict, "power_id", PyLong_FromLong(d->power_id)); - PyDict_SetItemString(d_dict, "type", PyLong_FromLong(d->type)); - PyDict_SetItemString(d_dict, "from_location", PyLong_FromLong(d->from_location)); - PyDict_SetItemString(d_dict, "dislodged_by_location", PyLong_FromLong(d->dislodged_by_location)); + set_owned(d_dict, "power_id", PyLong_FromLong(d->power_id)); + set_owned(d_dict, "type", PyLong_FromLong(d->type)); + set_owned(d_dict, "from_location", PyLong_FromLong(d->from_location)); + set_owned(d_dict, "dislodged_by_location", PyLong_FromLong(d->dislodged_by_location)); + PyObject* destinations = PyList_New(d->num_possible_retreats); + for (int j = 0; j < d->num_possible_retreats; j++) + PyList_SetItem(destinations, j, PyLong_FromLong(d->possible_retreats[j].location)); + set_owned(d_dict, "possible_retreats", destinations); PyList_Append(result_list, d_dict); Py_DECREF(d_dict); } diff --git a/pufferlib/ocean/diplomacy/diplomacy.c b/pufferlib/ocean/diplomacy/diplomacy.c index 7f61da89e8..4c0e123fea 100644 --- a/pufferlib/ocean/diplomacy/diplomacy.c +++ b/pufferlib/ocean/diplomacy/diplomacy.c @@ -132,6 +132,9 @@ int can_move(Map* map, UnitType unit_type, int from_loc, int to_loc) { to_loc < 0 || to_loc >= map->num_locations) { return 0; } + if (unit_type != UNIT_ARMY && unit_type != UNIT_FLEET) return 0; + if (!strcmp(map->locations[from_loc].name, "SWI") || + !strcmp(map->locations[to_loc].name, "SWI")) return 0; return map->adjacency_cache[unit_type][from_loc][to_loc]; } @@ -335,6 +338,12 @@ void init_game(GameState* game, Map* map, int welfare_mode, int max_years) { game->powers[6].centers[1] = 18; // CON game->powers[6].centers[2] = 58; // SMY game->powers[6].num_centers = 3; + + // Center lists and map owners are two views of the same ownership state. + for (int i = 0; i < map->num_locations; i++) map->locations[i].owner_power = -1; + for (int p = 0; p < MAX_POWERS; p++) + for (int c = 0; c < game->powers[p].num_centers; c++) + map->locations[game->powers[p].centers[c]].owner_power = p; } void reset_game(GameState* game) { @@ -564,25 +573,12 @@ static int validate_support_order(GameState* game, Unit* unit, const Order* orde } } - int can_support = 0; - if (can_move(map, unit->type, unit->location, order->target_unit_location)) { - can_support = 1; - } - if (order->dest_location >= 0 && order->dest_location < map->num_locations) { - if (can_move(map, unit->type, unit->location, order->dest_location)) { - can_support = 1; - } else { - int coasts[10]; - int num_coasts; - find_coasts(map, order->dest_location, coasts, &num_coasts); - for (int c = 0; c < num_coasts; c++) { - if (can_move(map, unit->type, unit->location, coasts[c])) { - can_support = 1; - break; - } - } - } - } + int destination = order->type == ORDER_SUPPORT_MOVE ? order->dest_location : order->target_unit_location; + int can_support = can_move(map, unit->type, unit->location, destination); + int coasts[10], num_coasts; + find_coasts(map, destination, coasts, &num_coasts); + for (int c = 0; c < num_coasts; c++) + if (can_move(map, unit->type, unit->location, coasts[c])) can_support = 1; if (!can_support) { return -1; } @@ -647,6 +643,13 @@ static int validate_convoy_order(GameState* game, Unit* unit, const Order* order return -1; } + int origin = get_parent_location(map, order->target_unit_location); + int destination = get_parent_location(map, order->dest_location); + if (origin == destination || order->dest_location != destination || + (map->locations[origin].type != LOC_COAST && map->locations[origin].type != LOC_PORT) || + (map->locations[destination].type != LOC_COAST && map->locations[destination].type != LOC_PORT)) + return -1; + int found_army = 0; for (int check_p = 0; check_p < MAX_POWERS; check_p++) { Power* check_power = &game->powers[check_p]; @@ -749,7 +752,7 @@ int validate_order(GameState* game, int power_id, const Order* order) { case ORDER_DISBAND: // Can always disband own units (in welfare diplomacy) // In standard diplomacy, only during adjustment phase if over limit - if (!game->welfare_mode && game->phase != PHASE_WINTER_ADJUSTMENT) { + if (game->phase != PHASE_WINTER_ADJUSTMENT) { return -1; // Wrong phase for standard diplomacy } return 0; @@ -3581,6 +3584,12 @@ void calculate_retreat_destinations(GameState* game) { for (int a = 0; a < loc->num_adjacent; a++) { int adj_loc = loc->adjacencies[a]; + if (dislodged->type == UNIT_ARMY) + adj_loc = get_parent_location(game->map, adj_loc); + int duplicate = 0; + for (int i = 0; i < retreat->num_possible_retreats; i++) + if (retreat->possible_retreats[i].location == adj_loc) duplicate = 1; + if (duplicate) continue; // Check if unit can move there (based on unit type and terrain) if (!can_move(game->map, dislodged->type, from_loc, adj_loc)) { @@ -3657,10 +3666,18 @@ void calculate_retreat_destinations(GameState* game) { continue; // Skip - cannot coastal crawl in retreat } + int occupied = 0; + for (int p = 0; p < MAX_POWERS; p++) + for (int u = 0; u < game->powers[p].num_units; u++) + if (get_parent_location(game->map, game->powers[p].units[u].location) == + get_parent_location(game->map, adj_loc)) occupied = 1; + if (occupied) continue; + // Valid retreat destination retreat->possible_retreats[retreat->num_possible_retreats].location = adj_loc; retreat->num_possible_retreats++; } + *dislodged = *retreat; } } @@ -3736,7 +3753,7 @@ static void validate_retreat_orders(GameState* game) { Order* order = &power->orders[o]; if (order->type != ORDER_RETREAT && order->type != ORDER_DISBAND) { order->result = RESULT_VOID; - } else if (!is_unit_dislodged(power, order->unit_location)) { + } else if (validate_retreat_order(power, order) != 0) { order->result = RESULT_VOID; } } @@ -3758,7 +3775,8 @@ static int is_location_occupied(GameState* game, int location) { for (int p = 0; p < MAX_POWERS; p++) { Power* power = &game->powers[p]; for (int u = 0; u < power->num_units; u++) { - if (power->units[u].location == location) { + if (get_parent_location(game->map, power->units[u].location) == + get_parent_location(game->map, location)) { return 1; } } @@ -3772,7 +3790,10 @@ static int process_single_retreat(GameState* game, Power* power, DislodgedUnit* for (int o = 0; o < power->num_orders; o++) { Order* order = &power->orders[o]; + if (order->result == RESULT_VOID) continue; if (order->type == ORDER_RETREAT && order->unit_location == dislodged->from_location) { + if (dislodged->type == UNIT_ARMY) + order->target_location = get_parent_location(game->map, order->target_location); int valid = is_valid_retreat_destination(dislodged, order->target_location); int occupied = valid && is_location_occupied(game, order->target_location); @@ -3818,7 +3839,7 @@ static int collect_retreat_destinations(GameState* game, int* destinations, int* } // Detect and mark conflicts (multiple retreats to same location) -static void detect_retreat_conflicts(int* destinations, int* has_conflict, int count) { +static void detect_retreat_conflicts(GameState* game, int* destinations, int* has_conflict, int count) { for (int i = 0; i < count; i++) { has_conflict[i] = 0; } @@ -3826,7 +3847,7 @@ static void detect_retreat_conflicts(int* destinations, int* has_conflict, int c if (destinations[i] == -1) continue; for (int j = i + 1; j < count; j++) { if (destinations[j] == -1) continue; - if (destinations[i] == destinations[j]) { + if (get_parent_location(game->map, destinations[i]) == get_parent_location(game->map, destinations[j])) { has_conflict[i] = 1; has_conflict[j] = 1; } @@ -3836,19 +3857,17 @@ static void detect_retreat_conflicts(int* destinations, int* has_conflict, int c // Apply conflict results to orders and destinations static void apply_retreat_conflicts(GameState* game, int* destinations, int* has_conflict, int count) { - int order_idx = 0; + int idx = 0; for (int p = 0; p < MAX_POWERS; p++) { Power* power = &game->powers[p]; - for (int o = 0; o < power->num_orders; o++) { - Order* order = &power->orders[o]; - if (order->type == ORDER_RETREAT || order->type == ORDER_DISBAND) { - if (order_idx < count && has_conflict[order_idx]) { - destinations[order_idx] = -1; - if (order->type == ORDER_RETREAT) { - order->result = RESULT_BOUNCE; - } - } - order_idx++; + for (int r = 0; r < power->num_retreats; r++, idx++) { + if (idx >= count || !has_conflict[idx]) continue; + destinations[idx] = -1; + for (int o = 0; o < power->num_orders; o++) { + Order* order = &power->orders[o]; + if (order->type == ORDER_RETREAT && + order->unit_location == power->retreats[r].from_location) + order->result = RESULT_BOUNCE; } } } @@ -3891,7 +3910,7 @@ void resolve_retreat_phase(GameState* game) { int has_conflict[MAX_UNITS]; int num_retreats = collect_retreat_destinations(game, retreat_destinations, retreat_power); - detect_retreat_conflicts(retreat_destinations, has_conflict, num_retreats); + detect_retreat_conflicts(game, retreat_destinations, has_conflict, num_retreats); apply_retreat_conflicts(game, retreat_destinations, has_conflict, num_retreats); apply_successful_retreats(game, retreat_destinations, num_retreats); @@ -3922,15 +3941,23 @@ static int process_build_order(GameState* game, Power* power, Order* order, int return diff; } - Location* loc = &game->map->locations[order->unit_location]; + if (order->unit_type == UNIT_ARMY) + order->unit_location = get_parent_location(game->map, order->unit_location); + Location* loc = &game->map->locations[get_parent_location(game->map, order->unit_location)]; + Location* build_loc = &game->map->locations[order->unit_location]; int is_valid_build = 0; if (loc->has_supply_center && loc->owner_power == p) { - int location_empty = !is_location_occupied_by_power(power, order->unit_location); + int location_empty = 1; + int parent = get_parent_location(game->map, order->unit_location); + for (int q = 0; q < MAX_POWERS; q++) + for (int u = 0; u < game->powers[q].num_units; u++) + if (get_parent_location(game->map, game->powers[q].units[u].location) == parent) + location_empty = 0; if (location_empty && loc->is_home_center == p) { if (order->unit_type == UNIT_FLEET) { - is_valid_build = (loc->type != LOC_LAND && loc->type != LOC_PORT); + is_valid_build = (build_loc->type != LOC_LAND && build_loc->type != LOC_PORT); } else { is_valid_build = 1; } @@ -3969,6 +3996,14 @@ static int remove_unit_at_location(Power* power, int location) { // Process a disband order, returns new diff value static int process_disband_order(GameState* game, Power* power, Order* order, int diff) { + int matching_unit = 0; + for (int u = 0; u < power->num_units; u++) + if (power->units[u].location == order->unit_location && power->units[u].type == order->unit_type) + matching_unit = 1; + if (!matching_unit) { + order->result = RESULT_VOID; + return diff; + } if (diff > 0 || game->welfare_mode) { int unit_found = remove_unit_at_location(power, order->unit_location); order->result = unit_found ? RESULT_SUCCESS : RESULT_VOID; @@ -4090,9 +4125,18 @@ void resolve_adjustment_phase(GameState* game) { for (int p = 0; p < MAX_POWERS; p++) { Power* power = &game->powers[p]; int diff = power->num_units - power->num_centers; + int builds = 0, disbands = 0; + for (int o = 0; o < power->num_orders; o++) { + builds |= power->orders[o].type == ORDER_BUILD; + disbands |= power->orders[o].type == ORDER_DISBAND; + } + if (builds && disbands) { + for (int o = 0; o < power->num_orders; o++) power->orders[o].result = RESULT_VOID; + } for (int o = 0; o < power->num_orders; o++) { Order* order = &power->orders[o]; + if (builds && disbands) continue; if (order->type == ORDER_BUILD) { diff = process_build_order(game, power, order, p, diff); } else if (order->type == ORDER_DISBAND) { @@ -4128,6 +4172,25 @@ void resolve_adjustment_phase(GameState* game) { } } +static void update_center_ownership(GameState* game) { + Map* map = game->map; + for (int p = 0; p < MAX_POWERS; p++) { + Power* power = &game->powers[p]; + for (int u = 0; u < power->num_units; u++) { + int loc = get_parent_location(map, power->units[u].location); + if (map->locations[loc].has_supply_center) map->locations[loc].owner_power = p; + } + } + for (int p = 0; p < MAX_POWERS; p++) game->powers[p].num_centers = 0; + for (int loc = 0; loc < map->num_locations; loc++) { + Location* province = &map->locations[loc]; + if (province->has_supply_center && province->parent_location < 0 && province->owner_power >= 0) { + Power* power = &game->powers[province->owner_power]; + power->centers[power->num_centers++] = loc; + } + } +} + void advance_phase(GameState* game) { // Advance to next phase switch (game->phase) { @@ -4171,6 +4234,18 @@ void advance_phase(GameState* game) { break; } + if (game->phase == PHASE_WINTER_ADJUSTMENT) { + update_center_ownership(game); + if (!game->welfare_mode) { + for (int p = 0; p < MAX_POWERS; p++) { + if (game->powers[p].num_centers >= 18) { + game->phase = PHASE_COMPLETED; + game->is_game_over = 1; + } + } + } + } + // Clear orders for new phase for (int p = 0; p < MAX_POWERS; p++) { game->powers[p].num_orders = 0; @@ -4242,8 +4317,8 @@ static void encode_observations(Env* env) { // Buildable (1 feature): home center, empty, and in adjustment phase int is_home = l->is_home_center; - int can_build = (is_home >= 0 && is_home == agent && - unit_at[loc] < 0 && + int can_build = (l->has_supply_center && l->owner_power == agent && + is_home == agent && unit_at[loc] < 0 && game->phase == PHASE_WINTER_ADJUSTMENT); base[idx++] = can_build ? 1.0f : 0.0f; } else { diff --git a/pufferlib/ocean/diplomacy/diplomacy.py b/pufferlib/ocean/diplomacy/diplomacy.py index 7d55253bce..72c386e6fa 100644 --- a/pufferlib/ocean/diplomacy/diplomacy.py +++ b/pufferlib/ocean/diplomacy/diplomacy.py @@ -34,6 +34,10 @@ def __init__( if isinstance(welfare_mode, str): welfare_mode = welfare_mode.lower() in ('true', '1', 'yes') + if type(max_years) is not int or not 1 <= max_years <= 1000: + raise ValueError("max_years must be an integer between 1 and 1000") + if type(welfare_mode) is not bool or num_players != 7: + raise ValueError("Diplomacy requires seven powers and a boolean welfare_mode") self.max_years = max_years self.welfare_mode = welfare_mode self.num_players = num_players @@ -79,6 +83,7 @@ def __init__( def reset(self, seed=None): """Reset the environment to initial state.""" + self._require_open() if seed is not None: self.seed = seed @@ -91,7 +96,14 @@ def reset(self, seed=None): def step(self, actions): """Execute one step of the environment.""" - # Actions are already in self.actions (set by PufferLib) + self._require_open() + values = np.asarray(actions) + if values.shape != self.actions.shape or not np.issubdtype(values.dtype, np.number): + raise ValueError("actions must contain one integer per power") + if not np.isfinite(values).all() or (values != np.floor(values)).any() or (values < 0).any() or (values >= 1088).any(): + raise ValueError("actions must be integers in [0, 1088)") + self.actions[:] = values + # Copy arguments into the shared action buffer before stepping. # Call C step function which will process them binding.env_step(self.env_handle) @@ -120,8 +132,16 @@ def render(self): def close(self): """Clean up resources.""" - if self._renderer is not None: + if getattr(self, '_renderer', None) is not None: self._renderer.close() self._renderer = None - if hasattr(self, 'env_handle'): + if getattr(self, 'env_handle', None) is not None: binding.env_close(self.env_handle) + self.env_handle = None + + def _require_open(self): + if getattr(self, 'env_handle', None) is None: + raise RuntimeError("Diplomacy environment is closed") + + def __del__(self): + self.close() diff --git a/pufferlib/ocean/diplomacy/diplomacy_map.c b/pufferlib/ocean/diplomacy/diplomacy_map.c index 918981ebad..762ea660b1 100644 --- a/pufferlib/ocean/diplomacy/diplomacy_map.c +++ b/pufferlib/ocean/diplomacy/diplomacy_map.c @@ -30,6 +30,7 @@ void init_standard_map(Map* map) { // Initialize new coast fields for all locations (default: not a coast variant) for (int i = 0; i < MAX_LOCATIONS; i++) { map->locations[i].parent_location = -1; + map->locations[i].is_home_center = -1; map->locations[i].coast_type = COAST_NONE; } diff --git a/pufferlib/ocean/diplomacy/game.py b/pufferlib/ocean/diplomacy/game.py new file mode 100644 index 0000000000..6313cd06cf --- /dev/null +++ b/pufferlib/ocean/diplomacy/game.py @@ -0,0 +1,257 @@ +"""Complete simultaneous-order interface to the C Welfare Diplomacy adjudicator. + +The older Discrete(1088) environment is a restricted integration interface. +Game accepts a full list of textual orders for every power in each phase. +""" +from collections.abc import Mapping +import copy +import hashlib +import json +from pathlib import Path +import random + +from .diplomacy import Diplomacy +from . import binding + +POWERS = ('AUSTRIA', 'ENGLAND', 'FRANCE', 'GERMANY', 'ITALY', 'RUSSIA', 'TURKEY') +PHASES = ('SPRING_MOVEMENT', 'SPRING_RETREAT', 'FALL_MOVEMENT', 'FALL_RETREAT', + 'WINTER_ADJUSTMENT', 'COMPLETED') + + +class Game: + """Seven powers submit orders together; omitted units hold or disband on retreat. + + Winter builds may be waived by submitting an empty list. Welfare scores are + accumulated after Winter. No press, negotiation or hidden information layer + is imposed. `observe(power)` returns the complete public state and actor. + """ + def __init__(self, max_years=10, welfare_mode=True, seed=1): + self.config = dict(max_years=max_years, welfare_mode=welfare_mode, seed=seed) + self._env = Diplomacy(**self.config) + self._history = [] + self.reset() + + def _open(self): + self._env._require_open() + + @property + def state(self): + self._open() + raw = binding.query_game_state(self._env.env_handle) + locations = binding.query_map_info(self._env.env_handle)['locations'] + names = [loc['name'] for loc in locations] + powers = {} + for name, power in zip(POWERS, raw['powers']): + powers[name] = {'units': [f"{'A' if u['type'] == 1 else 'F'} {names[u['location']]}" + for u in power['units']], + 'centers': [names[c] for c in power['centers']], + 'welfare': power['welfare_points']} + retreats = [] + for d in binding.get_dislodged_units(self._env.env_handle): + retreats.append({'power': POWERS[d['power_id']], + 'unit': f"{'A' if d['type'] == 1 else 'F'} {names[d['from_location']]}", + 'attacker_origin': names[d['dislodged_by_location']], + 'destinations': [names[i] for i in d['possible_retreats']]}) + return {'year': raw['year'], 'phase': PHASES[raw['phase']], + 'terminated': raw['is_game_over'], 'powers': powers, + 'locations': locations, 'retreats': retreats} + + def observe(self, power): + if power not in POWERS: + raise ValueError(f'unknown power: {power}') + state = self.state + state['actor'] = power + state['unit_slots'] = list(state['powers'][power]['units']) + return state + + def observations(self): + state = self.state + return {p: dict(copy.deepcopy(state), actor=p, + unit_slots=list(state['powers'][p]['units'])) for p in POWERS} + + def reset(self, seed=None): + self._env.reset(seed=seed) + if seed is not None: + self.config['seed'] = seed + self._history = [] + return self.observations(), {'state_hash': self.state_hash()} + + def state_hash(self): + return hashlib.sha256(json.dumps(self.state, sort_keys=True).encode()).hexdigest() + + def step(self, orders): + self._open() + if self.state['terminated']: + raise RuntimeError('game is complete; reset before stepping') + if not isinstance(orders, Mapping) or any(p not in POWERS for p in orders): + raise ValueError('orders must map power names to lists of order strings') + normalized = {} + initial_state = self.state + phase = initial_state['phase'] + locations = initial_state['locations'] + # Validate the entire input before touching any power's pending orders. + for p in POWERS: + values = orders.get(p, []) + if not isinstance(values, (list, tuple)) or len(values) > 34: + raise ValueError('each power must supply at most 34 order strings') + values = [v.strip().upper() if isinstance(v, str) else v for v in values] + parsed = [] + for value in values: + if not isinstance(value, str) or len(value.encode()) >= 64: + raise ValueError('orders must be strings shorter than 64 bytes') + order = binding.test_parse_order(self._env.env_handle, value) + if order is None: + raise ValueError(f'cannot parse order: {value}') + parsed.append(order) + kinds = {o['type'] for o in parsed} + if phase == 'WINTER_ADJUSTMENT' and 6 in kinds and 7 in kinds: + raise ValueError('a Winter order set cannot mix builds and disbands') + origins = [locations[o['unit_location']]['parent'] if + locations[o['unit_location']]['parent'] >= 0 else o['unit_location'] + for o in parsed] + if len(origins) != len(set(origins)): + raise ValueError('submit only one order per province/unit') + normalized[p] = values + for p in POWERS: + binding.game_submit_orders(self._env.env_handle, POWERS.index(p), normalized[p]) + _, rewards, terminals, truncations, _ = self._env.step([0] * 7) + info = {'orders': normalized, 'results': {}, 'state_hash': self.state_hash()} + for p in POWERS: + idx = POWERS.index(p) + info['results'][p] = [binding.get_order_result(self._env.env_handle, idx, i) + for i in range(binding.get_num_orders(self._env.env_handle, idx))] + self._history.append(copy.deepcopy(normalized)) + return (self.observations(), dict(zip(POWERS, map(float, rewards))), + bool(terminals.all()), bool(truncations.any()), info) + + def possible_orders(self, power): + """Locally valid candidates by unit/location; sets still need coordination. + + A support or convoy candidate is not a promise it will succeed: other + powers' simultaneous orders determine that. Winter sets must obey the + build count and cannot mix builds with disbands. + """ + state = self.observe(power) + locs = state['locations'] + by_name = {loc['name']: loc for loc in locs} + phase = state['phase'] + own = state['powers'][power] + handle = self._env.env_handle + pidx = POWERS.index(power) + output = {} + def parent(name): + loc = by_name[name] + return locs[loc['parent']]['name'] if loc['parent'] >= 0 else name + if phase == 'COMPLETED': + return output + if 'RETREAT' in phase: + for retreat in state['retreats']: + if retreat['power'] == power: + unit = retreat['unit'] + output[unit] = [unit + ' D'] + [unit + ' R ' + d for d in retreat['destinations']] + return output + if phase == 'WINTER_ADJUSTMENT': + if self.config['welfare_mode'] or len(own['units']) > len(own['centers']): + output.update({u: [u + ' D'] for u in own['units']}) + if len(own['centers']) > len(own['units']): + occupied = {parent(u.split()[1]) for q in state['powers'].values() for u in q['units']} + for loc in locs: + base = parent(loc['name']) + base_loc = by_name[base] + if (base_loc['home_power'] == pidx and base in own['centers'] and base not in occupied): + values = [] + if loc['parent'] < 0 and loc['type'] != 2: + values.append('A ' + loc['name'] + ' B') + if loc['type'] == 1: + values.append('F ' + loc['name'] + ' B') + if values: + output[loc['name']] = values + return output + units = [u for q in state['powers'].values() for u in q['units']] + for unit in own['units']: + kind, origin = unit.split() + candidates = {unit + ' H'} + for dest in by_name: + if dest == 'SWI' or (kind == 'A' and '/' in dest): + continue + if binding.can_move_names(handle, 1 if kind == 'A' else 2, origin, dest): + candidates.add(unit + ' - ' + dest) + if kind == 'A' and by_name[dest]['parent'] < 0 and by_name[dest]['type'] in (1, 3): + candidate = unit + ' - ' + dest + ' VIA' + if binding.test_validate_order(handle, pidx, candidate) == 0: + candidates.add(candidate) + for target in units: + if target == unit: + continue + candidate = unit + ' S ' + target + if binding.test_validate_order(handle, pidx, candidate) == 0: + candidates.add(candidate) + for dest in by_name: + if dest == 'SWI': + continue + candidate = unit + ' S ' + target + ' - ' + dest + if binding.test_validate_order(handle, pidx, candidate) == 0: + candidates.add(candidate) + if kind == 'F' and by_name[origin]['type'] == 2 and target.startswith('A '): + candidate = unit + ' C ' + target + ' - ' + dest + if binding.test_validate_order(handle, pidx, candidate) == 0: + candidates.add(candidate) + output[unit] = sorted(candidates) + return output + + def scripted_orders(self, seed=0): + """A deterministic legal-order example policy, not a strong opponent.""" + rng = random.Random(seed) + state = self.state + result = {} + for p, data in state['powers'].items(): + options = self.possible_orders(p) + if state['phase'] == 'WINTER_ADJUSTMENT': + # Waive builds; disband excess in standard mode, one in welfare. + excess = max(0, len(data['units']) - len(data['centers'])) + count = max(excess, min(1, len(data['units']))) if self.config['welfare_mode'] else excess + result[p] = [u + ' D' for u in data['units'][:count]] + else: + chosen = [] + for values in options.values(): + if not values: + continue + moves = [v for v in values if ' - ' in v and ' S ' not in v and ' C ' not in v] + chosen.append(rng.choice(moves if moves and rng.random() < 0.65 else values)) + result[p] = chosen + return result + + def save_replay(self, path): + Path(path).write_text(json.dumps({'format': 1, 'config': self.config, + 'orders': self._history, 'state_hash': self.state_hash()}, indent=2)) + + @classmethod + def from_replay(cls, path): + data = json.loads(Path(path).read_text()) + if data['format'] != 1: + raise ValueError('unsupported replay format') + game = cls(**data['config']) + try: + for orders in data['orders']: + game.step(orders) + if game.state_hash() != data['state_hash']: + raise ValueError('replay state mismatch') + return game + except BaseException: + game.close() + raise + + def render(self): + state = self.state + return '\n'.join([f"{state['year']} {state['phase']}"] + + [f"{p}: {', '.join(v['units'])}; centers={len(v['centers'])}; welfare={v['welfare']}" + for p, v in state['powers'].items()]) + + def close(self): + self._env.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() diff --git a/tests/diplomacy/c_tests/test_c_binding.py b/tests/diplomacy/c_tests/test_c_binding.py index 3154cd6124..167a673467 100644 --- a/tests/diplomacy/c_tests/test_c_binding.py +++ b/tests/diplomacy/c_tests/test_c_binding.py @@ -76,9 +76,9 @@ def test_multiple_steps(self): obs, rewards, dones, truncated, info = env.step(actions) assert obs is not None - @pytest.mark.skip(reason="Rendering not yet implemented") def test_render(self): - """Test that the environment can be rendered.""" - env = Diplomacy() - env.reset() - env.render() + from pufferlib.ocean.diplomacy import Game + with Game() as game: + text = game.render() + assert '1901 SPRING_MOVEMENT' in text + assert 'RUSSIA' in text and 'F STP/SC' in text diff --git a/tests/diplomacy/c_tests/test_c_game_state.py b/tests/diplomacy/c_tests/test_c_game_state.py index c78b86c259..41a92ac9e0 100644 --- a/tests/diplomacy/c_tests/test_c_game_state.py +++ b/tests/diplomacy/c_tests/test_c_game_state.py @@ -75,15 +75,21 @@ def test_year_progression(self): assert after["phase"] == 0 assert after["year"] == 1902 - @pytest.mark.skip(reason="Adjustment phase not yet implemented") def test_unit_creation(self): - """Test that units can be created during adjustment phase.""" - pass + from pufferlib.ocean.diplomacy import Game + from tests.test_diplomacy import set_position + with Game() as game: + set_position(game, {}, 'W1901A', {'FRANCE': ['PAR']}) + game.step({'FRANCE': ['A PAR B']}) + assert game.state['powers']['FRANCE']['units'] == ['A PAR'] - @pytest.mark.skip(reason="Adjustment phase not yet implemented") def test_unit_removal(self): - """Test that units can be removed during adjustment phase.""" - pass + from pufferlib.ocean.diplomacy import Game + with Game(max_years=2) as game: + game.step({}) + game.step({}) + game.step({'FRANCE': ['A PAR D']}) + assert len(game.state['powers']['FRANCE']['units']) == 2 def test_supply_center_ownership(self): """Test that supply center ownership is tracked correctly.""" @@ -121,7 +127,10 @@ def test_game_over_max_years(self): state = binding.query_game_state(env.env_handle) assert state["phase"] == 5 - @pytest.mark.skip(reason="Dislodged units not yet implemented") def test_dislodged_units(self): - """Test that dislodged units are tracked correctly.""" - pass + from pufferlib.ocean.diplomacy import Game + from tests.test_diplomacy import set_position + with Game() as game: + set_position(game, {'FRANCE': ['A BUR', 'A PIC'], 'GERMANY': ['A BEL']}) + game.step({'FRANCE': ['A BUR - BEL', 'A PIC S A BUR - BEL']}) + assert game.state['retreats'][0]['unit'] == 'A BEL' diff --git a/tests/diplomacy/c_tests/test_c_map.py b/tests/diplomacy/c_tests/test_c_map.py index 819875d8d2..dc724142db 100644 --- a/tests/diplomacy/c_tests/test_c_map.py +++ b/tests/diplomacy/c_tests/test_c_map.py @@ -69,7 +69,7 @@ def test_supply_centers(self): # NOTE: Map data currently has 24 SCs instead of 34 - missing some home centers # TODO: Fix map generation to include all 34 SCs supply_centers = [loc for loc in locations if loc["is_supply_center"]] - assert len(supply_centers) >= 20 # At least 20 SCs marked (relaxed check) + assert len(supply_centers) == 34 # Check some known supply centers loc_dict = {loc["name"]: loc for loc in locations} @@ -77,7 +77,6 @@ def test_supply_centers(self): assert loc_dict["ROM"]["is_supply_center"] == 1 assert loc_dict["VIE"]["is_supply_center"] == 1 - @pytest.mark.skip(reason="Home center query API not yet implemented") def test_home_centers(self): """Test that home centers are correctly assigned.""" env = Diplomacy() @@ -105,18 +104,21 @@ def test_adjacency_cache_fleet(self): # English Channel not adjacent to Paris for fleet (pure land) assert binding.can_move_names(env.env_handle, 2, "ENG", "PAR") == 0 - @pytest.mark.skip(reason="Adjacency cache query API not yet implemented") def test_specific_adjacencies(self): - """Test specific known adjacencies from standard map.""" - # Test cases from DATC: - # - Belgium is adjacent to: BUR, ENG, HOL, NTH, PIC, RUH - # - Munich is adjacent to: BER, BOH, BUR, KIE, RUH, SIL, TYR, SWI - pass + env = Diplomacy() + try: + for dest in ['BUR', 'HOL', 'PIC', 'RUH']: + assert binding.can_move_names(env.env_handle, 1, 'BEL', dest) + assert not binding.can_move_names(env.env_handle, 1, 'MUN', 'SWI') + finally: + env.close() - @pytest.mark.skip(reason="Adjacency cache query API not yet implemented") def test_coast_adjacencies(self): - """Test that coastal locations have both land and sea adjacencies.""" - # Constantinople should be adjacent to: - # - Land: ANK, BUL - # - Sea: AEG, BLA - pass + env = Diplomacy() + try: + for dest in ['ANK', 'BUL']: + assert binding.can_move_names(env.env_handle, 1, 'CON', dest) + for dest in ['AEG', 'BLA']: + assert binding.can_move_names(env.env_handle, 2, 'CON', dest) + finally: + env.close() diff --git a/tests/diplomacy/c_tests/test_c_welfare.py b/tests/diplomacy/c_tests/test_c_welfare.py index 78e0f06909..f9a56459e4 100644 --- a/tests/diplomacy/c_tests/test_c_welfare.py +++ b/tests/diplomacy/c_tests/test_c_welfare.py @@ -31,48 +31,62 @@ def test_initial_welfare_points(self): for power in powers: assert power["welfare_points"] == 0 - @pytest.mark.skip(reason="Welfare calculation not yet implemented") def test_welfare_calculation_basic(self): - """Test basic welfare calculation: welfare = cumulative(centers - units).""" - # If a power has 3 centers and 3 units: - # - Year 1: welfare += (3 - 3) = 0 - # - Year 2: welfare += (4 - 3) = 1 - # - Total welfare after Year 2 = 1 - pass + from pufferlib.ocean.diplomacy import Game + with Game(max_years=2) as game: + game.step({}) + game.step({}) + game.step({'FRANCE': ['A PAR D']}) + assert game.state['powers']['FRANCE']['welfare'] == 1 - @pytest.mark.skip(reason="Welfare calculation not yet implemented") def test_welfare_with_unit_disband(self): - """Test that disbanding units increases welfare.""" - # Power with 4 centers, disbands to 2 units: - # - welfare += (4 - 2) = 2 - pass + from pufferlib.ocean.diplomacy import Game + with Game(max_years=2) as game: + game.step({}) + game.step({}) + game.step({'FRANCE': ['A PAR D']}) + assert 'A PAR' not in game.state['powers']['FRANCE']['units'] - @pytest.mark.skip(reason="Welfare calculation not yet implemented") def test_welfare_never_decreases(self): - """Test that welfare points are cumulative and never decrease.""" - pass + from pufferlib.ocean.diplomacy import Game + with Game(max_years=2) as game: + game.step({}) + game.step({}) + game.step({'FRANCE': ['A PAR D']}) + before = game.state['powers']['FRANCE']['welfare'] + for _ in range(3): game.step({}) + assert game.state['powers']['FRANCE']['welfare'] == before + 1 - @pytest.mark.skip(reason="Welfare calculation not yet implemented") def test_welfare_reward_calculation(self): - """Test that rewards reflect welfare point changes.""" - # If welfare increases by 2, reward should be +2 - pass + from pufferlib.ocean.diplomacy import Game + with Game(max_years=2) as game: + game.step({}) + game.step({}) + game.step({'FRANCE': ['A PAR D']}) + assert game._env.rewards[2] == 1 - @pytest.mark.skip(reason="Welfare calculation not yet implemented") def test_voluntary_disband_order(self): - """Test that powers can voluntarily disband units.""" - # In standard diplomacy, you can only disband if over the limit - # In welfare diplomacy, you can always disband voluntarily - pass + from pufferlib.ocean.diplomacy import Game + with Game(max_years=2) as game: + game.step({}) + game.step({}) + game.step({'FRANCE': ['A PAR D']}) + assert len(game.state['powers']['FRANCE']['units']) == 2 - @pytest.mark.skip(reason="Welfare calculation not yet implemented") def test_no_victory_condition(self): - """Test that welfare diplomacy has no victory condition.""" - # Game should continue even if one power has 18+ centers - pass + from pufferlib.ocean.diplomacy import Game + from tests.test_diplomacy import set_position + with Game() as game: + centers = [l['name'] for l in game.state['locations'] if l['is_supply_center']] + set_position(game, {}, 'F1901M', {'FRANCE': centers}) + game.step({}) + assert not game.state['terminated'] + assert len(game.state['powers']['FRANCE']['centers']) == 34 - @pytest.mark.skip(reason="Welfare calculation not yet implemented") def test_welfare_observation_encoding(self): - """Test that welfare points are included in observations.""" - # The observation should include each power's welfare points - pass + from pufferlib.ocean.diplomacy import Game + with Game(max_years=2) as game: + game.step({}) + game.step({}) + game.step({'FRANCE': ['A PAR D']}) + assert game.observe('FRANCE')['powers']['FRANCE']['welfare'] == 1 diff --git a/tests/diplomacy/c_tests/test_complete_game.py b/tests/diplomacy/c_tests/test_complete_game.py new file mode 100644 index 0000000000..a335e9ebfd --- /dev/null +++ b/tests/diplomacy/c_tests/test_complete_game.py @@ -0,0 +1,92 @@ +"""Complete-game contract: standard Fall capture and paper section 2.2 welfare.""" +import numpy as np +import pytest +from tests.diplomacy.adapters import GameAdapter +from pufferlib.ocean.diplomacy import binding + + +@pytest.fixture +def game(): + g = GameAdapter() + yield g + g.env.close() + + +def test_fall_capture_and_reset(game): + game.clear_units() + game.set_units('FRANCE', ['A BUR']) + game.set_current_phase('F1901M') + game.set_orders('FRANCE', ['A BUR - BEL']) + game.process() + assert 'BEL' in game.get_centers('FRANCE') + assert len(game.get_centers('FRANCE')) == 4 + game.env.reset() + assert len(game.get_centers('FRANCE')) == 3 + + +def test_spring_occupation_does_not_capture(game): + game.clear_units() + game.set_units('FRANCE', ['A BUR']) + game.set_orders('FRANCE', ['A BUR - BEL']) + game.process() + assert 'BEL' not in game.get_centers('FRANCE') + + +def test_switzerland_impassable(game): + assert not binding.can_move_names(game.env.env_handle, 1, 'BUR', 'SWI') + game.clear_units() + game.set_units('FRANCE', ['A BUR']) + game.set_orders('FRANCE', ['A BUR - SWI']) + game.process() + assert game.get_units('FRANCE') == ['A BUR'] + + +def test_movement_disband_invalid(game): + game.set_orders('FRANCE', ['A PAR D']) + game.process() + assert 'A PAR' in game.get_units('FRANCE') + + +def test_mixed_winter_adjustments_rejected(game): + game.clear_units() + game.set_units('FRANCE', ['A PAR']) + game.set_centers('FRANCE', ['PAR', 'MAR', 'BRE']) + game.set_current_phase('W1901A') + game.set_orders('FRANCE', ['A PAR D', 'A MAR B']) + game.process() + assert game.get_units('FRANCE') == ['A PAR'] + + +def test_initial_center_ownership_observations(game): + board = game.env.observations[0, :1620].reshape(81, 20) + assert board[:, 11:18].sum() == 22 + assert board[:, 18].sum() == 12 + + +def test_direct_movement_matches_pinned_reference_map(game): + """All army base-province and fleet directed edges, not selected examples.""" + from pathlib import Path + rows = {} + source = Path(__file__).parents[1] / 'reference/standard.map' + for line in source.read_text().splitlines(): + fields = line.split() + if len(fields) > 3 and fields[2] == 'ABUTS': + rows[fields[1]] = (fields[0], fields[3:]) + for kind in [1, 2]: + for origin, (origin_type, adjacent) in rows.items(): + if origin.upper() != origin: + continue + for destination, (dest_type, _) in rows.items(): + if destination.upper() != destination: + continue + if kind == 1: + if '/' in origin or '/' in destination: + continue + army_edges = rows.get(origin.lower(), rows[origin])[1] + expected = (origin_type not in ('WATER', 'SHUT') and + dest_type not in ('WATER', 'SHUT') and + destination in [p.upper().split('/')[0] for p in army_edges]) + else: + expected = (origin_type in ('WATER', 'COAST') and + dest_type in ('WATER', 'COAST') and destination in adjacent) + assert bool(binding.can_move_names(game.env.env_handle, kind, origin, destination)) == expected, (kind, origin, destination) diff --git a/tests/diplomacy/c_tests/test_simple_adjudication.py b/tests/diplomacy/c_tests/test_simple_adjudication.py index 8722ab2f99..eedab9e921 100644 --- a/tests/diplomacy/c_tests/test_simple_adjudication.py +++ b/tests/diplomacy/c_tests/test_simple_adjudication.py @@ -161,7 +161,6 @@ def test_following_unit(self): class TestSimpleSupport: """Test basic support mechanics (to be implemented).""" - @pytest.mark.skip(reason="Support adjudication not yet implemented") def test_supported_attack(self): """A supported attack should dislodge a defender.""" game = GameAdapter() @@ -180,7 +179,6 @@ def test_supported_attack(self): assert 'A TRI' in italy_units assert 'F TRI' not in austria_units - @pytest.mark.skip(reason="Support adjudication not yet implemented") def test_support_prevents_dislodgement(self): """Support to hold should prevent dislodgement.""" game = GameAdapter() @@ -201,7 +199,6 @@ def test_support_prevents_dislodgement(self): class TestCircularMovement: """Test circular movement (to be implemented).""" - @pytest.mark.skip(reason="Circular movement not yet implemented") def test_simple_circular_movement(self): """Three units in a circle should all move.""" game = GameAdapter() diff --git a/tests/diplomacy/reference/README.md b/tests/diplomacy/reference/README.md new file mode 100644 index 0000000000..d86ccdd106 --- /dev/null +++ b/tests/diplomacy/reference/README.md @@ -0,0 +1,6 @@ +`standard.map` is copied unchanged from mukobi/welfare-diplomacy at +60520aaa4661e4c95abfd30ce6483990bb127567, diplomacy/maps/standard.map. +The reference repository is AGPL-3.0; retain its source attribution. This fixture +is used only by tests, alongside the existing copied DATC suite, and is excluded +from the runtime package. Upper-case adjacency tokens permit fleet travel; +lower-case tokens represent army-only connections. SHUT Switzerland is impassable. diff --git a/tests/diplomacy/reference/standard.map b/tests/diplomacy/reference/standard.map new file mode 100644 index 0000000000..620fa3a81a --- /dev/null +++ b/tests/diplomacy/reference/standard.map @@ -0,0 +1,213 @@ +BEGIN SPRING 1901 MOVEMENT + +AUSTRIA (AUSTRIAN) BUD TRI VIE +A BUD +A VIE +F TRI + +ENGLAND (ENGLISH) EDI LON LVP +F EDI +F LON +A LVP + +FRANCE (FRENCH) BRE MAR PAR +F BRE +A MAR +A PAR + +GERMANY (GERMAN) BER KIE MUN +F KIE +A BER +A MUN + +ITALY (ITALIAN) NAP ROM VEN +F NAP +A ROM +A VEN + +RUSSIA (RUSSIAN) MOS SEV STP WAR +A WAR +A MOS +F SEV +F STP/SC + +TURKEY (TURKISH) ANK CON SMY +F ANK +A CON +A SMY + +UNOWNED BEL BUL DEN GRE HOL NWY POR RUM SER SPA SWE TUN + +Adriatic Sea = adr adriatic +Aegean Sea = aeg aegean +Albania = alb +Ankara = ank +Apulia = apu +Armenia = arm +Baltic Sea = bal baltic +Barents Sea = bar barents +Belgium = bel +Berlin = ber berl +Black Sea = bla black bs +Bohemia = boh +Brest = bre +Budapest = bud +Bulgaria (east coast) = bul/ec bul(ec) bulgaria/ec bulgaria(ec) bul/nc bul(nc) bulgaria/nc bulgaria(nc) +Bulgaria (south coast) = bul/sc bul(sc) bulgaria/sc bulgaria(sc) +Bulgaria = bul bulg +Burgundy = bur burg burgandy +Clyde = cly +Constantinople = con +Denmark = den +Eastern Mediterranean = eas emed east eastern eastmed ems eme emd eas+med +Edinburgh = edi +English Channel = eng channel ech eng+ch +Finland = fin +Galicia = gal galacia +Gascony = gas gasc +Greece = gre +Gulf of Lyon = lyo gol gulfofl lyon gulf+of+lyons lyons +Gulf of Bothnia = bot gob both gulfofb bothnia +Helgoland Bight = hel helgoland heligoland heligoland+bight helg +Holland = hol +Ionian Sea = ion ionian +Irish Sea = iri irish irs +Kiel = kie +Liverpool = lvp livp lpl +Livonia = lvn livo lvo lva liv livon +London = lon +Marseilles = mar mars +Mid-Atlantic Ocean = mao midatlanticocean midatlantic mid mat +Moscow = mos +Munich = mun +Naples = nap +North Atlantic Ocean = nao nat na +North Africa = naf nora n+afr +North Sea = nth norsea nts ns +Norway = nwy nor norw +Norwegian Sea = nwg norwegian norwsea nrg norg +Paris = par +Picardy = pic +Piedmont = pie pid +Portugal = por port +Prussia = pru prus +Rome = rom +Ruhr = ruh +Rumania = rum romania +Serbia = ser serb +Sevastopol = sev sevastapol sevast seva +Silesia = sil +Skagerrak = ska skagerrack skag +Smyrna = smy +Spain (north coast) = spa/nc spa(nc) spain/nc spain(nc) spn/nc spn(nc) spa+(north+coast) +Spain (south coast) = spa/sc spa(sc) spain/sc spain(sc) spn/sc spn(sc) spa+(south+coast) spa/wc +Spain = spa spn +St Petersburg (north coast) = stp/nc stp(nc) stpete/nc stpete(nc) st.+petersburg/nc stp+(north+coast) +St Petersburg (south coast) = stp/sc stp(sc) stpete/sc stpete(sc) st.+petersburg/sc stp+(south+coast) +St Petersburg = stp stpete st.+petersburg st.petersburg +Sweden = swe +Syria = syr +Trieste = tri +Tunis = tun +Tuscany = tus tusc +Tyrolia = tyr tyl trl tyo tyrol +Tyrrhenian Sea = tys tyrr tyrrhenian tyn tyh tyrhh +Ukraine = ukr +Venice = ven +Vienna = vie vien +Wales = wal +Warsaw = war +Western Mediterranean = wes wmed west western westmed wms wme wmd west+med wes+med western+med +Yorkshire = yor york yonkers + +# ---------------------------------------------------------------- +# Location adjacency information, keyed off the official dpjudge +# name of the location. Here, upper- and lower-case IS important. +# ---------------------------------------------------------------- +WATER ADR ABUTS ALB APU ION TRI VEN +WATER AEG ABUTS BUL/SC CON EAS GRE ION SMY +COAST ALB ABUTS ADR GRE ION SER TRI +COAST ANK ABUTS ARM BLA CON smy +COAST APU ABUTS ADR ION NAP rom VEN +COAST ARM ABUTS ANK BLA SEV smy syr +WATER BAL ABUTS BER BOT DEN LVN KIE PRU SWE +WATER BAR ABUTS NWY NWG STP/NC +COAST BEL ABUTS BUR ENG HOL NTH PIC RUH +COAST BER ABUTS BAL KIE MUN PRU SIL +WATER BLA ABUTS ANK ARM BUL/EC CON RUM SEV +LAND BOH ABUTS GAL MUN SIL TYR VIE +WATER BOT ABUTS BAL FIN LVN STP/SC SWE +COAST BRE ABUTS ENG GAS MAO PAR PIC +LAND BUD ABUTS GAL RUM SER TRI VIE +COAST BUL/EC ABUTS BLA CON RUM +COAST BUL/SC ABUTS AEG CON GRE +COAST bul ABUTS AEG BLA CON GRE RUM SER +LAND BUR ABUTS BEL GAS RUH MAR MUN PAR PIC SWI +COAST CLY ABUTS EDI LVP NAO NWG +COAST CON ABUTS AEG BUL/EC BUL/SC BLA ANK SMY +COAST DEN ABUTS BAL HEL KIE NTH SKA SWE +WATER EAS ABUTS AEG ION SMY SYR +COAST EDI ABUTS CLY lvp NTH NWG YOR +WATER ENG ABUTS BEL BRE IRI LON MAO NTH PIC WAL +COAST FIN ABUTS BOT nwy STP/SC SWE +LAND GAL ABUTS BOH BUD RUM SIL UKR VIE WAR +COAST GAS ABUTS BUR BRE MAO mar PAR SPA/NC +COAST GRE ABUTS AEG ALB BUL/SC ION SER +WATER HEL ABUTS DEN HOL KIE NTH +COAST HOL ABUTS BEL HEL KIE NTH RUH +WATER ION ABUTS ADR AEG ALB APU EAS GRE NAP TUN TYS +WATER IRI ABUTS ENG LVP MAO NAO WAL +COAST KIE ABUTS BAL BER DEN HEL HOL MUN RUH +COAST LON ABUTS ENG NTH YOR WAL +COAST LVN ABUTS BAL BOT MOS PRU STP/SC WAR +COAST LVP ABUTS CLY edi IRI NAO WAL yor +WATER LYO ABUTS MAR PIE SPA/SC TUS TYS WES +WATER MAO ABUTS BRE ENG GAS IRI NAF NAO POR SPA/NC SPA/SC WES +COAST MAR ABUTS BUR gas LYO PIE SPA/SC SWI +LAND MOS ABUTS LVN SEV STP UKR WAR +LAND MUN ABUTS BER BOH BUR KIE RUH SIL TYR SWI +COAST NAF ABUTS MAO TUN WES +WATER NAO ABUTS CLY IRI LVP MAO NWG +COAST NAP ABUTS APU ION ROM TYS +COAST NWY ABUTS BAR fin NTH NWG SKA STP/NC SWE +WATER NTH ABUTS BEL DEN EDI ENG LON HEL HOL NWY NWG SKA YOR +WATER NWG ABUTS BAR CLY EDI NAO NWY NTH +LAND PAR ABUTS BUR BRE GAS PIC +COAST PIC ABUTS BEL BRE BUR ENG PAR +COAST PIE ABUTS LYO MAR TUS TYR ven SWI +COAST POR ABUTS MAO SPA/NC SPA/SC +COAST PRU ABUTS BAL BER LVN SIL WAR +COAST ROM ABUTS apu NAP TUS TYS ven +LAND RUH ABUTS BEL BUR HOL KIE MUN +COAST RUM ABUTS BLA BUD BUL/EC GAL SER SEV UKR +LAND SER ABUTS ALB BUD BUL GRE RUM TRI +COAST SEV ABUTS ARM BLA MOS RUM UKR +LAND SIL ABUTS BER BOH GAL MUN PRU WAR +WATER SKA ABUTS DEN NWY NTH SWE +COAST SMY ABUTS AEG ank arm CON EAS SYR +COAST SPA/NC ABUTS GAS MAO POR +COAST SPA/SC ABUTS LYO MAO MAR POR WES +COAST spa ABUTS GAS LYO MAO MAR POR WES +COAST STP/NC ABUTS BAR NWY +COAST STP/SC ABUTS BOT FIN LVN +COAST stp ABUTS BAR BOT FIN LVN MOS NWY +COAST SWE ABUTS BAL BOT DEN FIN NWY SKA +COAST SYR ABUTS arm EAS SMY +COAST TRI ABUTS ADR ALB BUD SER TYR VEN VIE +COAST TUN ABUTS ION NAF TYS WES +COAST TUS ABUTS LYO PIE ROM TYS ven +LAND TYR ABUTS BOH MUN PIE TRI VEN VIE SWI +WATER TYS ABUTS ION LYO ROM NAP TUN TUS WES +LAND UKR ABUTS GAL MOS RUM SEV WAR +COAST VEN ABUTS ADR APU pie rom TRI tus TYR +LAND VIE ABUTS BOH BUD GAL TRI TYR +COAST WAL ABUTS ENG IRI LON LVP yor +LAND WAR ABUTS GAL LVN MOS PRU SIL UKR +WATER WES ABUTS MAO LYO NAF SPA/SC TUN TYS +COAST YOR ABUTS EDI LON lvp NTH wal + +# ------------------------ +# Switzerland (impassable) +# ------------------------ +Switzerland = swi +SHUT SWI ABUTS MAR BUR MUN TYR PIE diff --git a/tests/test_diplomacy.py b/tests/test_diplomacy.py index 539cdc4e22..f3b9011c0e 100644 --- a/tests/test_diplomacy.py +++ b/tests/test_diplomacy.py @@ -1,318 +1,205 @@ -""" -Comprehensive test suite for Welfare Diplomacy C implementation. - -This test file covers: -1. Basic initialization and structure -2. Map data validation -3. Game state management -4. Order parsing and validation -5. Comparison with Python implementation -6. Welfare-specific features -""" - -import pytest +"""Public full-order interface, complete games, observations and replay.""" import numpy as np - - -class TestDiplomacyStructures: - """Test basic data structure initialization.""" - - @pytest.mark.skip(reason="Waiting for C compilation") - def test_import_module(self): - """Test that the diplomacy module can be imported.""" - from pufferlib.ocean.diplomacy import Diplomacy - - @pytest.mark.skip(reason="Waiting for C compilation") - def test_create_environment(self): - """Test basic environment creation.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy() - assert env is not None - env.close() - - @pytest.mark.skip(reason="Waiting for C compilation") - def test_observation_space(self): - """Test observation space is correctly defined.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy() - assert env.observation_space is not None - assert 'board' in env.observation_space.spaces - assert 'units' in env.observation_space.spaces - assert 'phase' in env.observation_space.spaces - assert 'year' in env.observation_space.spaces - env.close() - - @pytest.mark.skip(reason="Waiting for C compilation") - def test_action_space(self): - """Test action space is correctly defined.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy() - assert env.action_space is not None - env.close() - - -class TestGameInitialization: - """Test game initialization and reset.""" - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_initial_phase(self): - """Test game starts in Spring 1901 Movement phase.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy() - obs, info = env.reset() - assert obs['year'][0] == 1901 - assert obs['phase'][0] == 0 # SPRING_MOVEMENT - env.close() - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_initial_units(self): - """Test initial unit placement is correct.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy() - obs, info = env.reset() - - # Each power should start with 3 units - for power_units in obs['units_count']: - assert power_units == 3 - - env.close() - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_initial_centers(self): - """Test initial supply center ownership is correct.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy() - obs, info = env.reset() - - # Austria, England, France, Russia start with 3 centers - # Italy, Turkey start with 3 centers - # Germany starts with 3 centers - expected_centers = [3, 3, 3, 3, 3, 4, 3] # Russia starts with 4 - - for i, centers in enumerate(obs['centers']): - assert centers == expected_centers[i], f"Power {i} should have {expected_centers[i]} centers" - - env.close() - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_reset_clears_state(self): - """Test that reset properly clears game state.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy() - obs1, _ = env.reset(seed=42) - - # Step a few times - for _ in range(5): - obs, reward, terminated, truncated, info = env.step(0) - - # Reset and verify we're back to initial state - obs2, _ = env.reset(seed=42) - - assert obs1['year'][0] == obs2['year'][0] - assert obs1['phase'][0] == obs2['phase'][0] - np.testing.assert_array_equal(obs1['centers'], obs2['centers']) - - env.close() - - -class TestWelfareDiplomacy: - """Test Welfare Diplomacy specific features.""" - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_welfare_mode_enabled(self): - """Test welfare mode is enabled by default.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy(welfare_mode=True) - assert env.welfare_mode is True - env.close() - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_welfare_points_initialization(self): - """Test welfare points start at zero.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy() - obs, _ = env.reset() - - # All powers should start with 0 welfare points - for welfare in obs['welfare']: - assert welfare == 0 - - env.close() - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_welfare_points_calculation(self): - """Test welfare points are calculated correctly after adjustment.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy() - obs, _ = env.reset() - - # TODO: Simulate game to winter adjustment phase - # TODO: Disband units to create welfare points - # TODO: Verify welfare = centers - units - - env.close() - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_voluntary_disbanding(self): - """Test powers can voluntarily disband units in welfare mode.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy(welfare_mode=True) - obs, _ = env.reset() - - # TODO: Test that disband orders are valid even with surplus centers - # In standard Diplomacy: centers > units → must build - # In Welfare: centers > units → can disband instead - - env.close() - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_no_victory_condition(self): - """Test game doesn't end when a power controls 18+ centers.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy(welfare_mode=True, max_years=10) - obs, _ = env.reset() - - # TODO: Simulate a power gaining 18+ centers - # TODO: Verify game continues until max_years - - env.close() - - @pytest.mark.skip(reason="Waiting for full implementation") - def test_game_ends_at_max_years(self): - """Test game ends after max_years.""" - from pufferlib.ocean.diplomacy import Diplomacy - - env = Diplomacy(max_years=2) # Short game for testing - obs, _ = env.reset() - - # TODO: Simulate to 1902 winter - # TODO: Verify game ends (terminated=True) - - env.close() - - -class TestMapData: - """Test map data is loaded correctly.""" - - @pytest.mark.skip(reason="Waiting for map implementation") - def test_standard_map_locations(self): - """Test standard map has correct number of locations.""" - # Standard Diplomacy map has 75 locations - # TODO: Verify map.num_locations == 75 - pass - - @pytest.mark.skip(reason="Waiting for map implementation") - def test_power_names(self): - """Test all 7 powers are defined.""" - expected_powers = [ - "AUSTRIA", "ENGLAND", "FRANCE", "GERMANY", - "ITALY", "RUSSIA", "TURKEY" - ] - # TODO: Verify map.power_names matches expected_powers - pass - - @pytest.mark.skip(reason="Waiting for map implementation") - def test_adjacencies(self): - """Test basic adjacency relationships.""" - # TODO: Test a few known adjacencies - # e.g., PAR should be adjacent to BUR, PIC, GAS, BRE, MAR - pass - - @pytest.mark.skip(reason="Waiting for map implementation") - def test_supply_centers(self): - """Test supply centers are marked correctly.""" - # Standard map has 34 supply centers - # TODO: Verify count and positions - pass - - -class TestOrderParsing: - """Test order parsing and validation.""" - - @pytest.mark.skip(reason="Waiting for order parsing implementation") - def test_parse_move_order(self): - """Test parsing a move order.""" - # TODO: Parse "A PAR - MAR" and verify order struct - pass - - @pytest.mark.skip(reason="Waiting for order parsing implementation") - def test_parse_hold_order(self): - """Test parsing a hold order.""" - # TODO: Parse "A PAR H" and verify order struct - pass - - @pytest.mark.skip(reason="Waiting for order parsing implementation") - def test_parse_support_order(self): - """Test parsing a support order.""" - # TODO: Parse "A PAR S A MAR - BUR" - pass - - @pytest.mark.skip(reason="Waiting for order parsing implementation") - def test_parse_convoy_order(self): - """Test parsing a convoy order.""" - # TODO: Parse "F ENG C A WAL - BRE" - pass - - @pytest.mark.skip(reason="Waiting for order parsing implementation") - def test_invalid_order_syntax(self): - """Test invalid order syntax is rejected.""" - # TODO: Test various malformed orders - pass - - -class TestOrderValidation: - """Test order validation logic.""" - - @pytest.mark.skip(reason="Waiting for validation implementation") - def test_valid_move_to_adjacent(self): - """Test valid move to adjacent province.""" - pass - - @pytest.mark.skip(reason="Waiting for validation implementation") - def test_invalid_move_non_adjacent(self): - """Test invalid move to non-adjacent province.""" - pass - - @pytest.mark.skip(reason="Waiting for validation implementation") - def test_army_cannot_enter_water(self): - """Test army cannot move to water location.""" - pass - - @pytest.mark.skip(reason="Waiting for validation implementation") - def test_fleet_cannot_enter_inland(self): - """Test fleet cannot move to inland location.""" - pass - - -class TestPythonComparison: - """Compare C implementation with Python version.""" - - @pytest.mark.skip(reason="Waiting for both implementations") - def test_initial_state_matches_python(self): - """Test initial state matches Python implementation.""" - # TODO: Import Python version from welfare-diplomacy repo - # TODO: Compare initial states - pass - - @pytest.mark.skip(reason="Waiting for both implementations") - def test_order_resolution_matches_python(self): - """Test order resolution matches Python for known scenarios.""" - # TODO: Run same scenario in both implementations - # TODO: Compare final states - pass - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) +import pytest +from pufferlib.ocean.diplomacy import Game, POWERS, binding + + +def set_position(game, units, phase='S1901M', centers=None): + handle = game._env.env_handle + for i, p in enumerate(POWERS): + binding.game_clear_units(handle, i) + binding.game_set_units(handle, i, [tuple(u.split()) for u in units.get(p, [])]) + if centers is not None: + binding.game_clear_centers(handle) + for p, locations in centers.items(): + binding.game_set_centers(handle, POWERS.index(p), locations) + binding.game_set_phase(handle, phase) + + +def test_complete_initial_observations(): + with Game() as game: + state = game.state + assert len(state['locations']) == 82 + assert sum(l['is_supply_center'] for l in state['locations']) == 34 + assert sum(len(v['units']) for v in state['powers'].values()) == 22 + assert 'F STP/SC' in state['powers']['RUSSIA']['units'] + assert len(game.observations()) == 7 + for p in POWERS: + assert game.observe(p)['actor'] == p + assert game.observe(p)['unit_slots'] == state['powers'][p]['units'] + assert sum(l['owner_power'] < 0 and l['is_supply_center'] for l in state['locations']) == 12 + assert all(l['home_power'] == -1 for l in state['locations'] if not l['is_supply_center']) + + +def test_all_units_support_and_retreat(): + with Game() as game: + set_position(game, {'FRANCE': ['A BUR', 'A PIC'], 'GERMANY': ['A BEL']}) + game.step({'FRANCE': ['A BUR - BEL', 'A PIC S A BUR - BEL'], 'GERMANY': ['A BEL H']}) + assert game.state['phase'] == 'SPRING_RETREAT' + assert 'A BEL' in game.state['powers']['FRANCE']['units'] + retreat = game.state['retreats'][0] + assert retreat['power'] == 'GERMANY' + assert 'HOL' in retreat['destinations'] + assert 'A BEL R HOL' in game.possible_orders('GERMANY')['A BEL'] + game.step({'GERMANY': ['A BEL R HOL']}) + assert game.state['powers']['GERMANY']['units'] == ['A HOL'] + + +def test_convoy_through_public_interface(): + with Game() as game: + set_position(game, {'ENGLAND': ['A LON', 'F NTH']}) + game.step({'ENGLAND': ['A LON - BEL VIA', 'F NTH C A LON - BEL']}) + assert 'A BEL' in game.state['powers']['ENGLAND']['units'] + + +def test_empty_power_build_and_coast_build(): + with Game() as game: + set_position(game, {}, 'W1901A', {'RUSSIA': ['STP', 'MOS']}) + assert 'F STP/SC B' in game.possible_orders('RUSSIA')['STP/SC'] + game.step({'RUSSIA': ['F STP/SC B', 'A MOS B']}) + assert sorted(game.state['powers']['RUSSIA']['units']) == ['A MOS', 'F STP/SC'] + + +def test_invalid_submission_is_atomic(): + with Game() as game: + before = game.state_hash() + with pytest.raises(ValueError): + game.step({'FRANCE': ['A PAR - BUR'], 'GERMANY': ['nonsense']}) + assert before == game.state_hash() + game.step({}) + assert 'A PAR' in game.state['powers']['FRANCE']['units'] + + +@pytest.mark.parametrize('seed', [0, 1, 2]) +@pytest.mark.parametrize('welfare', [True, False]) +def test_complete_seeded_games_and_replay(seed, welfare, tmp_path): + with Game(max_years=3, welfare_mode=welfare, seed=seed) as game: + rewards_total = {p: 0 for p in POWERS} + for step in range(30): + orders = game.scripted_orders(seed * 100 + step) + _, rewards, done, truncated, _ = game.step(orders) + assert not truncated + state = game.state + occupied = [] + for p, data in state['powers'].items(): + rewards_total[p] += rewards[p] + assert np.isfinite(rewards[p]) + assert len(data['units']) <= 34 + occupied += [u.split()[1].split('/')[0] for u in data['units']] + assert data['welfare'] == rewards_total[p] + assert len(occupied) == len(set(occupied)) + assert 'SWI' not in occupied + if done: + break + else: + pytest.fail('three-year game did not finish') + assert state['year'] == 1904 + path = tmp_path / 'replay.json' + game.save_replay(path) + with Game.from_replay(path) as restored: + assert restored.state_hash() == game.state_hash() + with pytest.raises(RuntimeError): + game.step({}) + game.reset() + assert game.state['year'] == 1901 + game.close() + with pytest.raises(RuntimeError): + game.observe('FRANCE') + + +def test_support_must_reach_destination_not_origin(): + with Game() as game: + set_position(game, {'FRANCE': ['A PAR', 'A BUR'], 'GERMANY': ['A MUN']}) + assert 'A PAR S A BUR - MUN' not in game.possible_orders('FRANCE')['A PAR'] + game.step({'FRANCE': ['A BUR - MUN', 'A PAR S A BUR - MUN']}) + assert 'A MUN' in game.state['powers']['GERMANY']['units'] + + +def test_army_coast_build_normalizes_parent(): + with Game() as game: + set_position(game, {}, 'W1901A', {'RUSSIA': ['STP']}) + game.step({'RUSSIA': ['A STP/SC B']}) + assert game.state['powers']['RUSSIA']['units'] == ['A STP'] + + +def test_convoys_require_distinct_coastal_provinces(): + with Game() as game: + set_position(game, {'ENGLAND': ['A LON', 'F NTH']}) + options = game.possible_orders('ENGLAND')['F NTH'] + for destination in ['ADR', 'MOS', 'LON']: + assert 'F NTH C A LON - ' + destination not in options + + +def test_wrong_type_winter_disband_is_void(): + with Game() as game: + game.step({}) + game.step({}) + game.step({'FRANCE': ['F PAR D']}) + assert 'A PAR' in game.state['powers']['FRANCE']['units'] + + +def test_retreat_options_exclude_occupied_provinces(): + with Game() as game: + set_position(game, {'FRANCE': ['A BUR', 'A PIC'], 'GERMANY': ['A BEL'], 'ITALY': ['A HOL']}) + game.step({'FRANCE': ['A BUR - BEL', 'A PIC S A BUR - BEL']}) + choices = game.state['retreats'][0]['destinations'] + assert 'HOL' not in choices + assert 'PIC' not in choices + assert 'RUH' in choices + + +def test_wrong_type_retreat_disbands_instead_of_moving(): + with Game() as game: + set_position(game, {'FRANCE': ['A BUR', 'A PIC'], 'GERMANY': ['A BEL']}) + game.step({'FRANCE': ['A BUR - BEL', 'A PIC S A BUR - BEL']}) + _, _, _, _, info = game.step({'GERMANY': ['F BEL R HOL']}) + assert game.state['powers']['GERMANY']['units'] == [] + assert info['results']['GERMANY'] == [5] + + +def test_duplicate_coast_orders_are_rejected_atomically(): + with Game() as game: + before = game.state_hash() + with pytest.raises(ValueError): + game.step({'RUSSIA': ['F STP/SC - BOT', 'F STP/NC - FIN']}) + assert before == game.state_hash() + + +def test_retreats_to_opposite_coasts_conflict(): + with Game() as game: + set_position(game, {'FRANCE': ['F MAO'], 'ITALY': ['F LYO'], + 'ENGLAND': ['F NAO', 'F IRI'], 'TURKEY': ['F TYS', 'F WES']}) + game.step({'ENGLAND': ['F NAO - MAO', 'F IRI S F NAO - MAO'], + 'TURKEY': ['F TYS - LYO', 'F WES S F TYS - LYO']}) + assert game.state['phase'] == 'SPRING_RETREAT' + assert len(game.state['retreats']) == 2 + game.step({'FRANCE': ['F MAO R SPA/NC'], 'ITALY': ['F LYO R SPA/SC']}) + assert game.state['powers']['FRANCE']['units'] == [] + assert game.state['powers']['ITALY']['units'] == [] + + +def test_retreat_results_follow_unit_not_submission_order(): + with Game() as game: + set_position(game, {'FRANCE': ['F MAO', 'F LYO'], 'ITALY': ['F GAS'], + 'ENGLAND': ['F NAO', 'F IRI'], 'TURKEY': ['F TYS', 'F WES'], + 'GERMANY': ['A PAR', 'A BRE']}) + game.step({'ENGLAND': ['F NAO - MAO', 'F IRI S F NAO - MAO'], + 'TURKEY': ['F TYS - LYO', 'F WES S F TYS - LYO'], + 'GERMANY': ['A PAR - GAS', 'A BRE S A PAR - GAS']}) + assert len(game.state['retreats']) == 3 + _, _, _, _, info = game.step({'FRANCE': ['F LYO R MAR', 'F MAO R SPA/NC'], + 'ITALY': ['F GAS R SPA/NC']}) + assert game.state['powers']['FRANCE']['units'] == ['F MAR'] + assert game.state['powers']['ITALY']['units'] == [] + assert info['results']['FRANCE'] == [1, 2] + + +def test_army_retreat_coast_alias_normalizes_parent(): + with Game() as game: + set_position(game, {'RUSSIA': ['A LVN'], 'GERMANY': ['A PRU', 'A WAR']}) + game.step({'GERMANY': ['A PRU - LVN', 'A WAR S A PRU - LVN']}) + choices = game.possible_orders('RUSSIA')['A LVN'] + assert 'A LVN R STP' in choices + assert 'A LVN R STP/SC' not in choices + game.step({'RUSSIA': ['A LVN R STP/SC']}) + assert game.state['powers']['RUSSIA']['units'] == ['A STP']