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"