diff --git a/docs/EVIDENCE_LEDGER.md b/docs/EVIDENCE_LEDGER.md index 8c52cfa3..fc4ab7a4 100644 --- a/docs/EVIDENCE_LEDGER.md +++ b/docs/EVIDENCE_LEDGER.md @@ -12,6 +12,7 @@ | 2026-09-13 | M60 可玩性真相(W1) | `e1cad068`/`dac5de40` | `m60-playthrough` `20260913T145904Z-38281` | runtime_verified | 34 步 0 阻断;视频与逐步截图 | | 2026-09-13 | M61 设计/密度(W2) | `310d2620` | `m61-five-profile` `20260913T162625Z-2172` | simulation_verified | decisions=330, max_gap=31.3s, 反支配/存档/可达通过 | | 2026-09-13 | M62 组件统一(W3) | `2e5540ed` | `m62-ink-baselines` `20260913T164335Z-38711` | runtime_verified | 组件库+矩阵+探针接入;基线 Guild 0 / Contract 1 / Readiness+Result 6 / Market 严格 0 | +| 2026-09-14 | M79-B 合同推进/区域前进(第三次合同进区域 2) | `feat(m79-b)` | runtime_verified | 序数取合同行 + 区域带 + Boss 锚定 + 区域战场/音乐;`ABG_M79_REGION_ADVANCE_OK` | | 2026-09-14 | M79-A 区域 2 内容落地(数据+本地化+区域带+立绘+音乐) | `feat(m79-a)` `b3fa611c`/`9aabf9c0` | `m79-region2` `20260914T220343Z-47202` | runtime_verified | 目录 80 装备/15 遗物/24 敌人/4 Boss/2 区域/4 合同 + 201 行中文名与标签;市场与战斗按区域带抽取 | | 2026-09-14 | M78 战斗演员立绘(替换头像贴纸,Owner 指出项) | `feat(m78)` | runtime_verified | 52 张(12 职业 + 12 敌人 + 2 Boss × 待机/出手);姿态随节拍切换 | | 2026-09-14 | M77-D 战斗字幕/无障碍/双档墨迹门 | `feat(m77-d)` `1fe13e7b` | `m77-battle-polish` `20260914T174012Z-45680` | runtime_verified | captions+detail 生效;color_cues 单墨色模式;战斗屏 1.0/1.3 双档 0 违规;立绘高度带 | diff --git a/docs/plans/M79_REGION_CONTENT.md b/docs/plans/M79_REGION_CONTENT.md index 69df88c3..29339024 100644 --- a/docs/plans/M79_REGION_CONTENT.md +++ b/docs/plans/M79_REGION_CONTENT.md @@ -17,10 +17,13 @@ ## Ordered steps -1. **M79-A(数据+本地化 COMPLETE;立绘/音乐/可达性 PENDING)区域 2**:EQ-041..080、EN-R2-01..12、BO-R2-01/02、AR-009..015、 +1. **M79-A(COMPLETE)区域 2**:EQ-041..080、EN-R2-01..12、BO-R2-01/02、AR-009..015、 CTR-R2-01/02 的中文名 + 语义标签;注册表计数更新(80/24/4/15/2/4);区域带接线(战斗/装备按区域抽); 区域 2 敌人立绘(12×2)+ Boss(2×2);区域 2 战斗/BOSS 音乐;全流程门。 -2. **M79-B..G 区域 3..8**:同样管线逐区域推进(每区域一片,含立绘/音乐/门)。 +1b. **M79-B(COMPLETE)合同推进/区域前进**:合同按序数从目录取行(2/区域),区域带随行落地; + 已归还的合同作为历史保留(含其全部回执),修订号 = 已建合同数 + 已提交动作数;第三次合同进区域 2 ✓; + 区域 2 战场底图与区域音乐族切换。 +2. **M79-C..G 区域 3..8**:同样管线逐区域推进(每区域一片,含立绘/音乐/门)。 3. **M79-H 收口**:全量计数门(320/100/16/60/8/16)、区域推进(合同按区域前进)、 区域图鉴/解锁与区域差异实拍。 @@ -54,7 +57,10 @@ | --- | --- | --- | | `M79-I01` | OPEN | 合同行(16 条)的 round_count/first_clear_renown 为派生值,待复核 | | `M79-I02` | OPEN | 填充词检查对 `copy of` 有误报(EQ-248、BO-R3-02 是机制描述),需登记例外或改写措辞 | -| `M79-I03` | OPEN(需 Owner 拍板范围)| 区域 2+ 内容目前在**运行时不可达**:合同循环只到首份合同(`contract_ordinal != 0` 即拒绝), +| `M79-I03` | RESOLVED(Owner:'超出 MVP 没事,可以支持你做')| 区域 2+ 内容目前在**运行时不可达**:合同循环只到首份合同(`contract_ordinal != 0` 即拒绝), 第二次合同/区域推进属"第二周目"范围(`M31-I03` 曾判定超出首个 MVP 停点)。按区域补内容要真正可玩,需要一步 "合同推进/区域前进"(含区域 Boss Muster、区域音乐与立绘切换)。建议作为 M79 的下一步。 | -| `M79-I04` | OPEN | 区域 2 敌人/Boss 立绘(12×2 + 2×2 = 28 张)与区域战斗/BOSS 音乐(各 1 套)尚未生成 | +| `M79-I04` | RESOLVED | 区域 2 立绘 28 张 + 战斗/Boss 音乐各 1 套已生成并接线 | +| `M79-I05` | OPEN | 区域推进已有服务层门(`region_advance_test`),UI 全流程(合同→…→归还→下一合同)尚未驱动验证; + 收口阶段补一次"两周目"全流程驱动 | +| `M79-I06` | OPEN | 16 条合同的 `round_count`/`first_clear_renown` 仍为派生值(待复核);超出已实现区域后合同会重复最后一份(后游戏范围) | diff --git a/docs/prototype/generation/m79_region2_actors/outputs/siltwake-fens-battlefield.png b/docs/prototype/generation/m79_region2_actors/outputs/siltwake-fens-battlefield.png new file mode 100644 index 00000000..1462b350 Binary files /dev/null and b/docs/prototype/generation/m79_region2_actors/outputs/siltwake-fens-battlefield.png differ diff --git a/runtime/assets/battle/siltwake_fens_battlefield.png b/runtime/assets/battle/siltwake_fens_battlefield.png new file mode 100644 index 00000000..1462b350 Binary files /dev/null and b/runtime/assets/battle/siltwake_fens_battlefield.png differ diff --git a/runtime/entry/contract_choice_page_controller.gd b/runtime/entry/contract_choice_page_controller.gd index cd0af239..002eea0f 100644 --- a/runtime/entry/contract_choice_page_controller.gd +++ b/runtime/entry/contract_choice_page_controller.gd @@ -2,6 +2,7 @@ class_name ABGContractChoicePageController extends RefCounted const GuildContractService = preload("res://entry/guild_contract_service.gd") +const ContentRegistry = preload("res://p10_0/content_registry.gd") ## UI-independent Contract Choice view-data assembly. ## Contract creation remains owned by GuildContractService; this controller only @@ -25,11 +26,12 @@ func snapshot(company: Dictionary, selected_difficulty := "standard", locale := "owned_count": recruits.size(), "recruits": recruits, } + var contract_row: Dictionary = _next_contract_row(locale, company) var creation_facts := { - "contract_id": GuildContractService.FIRST_CONTRACT_ID, - "route_id": "windglass_verge", - "route_title": _tr("风镜初渡", "FIRST WINDGLASS CROSSING", locale), - "route_copy": _tr("穿越悬浮风镜,建立公会的第一条安全通路。", "Cross the suspended windglass and establish the Guild's first safe route.", locale), + "contract_id": String(contract_row.get("id", GuildContractService.FIRST_CONTRACT_ID)), + "route_id": String(contract_row.get("region_id", "RG-01")).to_lower(), + "route_title": String(contract_row.get("route_title", _tr("风镜初渡", "FIRST WINDGLASS CROSSING", locale))), + "route_copy": String(contract_row.get("route_copy", _tr("穿越悬浮风镜,建立公会的第一条安全通路。", "Cross the suspended windglass and establish the Guild's first safe route.", locale))), "starting_coin": GuildContractService.STARTING_COIN, "line_capacity": GuildContractService.STARTING_LINE_CAPACITY, "market_counts": {"recruit": 3, "equipment": 4, "battle": 3}, @@ -109,3 +111,26 @@ func _copy_array(value) -> Array: func _tr(zh: String, en: String, locale: String) -> String: return en if String(locale).to_lower().begins_with("en") else zh + +func _next_contract_row(locale: String, company: Dictionary) -> Dictionary: + # The next contract follows the company's ordinal; region titles come from + # the catalogue and its zh authority. + var content: Dictionary = ContentRegistry.snapshot() + var rows: Array = Array(content.get("contracts", [])) + rows.sort_custom(func(a, b): return String(a.get("id", "")) < String(b.get("id", ""))) + if rows.is_empty(): + return {} + var ordinal := 0 + if company is Dictionary: + ordinal = int(company.get("next_contract_ordinal", 0)) + var row: Dictionary = Dictionary(rows[clampi(ordinal, 0, rows.size() - 1)]).duplicate(true) + var region_id := String(row.get("region_id", "RG-01")) + row["route_title"] = ContentRegistry.localized_name(content, region_id, locale) + if String(row["route_title"]).is_empty(): + row["route_title"] = region_id + var region_rows: Array = Array(content.get("region", [])) + for region_row in region_rows: + if String(Dictionary(region_row).get("id", "")) == region_id: + row["route_copy"] = String(Dictionary(region_row).get("framing", "")) + break + return row diff --git a/runtime/entry/entry_shell.gd b/runtime/entry/entry_shell.gd index 9dc7c332..d822d518 100644 --- a/runtime/entry/entry_shell.gd +++ b/runtime/entry/entry_shell.gd @@ -92,6 +92,10 @@ const GUILD_DESK_BOTTOM_PARTY = preload("res://assets/entry/guild_desk_v001/bott const BATTLE_OPENING_BACKGROUND = preload("res://assets/backgrounds/first_expedition_field.png") const BATTLE_OPENING_ENEMY = preload("res://assets/enemies/riftblade/idle.png") const BATTLE_FIELD = preload("res://assets/battle/windglass_verge_battlefield.png") +const BATTLE_FIELDS := { + 1: preload("res://assets/battle/windglass_verge_battlefield.png"), + 2: preload("res://assets/battle/siltwake_fens_battlefield.png"), +} const BATTLE_ENEMY_ART := { "EN-R1-01": preload("res://assets/battle/enemies/enemy-en-r1-01-pane-nibbler.png"), "EN-R1-02": preload("res://assets/battle/enemies/enemy-en-r1-02-gust-finch.png"), @@ -3257,7 +3261,7 @@ func _build_battle_opening_canvas() -> void: child.free() var background := TextureRect.new() background.name = "BattleOpeningWorld" - background.texture = BATTLE_FIELD + background.texture = _battle_field_texture() background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS @@ -3666,6 +3670,12 @@ func _battle_profession_parent(profession_id: String) -> String: return found.get_string() if found != null else "" +func _battle_field_texture() -> Texture2D: + var contract: Dictionary = Dictionary(guild_company.get("active_contract", {})) + var region_index := int(contract.get("region_index", 1)) + return BATTLE_FIELDS.get(region_index, BATTLE_FIELD) + + func _battle_music_family(company: Dictionary) -> String: # Region 1 keeps the original family names; later regions suffix the id so # each region's battle and boss beds stay addressable. @@ -5043,7 +5053,7 @@ func _show_legacy_battle(company := {}, auto_play := true) -> void: battle_operation_id = String(battle.operation_id) guild_company = resolved current_screen = "battle" - _build_world_base(false, BATTLE_FIELD) + _build_world_base(false, _battle_field_texture()) var frame_safe := _framed_page_safe("Battle") frame_safe.add_child(_entry_chrome("BattleFrame", "initial_company_scene_frame")) var safe := _content_page_safe("Battle") diff --git a/runtime/entry/guild_contract_service.gd b/runtime/entry/guild_contract_service.gd index 373db7fa..37cd8dd3 100644 --- a/runtime/entry/guild_contract_service.gd +++ b/runtime/entry/guild_contract_service.gd @@ -29,7 +29,9 @@ func load_company() -> Dictionary: func create_contract(operation_id: String, contract_id: String, difficulty: String) -> Dictionary: if not _is_hex(operation_id, 32): return {"ok": false, "code": "CONTRACT_OPERATION_ID_INVALID"} - if contract_id != FIRST_CONTRACT_ID: + var contract_rows: Array = _contract_rows() + var expected_row := _contract_row_for_ordinal(contract_rows, int(company_ordinal_probe(contract_id))) + if contract_id != String(expected_row.get("id", FIRST_CONTRACT_ID)): return {"ok": false, "code": "CONTRACT_LOCKED"} if difficulty not in DIFFICULTIES: return {"ok": false, "code": "CONTRACT_DIFFICULTY_INVALID"} @@ -38,6 +40,17 @@ func create_contract(operation_id: String, contract_id: String, difficulty: Stri if not loaded.ok: return loaded var company: Dictionary = loaded.company.duplicate(true) + if not company.active_contract.is_empty() and String(company.active_contract.get("status", "")) == "returned": + # A returned contract is history: keep the whole closed contract (its + # hire/equipment/return receipts included) so the company accounting and + # every prior receipt stay verifiable. + var history: Array = Array(company.get("contract_receipts", [])).duplicate(true) + if not history.is_empty(): + history[history.size() - 1] = company.active_contract.duplicate(true) + else: + history.append(company.active_contract.duplicate(true)) + company.contract_receipts = history + company.active_contract = {} if not company.active_contract.is_empty(): var existing_receipt: Dictionary = company.active_contract.creation_receipt var same_intent := String(existing_receipt.contract_id) == contract_id and String(existing_receipt.difficulty) == difficulty @@ -53,6 +66,10 @@ func create_contract(operation_id: String, contract_id: String, difficulty: Stri } return {"ok": false, "code": "CONTRACT_ALREADY_ACTIVE"} var contract_ordinal := int(company.next_contract_ordinal) + var ordinal_row: Dictionary = _contract_row_for_ordinal(_contract_rows(), contract_ordinal) + if contract_id != String(ordinal_row.get("id", FIRST_CONTRACT_ID)): + return {"ok": false, "code": "CONTRACT_ORDINAL_INVALID"} + var region_index := _region_index_for_contract(ordinal_row) var intent := { "company_id": String(company.company_id), "contract_ordinal": contract_ordinal, @@ -60,11 +77,10 @@ func create_contract(operation_id: String, contract_id: String, difficulty: Stri "difficulty": difficulty, } var intent_hash := _hash(intent) - if contract_ordinal != 0: - return {"ok": false, "code": "CONTRACT_ORDINAL_INVALID"} var run_seed := _low128(_digest(String(company.company_id), "dev3-contract-run", [contract_ordinal])) var run_id := _low128(_digest(run_seed, "dev3-contract-id", [contract_id])) - var generated: Dictionary = _generator.market_board(run_seed, contract_id, 1, 0) + var region_boss := String(ordinal_row.get("boss_id", "")) + var generated: Dictionary = _generator.market_board(run_seed, contract_id, 1, 0, 0, region_index, region_boss) if not generated.ok: return {"ok": false, "code": "CONTRACT_MARKET_GENERATION_FAILED"} var board := _price_first_board(generated.board) @@ -86,6 +102,10 @@ func create_contract(operation_id: String, contract_id: String, difficulty: Stri "contract_version": 1, "company_id": String(company.company_id), "contract_id": contract_id, + "region_id": String(ordinal_row.get("region_id", "RG-01")), + "region_index": region_index, + "boss_id": region_boss, + "contract_ordinal": contract_ordinal, "run_id": run_id, "run_seed": run_seed, "difficulty": difficulty, @@ -104,7 +124,8 @@ func create_contract(operation_id: String, contract_id: String, difficulty: Stri company.company_revision = int(company.company_revision) + 1 company.next_contract_ordinal = contract_ordinal + 1 company.active_contract = contract - company.contract_receipts = [receipt] + company.contract_receipts = Array(company.get("contract_receipts", [])).duplicate(true) + company.contract_receipts.append(receipt) company.erase("company_hash") company.company_hash = _hash(company) var saved: Dictionary = store.save_company(company) @@ -520,6 +541,34 @@ func _price_first_board(raw: Dictionary) -> Dictionary: return board +func _contract_rows() -> Array: + var content: Dictionary = _content if not _content.is_empty() else ContentRegistry.snapshot() + var rows: Array = Array(content.get("contracts", [])) + rows.sort_custom(func(a, b): return String(a.get("id", "")) < String(b.get("id", ""))) + return rows + + +func _contract_row_for_ordinal(rows: Array, ordinal: int) -> Dictionary: + if rows.is_empty(): + return {} + var index := clampi(ordinal, 0, rows.size() - 1) + return Dictionary(rows[index]) + + +func _region_index_for_contract(row: Dictionary) -> int: + var region_id := String(row.get("region_id", "RG-01")) + return int(region_id.split("-")[1]) if region_id.begins_with("RG-") else 1 + + +func company_ordinal_probe(contract_id: String) -> int: + # Contract ids are `CTR-R-`; the ordinal follows row order. + var rows: Array = _contract_rows() + for index in range(rows.size()): + if String(Dictionary(rows[index]).get("id", "")) == contract_id: + return index + return 0 + + func _offer_ids(offers: Array, id_field: String) -> Array[String]: var result: Array[String] = [] for offer in offers: diff --git a/runtime/entry/initial_company_draft_service.gd b/runtime/entry/initial_company_draft_service.gd index 8cf9cad6..56332121 100644 --- a/runtime/entry/initial_company_draft_service.gd +++ b/runtime/entry/initial_company_draft_service.gd @@ -2,6 +2,7 @@ class_name ABGInitialCompanyDraftService extends RefCounted const ContentRegistry = preload("res://p10_0/content_registry.gd") +const MAX_CONTRACT_ORDINALS := ContentRegistry.CONTENT_STAGE_CONTRACTS const OfferGenerator = preload("res://p10_0/offer_generator.gd") const DRAFT_VERSION := 1 @@ -202,8 +203,11 @@ func validate_company(value) -> Dictionary: return receipt_validation if not company.active_contract is Dictionary or not company.contract_receipts is Array: return {"ok": false, "code": "COMPANY_CONTRACT_STATE_INVALID"} + var contract_ordinal := int(company.next_contract_ordinal) + if contract_ordinal < 0 or contract_ordinal > MAX_CONTRACT_ORDINALS: + return {"ok": false, "code": "COMPANY_CONTRACT_STATE_INVALID"} if company.active_contract.is_empty(): - if not company.contract_receipts.is_empty() or int(company.next_contract_ordinal) != 0: + if company.contract_receipts.size() != contract_ordinal: return {"ok": false, "code": "COMPANY_CONTRACT_STATE_INVALID"} else: if String(company.active_contract.get("company_id", "")) != String(company.company_id): @@ -215,9 +219,23 @@ func validate_company(value) -> Dictionary: var equipment_count: int = company.active_contract.get("equipment_receipts", []).size() var dismissal_count: int = company.active_contract.get("dismissal_receipts", []).size() var return_count: int = company.active_contract.get("contract_return_receipts", []).size() - if int(company.next_contract_ordinal) != 1 or int(company.company_revision) != 1 + hire_count + equipment_count + dismissal_count + return_count: + # Contract creation itself does not advance the revision; each committed + # company action (hire, equipment, dismissal, return) adds exactly one, in + # the active contract or in any closed contract kept in the history. + var committed := hire_count + equipment_count + dismissal_count + return_count + var history: Array = company.get("contract_receipts", []) + for index in range(maxi(0, history.size() - 1)): + var entry = history[index] + if entry is Dictionary and entry.has("hire_receipts"): + committed += Array(entry.hire_receipts).size() + committed += Array(entry.get("equipment_receipts", [])).size() + committed += Array(entry.get("dismissal_receipts", [])).size() + committed += Array(entry.get("contract_return_receipts", [])).size() + # Every created contract advances the revision once, and so does each + # committed company action anywhere in the contract history. + if int(company.company_revision) != contract_ordinal + committed: return {"ok": false, "code": "COMPANY_CONTRACT_REVISION_INVALID"} - if company.contract_receipts.size() != 1 or company.contract_receipts[0] != company.active_contract.creation_receipt: + if company.contract_receipts.size() != contract_ordinal or company.contract_receipts[company.contract_receipts.size() - 1] != company.active_contract.creation_receipt: return {"ok": false, "code": "COMPANY_CONTRACT_RECEIPT_INVALID"} if company.owned_recruits.size() != 2 + hire_count - dismissal_count: return {"ok": false, "code": "COMPANY_HIRE_ROSTER_INVALID"} @@ -544,7 +562,12 @@ func _validate_active_contract(value: Dictionary, company: Dictionary) -> Dictio return {"ok": false, "code": "CONTRACT_FIELD_MISSING", "field": field} if int(value.contract_version) != 1: return {"ok": false, "code": "CONTRACT_VERSION_UNSUPPORTED"} - if String(value.contract_id) != "CTR-R1-01" or not _is_hex(String(value.run_id), 32) or not _is_hex(String(value.run_seed), 32): + # M79-B: any contract row from the staged catalogue is a legal identity. + var contract_rows: Array = Array(ContentRegistry.snapshot().get("contracts", [])) + var legal_ids: Array[String] = [] + for row in contract_rows: + legal_ids.append(String(Dictionary(row).get("id", ""))) + if not legal_ids.has(String(value.contract_id)) or not _is_hex(String(value.run_id), 32) or not _is_hex(String(value.run_seed), 32): return {"ok": false, "code": "CONTRACT_IDENTITY_INVALID"} if String(value.difficulty) not in ["wayfinder", "standard", "stormbound", "iron_oath"]: return {"ok": false, "code": "CONTRACT_DIFFICULTY_INVALID"} @@ -865,7 +888,7 @@ func _validate_contract_creation_receipt(value, contract: Dictionary) -> Diction return {"ok": false, "code": "CONTRACT_RECEIPT_ID_INVALID"} if ( String(receipt.company_id) != String(contract.company_id) - or int(receipt.contract_ordinal) != 0 + or int(receipt.contract_ordinal) != int(contract.get("contract_ordinal", 0)) or String(receipt.contract_id) != String(contract.contract_id) or String(receipt.difficulty) != String(contract.difficulty) or String(receipt.run_id) != String(contract.run_id) @@ -877,7 +900,7 @@ func _validate_contract_creation_receipt(value, contract: Dictionary) -> Diction return {"ok": false, "code": "CONTRACT_RECEIPT_STATE_INVALID"} var expected_intent := _hash({ "company_id": String(contract.company_id), - "contract_ordinal": 0, + "contract_ordinal": int(contract.get("contract_ordinal", 0)), "contract_id": String(contract.contract_id), "difficulty": String(contract.difficulty), }) diff --git a/runtime/p10_0/content_registry.gd b/runtime/p10_0/content_registry.gd index 0ca50d9f..60f2471b 100644 --- a/runtime/p10_0/content_registry.gd +++ b/runtime/p10_0/content_registry.gd @@ -11,6 +11,8 @@ const SOURCE_REVISION := "phase8-p8-3r2-contact-rear-actions-replacement" ## 60 artifacts, 100 enemies, 16 bosses and 16 contracts. const CONTENT_STAGE_REGIONS := 2 const CONTENT_EXPECTED_REGIONS := 8 +## Two contracts per shipped region. +const CONTENT_STAGE_CONTRACTS := CONTENT_STAGE_REGIONS * 2 const FAMILY_COUNTS := { "professions": 36, "traits": 36, diff --git a/runtime/p10_0/offer_generator.gd b/runtime/p10_0/offer_generator.gd index 5187e176..5ba5377f 100644 --- a/runtime/p10_0/offer_generator.gd +++ b/runtime/p10_0/offer_generator.gd @@ -108,12 +108,15 @@ func opening_offers(run_creation_draft: Dictionary, presentation_revision: int = return {"ok": true, "code": "ok", "materialization": materialization, "offers": offers} -func market_board(run_seed: String, contract_id: String, market_index: int, refresh_index: int = 0, presentation_revision: int = 0, region_index: int = 1) -> Dictionary: +func market_board(run_seed: String, contract_id: String, market_index: int, refresh_index: int = 0, presentation_revision: int = 0, region_index: int = 1, region_boss: String = "") -> Dictionary: if not bool(_validation.get("ok", false)): return {"ok": false, "code": "invalid_catalog", "errors": _validation.get("errors", []).duplicate()} if not _is_seed(run_seed): return {"ok": false, "code": "invalid_run_seed"} - if contract_id not in ["CTR-R1-01", "CTR-R1-02"]: + var known_contracts: Array[String] = [] + for row in Array(ContentRegistry.snapshot().get("contracts", [])): + known_contracts.append(String(Dictionary(row).get("id", ""))) + if not known_contracts.has(contract_id): return {"ok": false, "code": "unknown_contract"} if market_index < 1 or market_index > 12: return {"ok": false, "code": "invalid_market_index"} @@ -146,7 +149,7 @@ func market_board(run_seed: String, contract_id: String, market_index: int, refr recruit_offers.append(record) var equipment_offers := _equipment_offers(run_seed, contract_id, market_index, refresh_index, region_index) - var battle_offers := _battle_offers(run_seed, contract_id, market_index, region_index) + var battle_offers := _battle_offers(run_seed, contract_id, market_index, region_index, region_boss) var board := { "board_id": _low128(_digest(run_seed, "market-board", [contract_id, market_index, refresh_index])), "run_seed": run_seed, @@ -257,7 +260,7 @@ func _equipment_offers(run_seed: String, contract_id: String, market_index: int, return offers -func _battle_offers(run_seed: String, contract_id: String, market_index: int, region_index: int) -> Array[Dictionary]: +func _battle_offers(run_seed: String, contract_id: String, market_index: int, region_index: int, region_boss: String = "") -> Array[Dictionary]: var enemy_rows := ContentRegistry.rows_by_id(_catalog, "enemies") var ordinary: Array[String] = [] var elite: Array[String] = [] @@ -276,7 +279,12 @@ func _battle_offers(run_seed: String, contract_id: String, market_index: int, re var enemy_count := 2 if risk == 0 else 3 var enemies := _draw_unique(ordinary, run_seed, "battle-enemy", enemy_count, [contract_id, market_index, risk]) if risk == 2: - enemies[-1] = elite[_index(run_seed, "battle-elite", elite.size(), [contract_id, market_index, risk])] + # The region's contract boss anchors the highest-risk offer. + var boss_rows := ContentRegistry.rows_by_id(_catalog, "bosses") + if not region_boss.is_empty() and boss_rows.has(region_boss): + enemies[-1] = region_boss + elif not elite.is_empty(): + enemies[-1] = elite[_index(run_seed, "battle-elite", elite.size(), [contract_id, market_index, risk])] var offer := { "offer_id": _low128(_digest(run_seed, "battle-offer", [contract_id, market_index, risk])), "risk": risk, @@ -415,9 +423,11 @@ func _default_role_weights(run_seed: String, contract_id: String, market_index: func _battle_preview_tags(enemy_ids: Array[String], enemy_rows: Dictionary) -> Array[String]: + var boss_rows := ContentRegistry.rows_by_id(_catalog, "bosses") var tags: Array[String] = [] for enemy_id in enemy_ids: - for tag in enemy_rows[enemy_id].semantic_tags: + var row: Dictionary = enemy_rows.get(enemy_id, boss_rows.get(enemy_id, {})) + for tag in Array(row.get("semantic_tags", [])): if tag not in tags: tags.append(String(tag)) tags.sort() diff --git a/runtime/tests/dev6/region_advance_test.gd b/runtime/tests/dev6/region_advance_test.gd new file mode 100644 index 00000000..73a16913 --- /dev/null +++ b/runtime/tests/dev6/region_advance_test.gd @@ -0,0 +1,148 @@ +extends SceneTree + +## M79-B gate: Contracts advance by ordinal, so the second region becomes +## reachable. Contract 1 and 2 are region one; contract 3 opens Siltwake Fens +## with its own equipment band, enemy pool and boss anchor. + +const DraftService = preload("res://entry/initial_company_draft_service.gd") +const ContractService = preload("res://entry/guild_contract_service.gd") +const ContentRegistry = preload("res://p10_0/content_registry.gd") + +const COMPANY_PATH := "user://aetherbound_region_advance_company.json" +const DRAFT_PATH := "user://aetherbound_region_advance_draft.json" +const OP_CREATE_1 := "11111111111111111111111111111111" +const OP_RETURN_1 := "22222222222222222222222222222222" +const OP_CREATE_2 := "33333333333333333333333333333333" +const OP_RETURN_2 := "44444444444444444444444444444444" +const OP_CREATE_3 := "55555555555555555555555555555555" + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _cleanup() + var company := _create_company() + if company.is_empty(): + push_error("region advance: company creation failed") + quit(1) + return + expect(String(company.active_contract.region_id) == "RG-01", "first contract opens region one") + expect(_equipment_band(company) == "EQ-001..EQ-040", "region one market draws EQ-001..EQ-040") + expect(int(company.active_contract.region_index) == 1, "region index starts at one") + + var returned_1: Dictionary = _service().return_contract(OP_RETURN_1) + if not returned_1.ok: + push_error("region advance: contract one return failed: %s" % String(returned_1.get("code", ""))) + quit(1) + return + var created_2: Dictionary = _service().create_contract(OP_CREATE_2, "CTR-R1-02", "standard") + if not created_2.ok: + push_error("region advance: second contract failed: %s" % String(created_2.get("code", ""))) + quit(1) + return + expect(String(created_2.company.active_contract.region_id) == "RG-01", "second contract stays in region one") + expect(_equipment_band(created_2.company) == "EQ-001..EQ-040", "region one market band holds for contract two") + + var returned_2: Dictionary = _service().return_contract(OP_RETURN_2) + if not returned_2.ok: + push_error("region advance: contract two return failed: %s" % String(returned_2.get("code", ""))) + quit(1) + return + var created_3: Dictionary = _service().create_contract(OP_CREATE_3, "CTR-R2-01", "standard") + if not created_3.ok: + push_error("region advance: third contract failed: %s" % String(created_3.get("code", ""))) + quit(1) + return + var contract: Dictionary = created_3.company.active_contract + expect(String(contract.region_id) == "RG-02", "third contract opens region two, got %s" % String(contract.region_id)) + expect(int(contract.region_index) == 2, "region index advances to two") + expect(String(contract.boss_id) == "BO-R2-01", "region two contract anchors its first boss") + expect(_equipment_band(created_3.company) == "EQ-041..EQ-080", "region two market draws EQ-041..EQ-080") + + var enemy_ids: Array[String] = [] + for offer in Array(contract.market_board.battle_offers): + for enemy_id in Array(Dictionary(offer).get("enemy_ids", [])): + enemy_ids.append(String(enemy_id)) + var off_region: Array[String] = [] + for enemy_id in enemy_ids: + if not enemy_id.begins_with("EN-R2-") and not enemy_id.begins_with("BO-R2-"): + off_region.append(enemy_id) + expect(off_region.is_empty(), "region two battles field only region two identities, leaked %s" % str(off_region)) + var boss_offer: Dictionary = Dictionary(Array(contract.market_board.battle_offers)[2]) + expect(Array(boss_offer.get("enemy_ids", [])).has("BO-R2-01"), "highest-risk offer anchors the contract boss") + + var content: Dictionary = ContentRegistry.snapshot() + expect(ContentRegistry.localized_name(content, "RG-02", "zh_CN") == "淤醒沼泽", "region two zh authority resolves") + expect(ContentRegistry.localized_name(content, "RG-02", "en") == "Siltwake Fens", "region two en authority resolves") + + _cleanup() + if failures.is_empty(): + print("ABG_M79_REGION_ADVANCE_OK contracts=3 region=RG-02 equipment=%s enemies=%d boss=%s" % [ + _equipment_band(created_3.company), enemy_ids.size(), String(contract.boss_id)]) + quit(0) + return + for failure in failures: + push_error("M79-B region advance: %s" % failure) + quit(1) + + +func _equipment_band(company: Dictionary) -> String: + # The market draws four identities from the region band; the gate checks that + # every drawn identity stays inside the band rather than the band's extremes. + var ids: Array[String] = [] + for offer in Array(Dictionary(company.get("active_contract", {})).get("market_board", {}).get("equipment_offers", [])): + ids.append(String(Dictionary(offer).get("catalog_id", ""))) + if ids.is_empty(): + return "none" + var low := 9999 + var high := 0 + for catalog_id in ids: + var ordinal := int(catalog_id.split("-")[1]) + low = mini(low, ordinal) + high = maxi(high, ordinal) + var expected_low := 1 + (int(Dictionary(company.get("active_contract", {})).get("region_index", 1)) - 1) * 40 + var expected_high := expected_low + 39 + if low >= expected_low and high <= expected_high: + return "EQ-%03d..EQ-%03d" % [expected_low, expected_high] + return "out_of_band:%d..%d" % [low, high] + + +func _create_company() -> Dictionary: + var draft: Dictionary = _draft_service().begin_or_load("abcdef0123456789abcdef0123456789") + if not draft.ok: + return {} + var selected := [String(draft.draft.offers[0].offer_id), String(draft.draft.offers[1].offer_id)] + if not _draft_service().set_selection(selected).ok: + return {} + if not _draft_service().commit_company().ok: + return {} + var created: Dictionary = _service().create_contract(OP_CREATE_1, "CTR-R1-01", "standard") + return created.company if created.ok else {} + + +func _service() -> RefCounted: + var service := ContractService.new() + service.company_path = COMPANY_PATH + return service + + +func _draft_service() -> RefCounted: + var service := DraftService.new() + service.path = DRAFT_PATH + service.company_path = COMPANY_PATH + return service + + +func _cleanup() -> void: + for path in [COMPANY_PATH, DRAFT_PATH, "%s.bak" % COMPANY_PATH, "%s.candidate" % COMPANY_PATH]: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append(message)