From b6f8fd350486d1f8e364bdc698d84547c15bfbe1 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Wed, 16 Sep 2026 12:06:47 +0200 Subject: [PATCH] Removed unused parts of Godot for export --- nix/package.nix | 21 ++++ project.godot | 1 + scripts/classes/hex_pathfinder.gd | 31 +++--- scripts/server/game_server.gd | 22 ++++- scripts/server/game_state.gd | 158 ++++++++++++++++++++++-------- tests/game_server_test.gd | 12 +++ tests/game_state_test.gd | 43 ++++++++ 7 files changed, 231 insertions(+), 57 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 51060a1..6c51189 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -23,6 +23,10 @@ let # disable SIMD on some CPUs (e.g. Firefox on x86/x64 without SSE4.1) and then # reject the module with "v128 not enabled", so compile a matching template # without it and ship that instead. + # + # The template is also stripped down to what this 2D game needs: no 3D, 3D + # physics, navigation, XR or any engine module it never touches. 2D physics is + # kept because `scenes/units/unit.tscn` uses an `Area2D` for unit hover. webTemplates = stdenv.mkDerivation (finalAttrs: { pname = "godot-web-nosimd-template"; inherit (godot_4_6) version; @@ -54,8 +58,25 @@ let # archive the template) rejects because it predates 1980. find . -exec touch -h {} + + # Only the modules this game actually uses are compiled in; everything + # else (3D and its importers, compressed/extra image formats, audio + # codecs, ENet/WebRTC/UPnP, regex, SVG rasterisation, texture + # compressors, ...) is left out. `modules_enabled_by_default=no` keeps a + # future upstream module out of the build until it is enabled here. + # + # `force_png` (project.godot) makes the importer store lossless textures + # as PNG, so the WebP module is not needed at runtime. scons platform=web target=template_release \ wasm_simd=no threads=no dlink_enabled=no \ + disable_3d=yes disable_physics_3d=yes \ + disable_navigation_2d=yes disable_navigation_3d=yes disable_xr=yes \ + modules_enabled_by_default=no \ + module_freetype_enabled=yes \ + module_gdscript_enabled=yes \ + module_multiplayer_enabled=yes \ + module_noise_enabled=yes \ + module_text_server_adv_enabled=yes \ + module_websocket_enabled=yes \ -j$NIX_BUILD_CORES runHook postBuild diff --git a/project.godot b/project.godot index 6b7982c..6004218 100644 --- a/project.godot +++ b/project.godot @@ -62,3 +62,4 @@ camera_pan_down={ rendering_device/driver.windows="d3d12" renderer/rendering_method="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility" +textures/lossless_compression/force_png=true diff --git a/scripts/classes/hex_pathfinder.gd b/scripts/classes/hex_pathfinder.gd index 2478169..597c4e5 100644 --- a/scripts/classes/hex_pathfinder.gd +++ b/scripts/classes/hex_pathfinder.gd @@ -74,7 +74,14 @@ class _MinHeap: var parent := (index - 1) >> 1 if _keys[parent] <= _keys[index]: break - _swap(parent, index) + # Swaps are inlined: the heap is the hottest loop in A*, and a + # GDScript call per level dominated the profile. + var key_swap := _keys[parent] + _keys[parent] = _keys[index] + _keys[index] = key_swap + var value_swap := _values[parent] + _values[parent] = _values[index] + _values[index] = value_swap index = parent func pop() -> Vector2i: @@ -84,11 +91,7 @@ class _MinHeap: _values[0] = _values[last] _keys.resize(last) _values.resize(last) - if not _keys.is_empty(): - _sift_down(0) - return value - - func _sift_down(index: int) -> void: + var index := 0 var count := _keys.size() while true: var smallest := index @@ -100,13 +103,11 @@ class _MinHeap: smallest = right if smallest == index: break - _swap(index, smallest) + var key_swap := _keys[index] + _keys[index] = _keys[smallest] + _keys[smallest] = key_swap + var value_swap := _values[index] + _values[index] = _values[smallest] + _values[smallest] = value_swap index = smallest - - func _swap(a: int, b: int) -> void: - var key := _keys[a] - _keys[a] = _keys[b] - _keys[b] = key - var value := _values[a] - _values[a] = _values[b] - _values[b] = value + return value diff --git a/scripts/server/game_server.gd b/scripts/server/game_server.gd index 2a4eb46..88ebf05 100644 --- a/scripts/server/game_server.gd +++ b/scripts/server/game_server.gd @@ -39,6 +39,10 @@ var identity_civ: Dictionary = {} var _configured: bool = false var _profile_elapsed: float = 0.0 +## Set when the state changed since the last broadcast. Snapshots are expensive, +## so several changes in one frame are coalesced into a single broadcast in +## _process instead of one per changed.emit(). +var _state_dirty: bool = false func _ready() -> void: @@ -51,6 +55,9 @@ func _ready() -> void: func _process(delta: float) -> void: if _configured and state != null: state.advance_movement(delta / GameClock.SECONDS_PER_HOUR) + if _state_dirty: + _state_dirty = false + _broadcast_state() if profile_interval <= 0.0: return _profile_elapsed += delta @@ -139,7 +146,7 @@ func configure_game(setup: Dictionary) -> void: state = GameState.new() state.map_config = map_config state.configure(_load_civs(setup.get("civilisations", [])), int(setup.get("seed", 0))) - state.changed.connect(_broadcast_state) + state.changed.connect(_on_state_changed) _configured = true for peer_id in Network.get_peer_ids(): _assign_civ(int(peer_id), str(peer_names.get(int(peer_id), ""))) @@ -284,12 +291,23 @@ func _on_hour() -> void: state.tick_hour() +func _on_state_changed() -> void: + _state_dirty = true + + func _broadcast_state() -> void: if not Network.is_server() or state == null: return Profiler.begin("server.broadcast_state") + # The world data is identical for everyone, so serialise it once and only + # add each viewer's explored/visible/stats on top. + var shared := state.serialize_shared() + var stats_cache := {} for peer_id in Network.get_peer_ids(): - Network.send_game_state(peer_id, state.snapshot(int(peer_civ.get(peer_id, -1)))) + var civ := int(peer_civ.get(peer_id, -1)) + if not stats_cache.has(civ): + stats_cache[civ] = state.viewer_stats(civ) + Network.send_game_state(peer_id, state.viewer_snapshot(shared, civ, stats_cache[civ])) Profiler.end("server.broadcast_state") diff --git a/scripts/server/game_state.gd b/scripts/server/game_state.gd index a850e73..0f9fbfe 100644 --- a/scripts/server/game_state.gd +++ b/scripts/server/game_state.gd @@ -112,11 +112,28 @@ var _max_step_length: float = 1.0 var _next_unit_id: int = 1 var _next_city_id: int = 1 var _city_names_used: Dictionary = {} +## Id -> unit/city dictionaries, so orders and training resolve in constant time +## instead of scanning the arrays (which grow as units are trained). +var _unit_by_id: Dictionary = {} +var _city_by_id: Dictionary = {} ## City lookup keyed by tile, and the tiles adjacent to a city keyed by the ## owning civilisation. Both are derived caches rebuilt when cities change; ## they keep per-tile economy queries off the O(cities) path. var _city_by_coords: Dictionary = {} var _city_adjacent: Dictionary = {} +## Territory grouped by owner, so per-civ economy and visibility queries only +## walk their own tiles instead of scanning every claimed tile. Rebuilt +## alongside `territory` in _recompute_territory. +var _territory_by_civ: Dictionary = {} +## A tile's GDP per capita depends only on terrain, city placement and +## ownership, all fixed once the world is generated, so it is memoised instead +## of being recomputed on every economy query. +var _gdp_per_capita_cache: Dictionary = {} +## Serialised territory and per-viewer explored sets. Both change rarely (only +## on configure/claiming, and when a unit reveals new ground), so the wire +## arrays are built once and reused instead of rebuilt for every snapshot. +var _serialized_territory: Array = [] +var _serialized_explored: Dictionary = {} ## Terrain neighbourhoods, memoised because pathfinding asks for each tile's ## neighbours dozens of times. Cleared on configure; never mutated by callers. var _neighbour_cache: Dictionary = {} @@ -177,9 +194,15 @@ func configure(civs: Array, world_seed: int = 0) -> void: visible.clear() _next_unit_id = 1 _next_city_id = 1 + _unit_by_id.clear() + _city_by_id.clear() _city_names_used.clear() _city_by_coords.clear() _city_adjacent.clear() + _territory_by_civ.clear() + _gdp_per_capita_cache.clear() + _serialized_territory = [] + _serialized_explored.clear() _neighbour_cache.clear() total_hours = 0 _configured = true @@ -377,17 +400,11 @@ func tick_hour() -> void: func find_unit(unit_id: int) -> Dictionary: - for unit in units: - if int(unit["id"]) == unit_id: - return unit - return {} + return _unit_by_id.get(unit_id, {}) func find_city(city_id: int) -> Dictionary: - for city in cities: - if int(city["id"]) == city_id: - return city - return {} + return _city_by_id.get(city_id, {}) func unit_proto(unit: Dictionary) -> ProtoUnit: @@ -434,14 +451,26 @@ func get_tile_info(coords: Vector2i) -> Dictionary: func get_player_population(civ: int) -> int: - var total := 0.0 - for coords in territory.keys(): - if int(territory[coords]) == civ: - total += float(tile_population.get(coords, 0.0)) - return roundi(total) + return int(_player_aggregates(civ)["population"]) + + +## Population and GDP of a civilisation in a single pass over its tiles. The +## snapshot viewer stats need both, and two separate scans doubled the cost. +func _player_aggregates(civ: int) -> Dictionary: + var population := 0.0 + var base_gdp := 0.0 + for coords in _territory_by_civ.get(civ, []): + var pop := float(tile_population.get(coords, 0.0)) + population += pop + base_gdp += pop * get_tile_gdp_per_capita(coords) + var modifiers := get_civ_modifiers(civ) + var gdp := base_gdp * (1.0 + float(modifiers.get(ProtoBuilding.EFFECT_GDP, 0.0))) + return {"population": roundi(population), "gdp": gdp} func get_tile_gdp_per_capita(coords: Vector2i) -> float: + if _gdp_per_capita_cache.has(coords): + return _gdp_per_capita_cache[coords] var owner := civ_at(coords) if owner < 0: return 0.0 @@ -451,6 +480,7 @@ func get_tile_gdp_per_capita(coords: Vector2i) -> float: per_capita *= economy.capital_tile_gdp_multiplier if city["is_capital"] else economy.city_tile_gdp_multiplier elif _is_next_to_city(coords, owner): per_capita *= 1.0 + economy.city_surrounding_gdp_bonus + _gdp_per_capita_cache[coords] = per_capita return per_capita @@ -460,18 +490,15 @@ func get_tile_gdp(coords: Vector2i) -> float: func get_player_gdp(civ: int) -> float: Profiler.begin("state.player_gdp") - var total := 0.0 - for coords in territory.keys(): - if int(territory[coords]) == civ: - total += get_tile_gdp(coords) - var modifiers := get_civ_modifiers(civ) - total *= 1.0 + float(modifiers.get(ProtoBuilding.EFFECT_GDP, 0.0)) + var total := float(_player_aggregates(civ)["gdp"]) Profiler.end("state.player_gdp") return total -func get_player_upkeep(civ: int) -> float: - var total := get_building_upkeep(civ) +## Total upkeep per game hour. Callers that already know the civilisation's GDP +## (the economy tick and viewer stats) pass it in to avoid a second full scan. +func get_player_upkeep(civ: int, gdp: float = -1.0) -> float: + var total := get_building_upkeep(civ, gdp) for unit in units: if int(unit["civ"]) != civ: continue @@ -483,9 +510,10 @@ func get_player_upkeep(civ: int) -> float: ## Budget upkeep of every building a civilisation owns, per game hour. Upkeep ## is a fraction of GDP so it keeps pace with the economy as it grows. -func get_building_upkeep(civ: int) -> float: +func get_building_upkeep(civ: int, gdp: float = -1.0) -> float: var total := 0.0 - var gdp := get_player_gdp(civ) + if gdp < 0.0: + gdp = get_player_gdp(civ) for city in cities: if int(city["civ"]) != civ: continue @@ -691,7 +719,7 @@ func _spawn_city(site: Dictionary) -> void: var coords: Vector2i = site["coords"] var is_capital: bool = site["is_capital"] var description := _city_description_for(civ, is_capital, coords) - cities.append({ + var city := { "id": _next_city_id, "civ": civ_index, "coords": coords, @@ -700,7 +728,9 @@ func _spawn_city(site: Dictionary) -> void: "population": description.population, "improvements": description.improvements.duplicate(), "buildings": {}, - }) + } + cities.append(city) + _city_by_id[_next_city_id] = city _next_city_id += 1 @@ -744,7 +774,7 @@ func _spawn_unit(center: Vector2i, civ_index: int, proto: ProtoUnit) -> void: if index < 0: proto_units.append(proto) index = proto_units.size() - 1 - units.append({ + var unit := { "id": _next_unit_id, "civ": civ_index, "proto": index, @@ -754,7 +784,9 @@ func _spawn_unit(center: Vector2i, civ_index: int, proto: ProtoUnit) -> void: "path": [], "path_index": 0, "progress_hours": 0.0, - }) + } + units.append(unit) + _unit_by_id[_next_unit_id] = unit _next_unit_id += 1 @@ -762,6 +794,7 @@ func _spawn_unit(center: Vector2i, civ_index: int, proto: ProtoUnit) -> void: ## landmass, via a multi-source breadth-first search seeded from every city. func _recompute_territory() -> void: territory.clear() + _gdp_per_capita_cache.clear() var owner_by_cell: Dictionary = {} var frontier: Array[Vector2i] = [] for city in cities: @@ -781,6 +814,13 @@ func _recompute_territory() -> void: territory[neighbour] = owner next.append(neighbour) frontier = next + _territory_by_civ.clear() + for coords in territory.keys(): + var owner := int(territory[coords]) + if not _territory_by_civ.has(owner): + _territory_by_civ[owner] = [] + _territory_by_civ[owner].append(coords) + _serialized_territory = _serialize_cells(territory) func _generate_population() -> void: @@ -828,7 +868,7 @@ func _tick_economy() -> void: var modifiers := get_civ_modifiers(civ) var income := economy.budget_income(gdp, hours) \ * (1.0 + float(modifiers.get(ProtoBuilding.EFFECT_BUDGET_INCOME, 0.0))) - var upkeep := get_player_upkeep(civ) * hours + var upkeep := get_player_upkeep(civ, gdp) * hours budgets[civ] = float(budgets[civ]) + income - upkeep @@ -867,8 +907,9 @@ func _refresh_visibility() -> void: visible.clear() for i in civilisations.size(): visible[i] = {} - for coords in territory.keys(): - _reveal(int(territory[coords]), coords, TERRITORY_VISION) + for civ in _territory_by_civ.keys(): + for coords in _territory_by_civ[civ]: + _reveal(int(civ), coords, TERRITORY_VISION) for unit in units: _reveal(int(unit["civ"]), unit["coords"], UNIT_VISION) Profiler.end("state.refresh_visibility") @@ -888,9 +929,26 @@ func _reveal(civ: int, center: Vector2i, radius: int) -> void: explored[civ] = {} if not visible.has(civ): visible[civ] = {} + var explored_cells: Dictionary = explored[civ] + var visible_cells: Dictionary = visible[civ] + var discovered := false for coords in _cells_in_radius(center, radius): - explored[civ][coords] = true - visible[civ][coords] = true + if not explored_cells.has(coords): + explored_cells[coords] = true + discovered = true + visible_cells[coords] = true + # Only new ground changes the serialised explored set; visibility is not + # cached because it changes every time a unit moves. + if discovered: + _serialized_explored.erase(civ) + + +## Serialised explored set for one viewer, built once and reused until a unit +## reveals a tile the civilisation had not seen before. +func _cached_explored(civ: int) -> Array: + if not _serialized_explored.has(civ): + _serialized_explored[civ] = _serialize_cells(explored.get(civ, {})) + return _serialized_explored[civ] func _cells_in_radius(center: Vector2i, radius: int) -> Array[Vector2i]: @@ -961,10 +1019,16 @@ func _compute_max_step_length() -> float: ## that cannot travel over RPC is included. When a viewer civilisation is given, ## its explored/visible sets are included. func snapshot(viewer_civ: int = -1) -> Dictionary: + return viewer_snapshot(serialize_shared(), viewer_civ) + + +## Everything in a snapshot that is identical for every viewer. The server +## builds this once per broadcast and layers the cheap per-viewer fields on top, +## instead of reserialising the whole world for every connected peer. +func serialize_shared() -> Dictionary: Profiler.begin("state.snapshot") var result := { "seed": seed, - "viewer": viewer_civ, "total_hours": total_hours, "civs": _serialize_civs(), "protos": _serialize_protos(), @@ -973,22 +1037,35 @@ func snapshot(viewer_civ: int = -1) -> Dictionary: "technologies": _serialize_technology_defs(), "cities": _serialize_cities(), "units": _serialize_units(), - "territory": _serialize_cells(territory), + "territory": _serialized_territory, "population": _serialize_population(), "budgets": _serialize_budgets(), "research": _serialize_civ_floats(research), "culture": _serialize_civ_floats(culture), "government": _serialize_civ_ints(government), "researched": _serialize_civ_researched(researched), - "viewer_stats": _viewer_stats(viewer_civ), "training": training.duplicate(true), - "explored": _serialize_cells(explored.get(viewer_civ, {})), - "visible": _serialize_cells(visible.get(viewer_civ, {})), } Profiler.end("state.snapshot") return result +## Adds the fields that differ per viewer to a shared snapshot. `stats` may be +## supplied by the caller to reuse a viewer_stats computed for another peer on +## the same civilisation. +func viewer_snapshot(shared: Dictionary, viewer_civ: int, stats: Dictionary = {}) -> Dictionary: + var result := shared.duplicate() + result["viewer"] = viewer_civ + result["viewer_stats"] = stats if not stats.is_empty() else viewer_stats(viewer_civ) + result["explored"] = _cached_explored(viewer_civ) + result["visible"] = _serialize_cells(visible.get(viewer_civ, {})) + return result + + +func viewer_stats(viewer_civ: int) -> Dictionary: + return _viewer_stats(viewer_civ) + + func _viewer_stats(viewer_civ: int) -> Dictionary: Profiler.begin("state.viewer_stats") if viewer_civ < 0: @@ -997,14 +1074,15 @@ func _viewer_stats(viewer_civ: int) -> Dictionary: "population": 0, "gdp": 0.0, "gdp_per_capita": 0.0, "budget": 0.0, "upkeep": 0.0, "research": 0.0, "culture": 0.0, "government": 0, } - var population := get_player_population(viewer_civ) - var gdp := get_player_gdp(viewer_civ) + var aggregates := _player_aggregates(viewer_civ) + var population := int(aggregates["population"]) + var gdp := float(aggregates["gdp"]) var result := { "population": population, "gdp": gdp, "gdp_per_capita": gdp / population if population > 0 else 0.0, "budget": get_budget(viewer_civ), - "upkeep": get_player_upkeep(viewer_civ), + "upkeep": get_player_upkeep(viewer_civ, gdp), "research": get_research(viewer_civ), "culture": get_culture(viewer_civ), "government": get_government(viewer_civ), diff --git a/tests/game_server_test.gd b/tests/game_server_test.gd index 5c16d0c..2c6e83d 100644 --- a/tests/game_server_test.gd +++ b/tests/game_server_test.gd @@ -81,6 +81,18 @@ func test_server_process_advances_movement() -> void: server.free() +## State changes only mark the server dirty; the broadcast is deferred to the +## next frame so a burst of orders cannot trigger several full snapshots. +func test_state_changes_coalesce_into_one_broadcast() -> void: + var server := _server() + server._on_state_changed() + server._on_state_changed() + assert_true(server._state_dirty) + server._process(1.0 / 60.0) + assert_false(server._state_dirty, "the pending change was flushed") + server.free() + + func test_move_order_for_enemy_unit_is_rejected() -> void: var server := _server() server.peer_civ[1] = 0 diff --git a/tests/game_state_test.gd b/tests/game_state_test.gd index 1ead70b..f8cb526 100644 --- a/tests/game_state_test.gd +++ b/tests/game_state_test.gd @@ -245,6 +245,49 @@ func test_snapshot_visibility_is_per_viewer() -> void: assert_ne(first["visible"], second["visible"]) +## The server serialises the viewer-independent half of a snapshot once and +## layers each viewer's fog on top, so shared fields must not differ per viewer. +func test_snapshot_shared_fields_are_viewer_independent() -> void: + var state := _state() + var first := state.snapshot(0) + var second := state.snapshot(1) + for key in ["seed", "total_hours", "civs", "protos", "buildings", "cities", "units", + "territory", "population", "budgets", "research", "culture", "government", + "researched", "training"]: + assert_eq(first[key], second[key], "key %s is shared" % key) + assert_ne(first["visible"], second["visible"], "visibility stays per viewer") + + +## The explored-set serialisation is cached until new ground is revealed, so the +## cache must be invalidated by a reveal that discovers a tile. +func test_explored_serialisation_cache_invalidates_on_discovery() -> void: + var state := _state() + state.explored[0].clear() + assert_empty(state.snapshot(0)["explored"], "cache reflects the emptied set") + state._reveal(0, state.land_cells[0], 1) + assert_true(state.snapshot(0)["explored"].size() > 0, "new ground is serialised") + + +func test_territory_index_matches_territory() -> void: + var state := _state() + var total := 0 + for civ in state._territory_by_civ.keys(): + for coords in state._territory_by_civ[civ]: + assert_eq(state.civ_at(coords), int(civ), "tile %s" % coords) + total += 1 + assert_eq(total, state.territory.size()) + + +func test_find_unit_and_city_use_the_id_index() -> void: + var state := _state() + for unit in state.units: + assert_eq(state.find_unit(int(unit["id"])), unit) + assert_true(state.find_unit(999).is_empty()) + for city in state.cities: + assert_eq(state.find_city(int(city["id"])), city) + assert_true(state.find_city(999).is_empty()) + + ## Friendly territory must be at least as fast as foreign/unclaimed land. func test_units_move_faster_in_their_own_territory() -> void: var state := _state()