From 2d38b9e5815778ada27199557ea8615952f3270e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 05:34:55 +0000 Subject: [PATCH 1/2] Initial plan From 36f6f55a53411748a839eed8986119d94aae5593 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 05:44:44 +0000 Subject: [PATCH 2/2] feat: allow specifying hub model inheritance per merged state Extends the merge config format to support per-hub inheritance overrides. Users can now specify which original state's hub province (city, port, farm, mine, wood) the merged state should inherit, instead of always using the default "keep diner's hub or fall back to first food's hub" behavior. New extended format (backward-compatible JSON): "STATE_A": { "merge": ["STATE_B", "STATE_C"], "city": "STATE_B", "port": "STATE_C" } Old list format still works unchanged. Co-authored-by: ShabbyGayBar <115197764+ShabbyGayBar@users.noreply.github.com> --- README.md | 22 +++ docs/README_zh-CN.md | 24 ++- src/vic3_state_merger/cli.py | 61 ++++++- src/vic3_state_merger/state_merger.py | 14 +- src/vic3_state_merger/state_regions.py | 51 +++++- tests/test_hub_overrides.py | 234 +++++++++++++++++++++++++ 6 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 tests/test_hub_overrides.py diff --git a/README.md b/README.md index 3cc3786..9fdaa1c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ It generates the following files that can be used in a Victoria 3 mod: The `merge_state.json` file contains the rules for merging states. You can download this file from the [release page](https://github.com/ShabbyGayBar/StateMerger/releases) which contains a list of vanilla game states, and customize your own state merging rules. +#### Basic format (list) + The keys in the `merge_state.json` file are strings representing a state id. The values are lists of strings representing state id. All states in the value list will be merged into the state in the key. @@ -44,6 +46,26 @@ For example, the following rule merges `state_1`, `state_2`, and `state_3` into } ``` +#### Extended format (dict) – specifying hub inheritance + +By default the merged state inherits each hub (city, port, mine, farm, wood) from whichever source state provides it first. You can override this behaviour for individual hub types by using a dict value instead of a list: + +```json +{ + "state_0": { + "merge": ["state_1", "state_2", "state_3"], + "city": "state_1", + "port": "state_2", + "mine": "state_3" + } +} +``` + +* The `"merge"` key lists the states to be merged (same as the basic list value). +* Each optional hub key (`"city"`, `"port"`, `"farm"`, `"mine"`, `"wood"`) names the **source state** whose original hub province will be used for the merged state. +* Hub types not listed fall back to the default merge behaviour. +* Both formats can be mixed freely in the same file – entries that need no hub customisation can still use the simple list form. + ### Step 2: Run the Script #### If You Downloaded the EXE file (Windows Only) diff --git a/docs/README_zh-CN.md b/docs/README_zh-CN.md index e165f45..4c31818 100644 --- a/docs/README_zh-CN.md +++ b/docs/README_zh-CN.md @@ -31,6 +31,8 @@ 省份合并规则保存在 `merge_state.json` 文件中。您可以编辑此文件以自定义省份合并规则。 +#### 基本格式(列表) + `merge_state.json` 文件结构类似下面这样: ```json { @@ -40,7 +42,27 @@ 冒号左边的是省份 ID 的字符串,右边的是表示省份 ID 的字符串列表。列表中的所有省份将被合并到冒号左边的省份中。 -例如,上面的 JSON 文件会将 `state_1`、`state_2` 和 `state_3` 合并到 `state_0` 中: +例如,上面的 JSON 文件会将 `state_1`、`state_2` 和 `state_3` 合并到 `state_0` 中。 + +#### 扩展格式(对象) ——指定城市模型继承 + +默认情况下,合并后的省份会从提供该 hub 的第一个来源省份继承各个 hub(城市、港口、矿场、农田、林场)。你可以通过将值写成对象(而非列表)来覆盖特定 hub 类型的继承来源: + +```json +{ + "state_0": { + "merge": ["state_1", "state_2", "state_3"], + "city": "state_1", + "port": "state_2", + "mine": "state_3" + } +} +``` + +* `"merge"` 键的值是要被合并的省份列表(与基本格式的列表值相同)。 +* 可选的 hub 键(`"city"`、`"port"`、`"farm"`、`"mine"`、`"wood"`)指定**来源省份**,合并后的省份将使用该省份原始 hub 所在的地块。 +* 未列出的 hub 类型仍使用默认合并逻辑。 +* 两种格式可以在同一文件中自由混用——不需要自定义 hub 的条目仍可使用简单列表形式。 ### 2. 运行脚本 diff --git a/src/vic3_state_merger/cli.py b/src/vic3_state_merger/cli.py index 7980ab3..b0d3568 100644 --- a/src/vic3_state_merger/cli.py +++ b/src/vic3_state_merger/cli.py @@ -77,6 +77,63 @@ def print_version() -> None: print(f"state-merger {__version__}") +_HUB_TYPES = {"city", "port", "farm", "mine", "wood"} + + +def load_merge_config(path: str) -> tuple[dict, dict]: + """Load a merge configuration file and return ``(merge_dict, hub_overrides)``. + + The file must be a JSON file whose keys are state IDs. Each value may be: + + * A **list** of state IDs to merge into the key state (original format, + fully backward-compatible):: + + { + "STATE_A": ["STATE_B", "STATE_C"] + } + + * A **dict** with a ``"merge"`` key (list of states to merge) and optional + hub-type keys (``"city"``, ``"port"``, ``"farm"``, ``"mine"``, ``"wood"``) + whose values are the state ID whose original hub province should be used for + the merged state:: + + { + "STATE_A": { + "merge": ["STATE_B", "STATE_C"], + "city": "STATE_B", + "port": "STATE_C" + } + } + + Hub types not listed use the default merge behavior (diner's hub is kept + unless it is empty, in which case the food state's hub is used). + + Returns: + merge_dict: {diner: [food_list]} + hub_overrides: {diner: {hub_type: source_state}} + """ + with open(path, "r", encoding="utf-8") as f: + raw = json.load(f) + + merge_dict: dict = {} + hub_overrides: dict = {} + + for state, value in raw.items(): + if isinstance(value, list): + merge_dict[state] = value + elif isinstance(value, dict): + merge_dict[state] = value.get("merge", []) + overrides = {k: v for k, v in value.items() if k in _HUB_TYPES} + if overrides: + hub_overrides[state] = overrides + else: + raise ValueError( + f"Invalid merge config value for state '{state}': expected list or dict, got {type(value).__name__}" + ) + + return merge_dict, hub_overrides + + def run_merge( merge_file: str, mod_dir: str, @@ -85,8 +142,7 @@ def run_merge( small_state_limit: int, ignore_small_states: bool, ) -> None: - with open(merge_file, "r", encoding="utf-8") as file: - merge_dict = json.load(file) + merge_dict, hub_overrides = load_merge_config(merge_file) resolved_data_dir = data_dir or _default_data_dir(mod_dir) @@ -95,6 +151,7 @@ def run_merge( _ensure_trailing_sep(mod_dir), merge_dict, _ensure_trailing_sep(resolved_data_dir), + hub_overrides=hub_overrides, ) state_merger.merge_state_data(ignoreSmallStates=ignore_small_states, smallStateLimit=small_state_limit) state_merger.merge_misc_data() diff --git a/src/vic3_state_merger/state_merger.py b/src/vic3_state_merger/state_merger.py index 803e6d6..f122e97 100644 --- a/src/vic3_state_merger/state_merger.py +++ b/src/vic3_state_merger/state_merger.py @@ -109,12 +109,13 @@ def clean_v3_yml_numbered_keys(yml_path:str) -> str: class StateMerger: - def __init__(self, game_root_dir:str, write_dir:str, merge_dict:dict, cache_dir:str="./data"): + def __init__(self, game_root_dir:str, write_dir:str, merge_dict:dict, cache_dir:str="./data", hub_overrides:dict|None=None): self.base_game_dir = {} self.mod_dir = {} self.game_root_dir = game_root_dir self.write_dir = write_dir self.merge_dict = merge_dict + self.hub_overrides = hub_overrides or {} self.cache_dir = cache_dir # Set the base game and mod directories @@ -166,6 +167,7 @@ def merge_state_data(self, ignoreSmallStates:bool=False, smallStateLimit:int=4): # Merge map_data self.map_data.merge_states( self.merge_dict, + hub_overrides=self.hub_overrides or None, ignoreSmallStates=ignoreSmallStates, smallStateLimit=smallStateLimit, ) @@ -347,8 +349,14 @@ def merge_loc_data(self): continue # If not found, add a missing hub name entry print(f"Missing HUB_NAME_{diner}_{attr} in {lang}") - # Search for attribute in the food_list - for food in food_list: + # Build search order: prefer the hub override source state (if any), + # then fall back to the food_list order. + override_source = self.hub_overrides.get(diner, {}).get(attr) + search_order = [] + if override_source and override_source != diner: + search_order.append(override_source) + search_order.extend(food_list) + for food in search_order: if f"HUB_NAME_{food}_{attr}" in data.keys(): miss_dict[f"HUB_NAME_{diner}_{attr}"] = ( '"' + data[f"HUB_NAME_{food}_{attr}"] + '"' diff --git a/src/vic3_state_merger/state_regions.py b/src/vic3_state_merger/state_regions.py index 3711769..c67088e 100644 --- a/src/vic3_state_merger/state_regions.py +++ b/src/vic3_state_merger/state_regions.py @@ -426,7 +426,38 @@ def merge_state( ) self.pop(food) - def merge_states(self, merge_dict:dict, ignoreSmallStates:bool=False, smallStateLimit:int=4): + def merge_states( + self, + merge_dict: dict, + hub_overrides: dict | None = None, + ignoreSmallStates: bool = False, + smallStateLimit: int = 4, + ): + """Merge states according to merge_dict and apply optional hub overrides. + + hub_overrides: {diner_state: {"city": source_state, "port": source_state, ...}} + Each hub type value is the name of the state whose original hub province ID + should be used for the merged state. States not listed use the default merge + behavior (keep diner's hub unless empty, then take food's hub). + """ + _HUB_TYPES = ("city", "port", "farm", "mine", "wood") + + # Snapshot hub province IDs for every state referenced in hub_overrides + # before any merging begins, because food states are removed after merge. + saved_hubs: dict[str, dict[str, str]] = {} + if hub_overrides: + referenced = { + src + for overrides in hub_overrides.values() + for src in overrides.values() + } + for state_name in referenced: + if state_name in self: + saved_hubs[state_name] = { + hub: getattr(self[state_name], hub, "") + for hub in _HUB_TYPES + } + for diner, food_list in merge_dict.items(): for food in food_list: self.merge_state( @@ -436,6 +467,24 @@ def merge_states(self, merge_dict:dict, ignoreSmallStates:bool=False, smallState smallStateLimit=smallStateLimit, ) + # Apply hub overrides after all food states have been merged into diner + if hub_overrides and diner in hub_overrides and diner in self: + for hub_type, source_state in hub_overrides[diner].items(): + if hub_type not in _HUB_TYPES: + print(f"Warning: Unknown hub type '{hub_type}' in hub_overrides for '{diner}', skipping") + continue + if source_state in saved_hubs: + hub_value = saved_hubs[source_state].get(hub_type, "") + elif source_state in self: + hub_value = getattr(self[source_state], hub_type, "") + else: + print( + f"Warning: Hub override source state '{source_state}' not found" + f" for '{diner}.{hub_type}', skipping" + ) + continue + setattr(self[diner], hub_type, hub_value) + def __str__(self, include_sea_nodes:bool=False): state_str = "" for state_region_item in self.values(): diff --git a/tests/test_hub_overrides.py b/tests/test_hub_overrides.py new file mode 100644 index 0000000..0fb919d --- /dev/null +++ b/tests/test_hub_overrides.py @@ -0,0 +1,234 @@ +"""Tests for the hub inheritance / hub_overrides feature.""" + +import json +import os +import tempfile + +import pytest + +from vic3_state_merger.cli import load_merge_config +from vic3_state_merger.state_regions import StateRegion, StateRegionItem + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_state_dict(name: str, province: str, city: str = "", port: str = "", farm: str = "", + mine: str = "", wood: str = "", state_id: int = 1) -> dict: + """Return a minimal state-region dict suitable for constructing a StateRegionItem.""" + return { + name: { + "id": state_id, + "subsistence_building": "building_subsistence_farms", + "provinces": [province], + "arable_land": 10, + "arable_resources": ["bg_wheat_farms"], + **({"city": city} if city else {}), + **({"port": port} if port else {}), + **({"farm": farm} if farm else {}), + **({"mine": mine} if mine else {}), + **({"wood": wood} if wood else {}), + } + } + + +def _make_region(states: list[tuple]) -> StateRegion: + """Build a StateRegion from a list of (name, id, province, city, port, farm, mine, wood) tuples.""" + region = StateRegion() + for idx, (name, prov, city, port, farm, mine, wood) in enumerate(states, start=1): + d = _make_state_dict(name, prov, city=city, port=port, farm=farm, mine=mine, wood=wood, state_id=idx) + region[name] = StateRegionItem(name, d) + return region + + +# --------------------------------------------------------------------------- +# Tests for load_merge_config +# --------------------------------------------------------------------------- + +class TestLoadMergeConfig: + def test_old_list_format(self, tmp_path): + """Old-style list values are parsed correctly and produce no hub_overrides.""" + data = {"STATE_A": ["STATE_B", "STATE_C"], "STATE_D": []} + p = tmp_path / "merge.json" + p.write_text(json.dumps(data), encoding="utf-8") + + merge_dict, hub_overrides = load_merge_config(str(p)) + + assert merge_dict == {"STATE_A": ["STATE_B", "STATE_C"], "STATE_D": []} + assert hub_overrides == {} + + def test_new_dict_format(self, tmp_path): + """New-style dict values are parsed into merge_dict and hub_overrides.""" + data = { + "STATE_A": { + "merge": ["STATE_B", "STATE_C"], + "city": "STATE_B", + "port": "STATE_C", + } + } + p = tmp_path / "merge.json" + p.write_text(json.dumps(data), encoding="utf-8") + + merge_dict, hub_overrides = load_merge_config(str(p)) + + assert merge_dict == {"STATE_A": ["STATE_B", "STATE_C"]} + assert hub_overrides == {"STATE_A": {"city": "STATE_B", "port": "STATE_C"}} + + def test_mixed_format(self, tmp_path): + """Old and new style values can coexist in the same file.""" + data = { + "STATE_A": { + "merge": ["STATE_B"], + "mine": "STATE_B", + }, + "STATE_D": ["STATE_E"], + "STATE_F": [], + } + p = tmp_path / "merge.json" + p.write_text(json.dumps(data), encoding="utf-8") + + merge_dict, hub_overrides = load_merge_config(str(p)) + + assert merge_dict == {"STATE_A": ["STATE_B"], "STATE_D": ["STATE_E"], "STATE_F": []} + assert hub_overrides == {"STATE_A": {"mine": "STATE_B"}} + + def test_dict_without_hub_overrides(self, tmp_path): + """A dict value with only a 'merge' key produces no hub_overrides entry.""" + data = {"STATE_A": {"merge": ["STATE_B"]}} + p = tmp_path / "merge.json" + p.write_text(json.dumps(data), encoding="utf-8") + + merge_dict, hub_overrides = load_merge_config(str(p)) + + assert merge_dict == {"STATE_A": ["STATE_B"]} + assert hub_overrides == {} + + def test_invalid_value_raises(self, tmp_path): + """A non-list, non-dict value raises ValueError.""" + data = {"STATE_A": "not_a_list_or_dict"} + p = tmp_path / "merge.json" + p.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ValueError, match="STATE_A"): + load_merge_config(str(p)) + + def test_non_hub_keys_ignored(self, tmp_path): + """Unknown keys in the dict format are not included in hub_overrides.""" + data = {"STATE_A": {"merge": ["STATE_B"], "city": "STATE_B", "unknown_key": "STATE_B"}} + p = tmp_path / "merge.json" + p.write_text(json.dumps(data), encoding="utf-8") + + _, hub_overrides = load_merge_config(str(p)) + + assert "unknown_key" not in hub_overrides.get("STATE_A", {}) + assert hub_overrides == {"STATE_A": {"city": "STATE_B"}} + + +# --------------------------------------------------------------------------- +# Tests for StateRegion.merge_states with hub_overrides +# --------------------------------------------------------------------------- + +class TestMergeStatesHubOverrides: + def _build_region(self): + """Build a region with three states: A (diner), B and C (food). + + Hub layout: + STATE_A: city=x1, port=(none), farm=x1, mine=(none), wood=(none) + STATE_B: city=x2, port=x2, farm=(none), mine=x2, wood=(none) + STATE_C: city=x3, port=x3, farm=x3, mine=(none), wood=x3 + """ + return _make_region([ + # name, province, city, port, farm, mine, wood + ("STATE_A", "x000001", "x000001", "", "x000001", "", ""), + ("STATE_B", "x000002", "x000002", "x000002", "", "x000002", ""), + ("STATE_C", "x000003", "x000003", "x000003", "x000003", "", "x000003"), + ]) + + def test_default_merge_no_overrides(self): + """Without hub_overrides, default merge logic is used.""" + region = self._build_region() + merge_dict = {"STATE_A": ["STATE_B", "STATE_C"]} + region.merge_states(merge_dict) + + # port: A had no port, B has port → A.port == B's original port + assert region["STATE_A"].port == "x000002" + # mine: A had no mine, B has mine + assert region["STATE_A"].mine == "x000002" + # wood: A had no wood, C has wood + assert region["STATE_A"].wood == "x000003" + # city: A keeps its own city (city is never propagated) + assert region["STATE_A"].city == "x000001" + + def test_hub_override_city(self): + """hub_overrides for city changes the merged state's city hub.""" + region = self._build_region() + merge_dict = {"STATE_A": ["STATE_B", "STATE_C"]} + hub_overrides = {"STATE_A": {"city": "STATE_B"}} + + region.merge_states(merge_dict, hub_overrides=hub_overrides) + + assert region["STATE_A"].city == "x000002" # overridden to STATE_B's city + + def test_hub_override_port(self): + """hub_overrides for port overrides the default merge result.""" + region = self._build_region() + # STATE_A originally has no port; default merge would give it STATE_B's port. + # Override to use STATE_C's port instead. + merge_dict = {"STATE_A": ["STATE_B", "STATE_C"]} + hub_overrides = {"STATE_A": {"port": "STATE_C"}} + + region.merge_states(merge_dict, hub_overrides=hub_overrides) + + assert region["STATE_A"].port == "x000003" # STATE_C's port + + def test_hub_override_keeps_diner_hub(self): + """hub_overrides referencing the diner state restores its original hub.""" + region = self._build_region() + # STATE_A has city "x000001". Override specifies STATE_A as source → keep it. + merge_dict = {"STATE_A": ["STATE_B", "STATE_C"]} + hub_overrides = {"STATE_A": {"city": "STATE_A"}} + + region.merge_states(merge_dict, hub_overrides=hub_overrides) + + assert region["STATE_A"].city == "x000001" + + def test_hub_override_multiple_hubs(self): + """Multiple hub types can be overridden simultaneously.""" + region = self._build_region() + merge_dict = {"STATE_A": ["STATE_B", "STATE_C"]} + hub_overrides = { + "STATE_A": { + "city": "STATE_C", # x000003 + "mine": "STATE_B", # x000002 + "wood": "STATE_C", # x000003 + } + } + + region.merge_states(merge_dict, hub_overrides=hub_overrides) + + assert region["STATE_A"].city == "x000003" + assert region["STATE_A"].mine == "x000002" + assert region["STATE_A"].wood == "x000003" + + def test_hub_override_unknown_source_warns(self, capsys): + """An override referencing a non-existent state prints a warning and is skipped.""" + region = self._build_region() + merge_dict = {"STATE_A": ["STATE_B"]} + hub_overrides = {"STATE_A": {"city": "STATE_NONEXISTENT"}} + + region.merge_states(merge_dict, hub_overrides=hub_overrides) + + captured = capsys.readouterr() + assert "Warning" in captured.out + assert "STATE_NONEXISTENT" in captured.out + + def test_no_hub_overrides_backward_compat(self): + """Calling merge_states without hub_overrides parameter works as before.""" + region = self._build_region() + merge_dict = {"STATE_A": ["STATE_B", "STATE_C"]} + + region.merge_states(merge_dict) # no hub_overrides kwarg + + # Default behavior: A's port comes from B (first state with a port) + assert region["STATE_A"].port == "x000002"