feat(m79-b): contract advancement opens region two with its band, boss, battlefield and music

This commit is contained in:
2026-09-15 06:59:21 +08:00
parent d47f96ce96
commit af712a63cd
11 changed files with 301 additions and 27 deletions
+1
View File
@@ -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 违规;立绘高度带 |
+10 -4
View File
@@ -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` 仍为派生值(待复核);超出已实现区域后合同会重复最后一份(后游戏范围) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

@@ -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
+12 -2
View File
@@ -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")
+54 -5
View File
@@ -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<region>-<slot>`; 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:
+29 -6
View File
@@ -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),
})
+2
View File
@@ -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,
+16 -6
View File
@@ -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()
+148
View File
@@ -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)