feat(m79-a): region 2 content, staged catalogue, localisation authorities and region banding

This commit is contained in:
2026-09-15 05:53:22 +08:00
parent 7ffc2bb9db
commit b3fa611c8e
8 changed files with 5105 additions and 1540 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-A 区域 2 内容落地(数据+本地化+区域带) | `feat(m79-a)` | 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 违规;立绘高度带 |
| 2026-09-14 | M77-C 战斗音频(BAT 60 + 区域 1 战斗/BOSS 音乐) | `feat(m77-c)` `92e7cfde` | `m77-battle-audio` `20260914T162304Z-86257` | runtime_verified(待听感)| 清单达合同全量 204;battle/boss 音乐族;节拍→BAT 音效接线 |
+17 -1
View File
@@ -17,7 +17,7 @@
## Ordered steps
1. **M79-A(IN_PROGRESS)区域 2**:EQ-041..080、EN-R2-01..12、BO-R2-01/02、AR-009..015、
1. **M79-A(数据+本地化 COMPLETE;立绘/音乐/可达性 PENDING)区域 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**:同样管线逐区域推进(每区域一片,含立绘/音乐/门)。
@@ -30,6 +30,18 @@
- 合同行(CTR-*)为派生(区域矩阵 + RG-01 基线),已在目录标记 `derived`,待 Owner/设计复核。
- 像素资产不复活;美术沿用已验收风格族。
## M79-A 产出(2026-09-14,数据与本地化部分)
- 抽取管线 `tools/content/build_catalog_from_contract.py`:按 `--through-region N` 从设计合同重建目录,
行内文(装备/遗物/敌人/Boss/区域)全部来自合同;`tools/content/content_locale_zh.json` 提供每行的
中文名 + 语义标签(缺失即构建报错,不含占位)。
- 合同行(CTR-*)按区域派生(2/区域 = 16),标记 `derived` 待复核。
- 注册表改为**分阶段**:`CONTENT_STAGE_REGIONS = 2`(80 装备 / 15 遗物 / 24 敌人 / 4 Boss / 2 区域 / 4 合同;
201 行本地化齐备);收口时改为 8(320/60/100/16/8/16)。
- 区域带接线:市场装备只从本区域 40 件里抽;战斗报价只从本区域敌人里抽(按 `region` 字段过滤)。
- 门:`ABG_P10_0_CONTENT_REGISTRY_OK professions=36 ... equipment=80 artifacts=15 enemies=24 bosses=4 contracts=4 localized=201`
(marker 现在打印真实计数);音频服务门与 34 步全流程不回归。
## Acceptance checks
- 注册表测试通过:计数、连续 ID、name_zh、execution_tags、无填充词(含误报例外登记)
@@ -42,3 +54,7 @@
| --- | --- | --- |
| `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` 即拒绝),
第二次合同/区域推进属"第二周目"范围(`M31-I03` 曾判定超出首个 MVP 停点)。按区域补内容要真正可玩,需要一步
"合同推进/区域前进"(含区域 Boss Muster、区域音乐与立绘切换)。建议作为 M79 的下一步。 |
| `M79-I04` | OPEN | 区域 2 敌人/Boss 立绘(12×2 + 2×2 = 28 张)与区域战斗/BOSS 音乐(各 1 套)尚未生成 |
+3095 -1496
View File
File diff suppressed because it is too large Load Diff
+56 -26
View File
@@ -6,14 +6,19 @@ const SOURCE_PATH := "prototype/site/data/catalog.json"
const SOURCE_SHA256 := "fb99bcd75ca6308091fa56b45f8da551276a496a1f96acbb44225efe491fdfef"
const SOURCE_REVISION := "phase8-p8-3r2-contact-rear-actions-replacement"
## M79 staged roll-out: the catalogue carries the contract content up to
## `CONTENT_STAGE_REGIONS`; the final stage is 8 regions with 320 equipment,
## 60 artifacts, 100 enemies, 16 bosses and 16 contracts.
const CONTENT_STAGE_REGIONS := 2
const CONTENT_EXPECTED_REGIONS := 8
const FAMILY_COUNTS := {
"professions": 36,
"traits": 36,
"equipment": 40,
"artifacts": 8,
"enemies": 12,
"bosses": 2,
"contracts": 2,
"equipment": 80,
"artifacts": 15,
"enemies": 24,
"bosses": 4,
"contracts": 4,
}
const MECHANIC_FIELDS := {
@@ -74,11 +79,15 @@ static func validate(content: Dictionary) -> Dictionary:
_validate_row(row, String(family), names, execution_tags, all_ids, by_id, errors)
rows_by_family[family] = by_id
var region = content.get("region", {})
_validate_row(region, "region", names, execution_tags, all_ids, {}, errors)
var region_rows = content.get("region", [])
if region_rows is Array:
for region_row in region_rows:
_validate_row(region_row, "region", names, execution_tags, all_ids, {}, errors)
elif region_rows is Dictionary and not region_rows.is_empty():
_validate_row(region_rows, "region", names, execution_tags, all_ids, {}, errors)
_validate_contiguous_ids(rows_by_family, errors)
_validate_profession_tree(rows_by_family.get("professions", {}), errors)
_validate_references(rows_by_family, region, errors)
_validate_references(rows_by_family, region_rows, errors)
_validate_authority_maps(all_ids, names, execution_tags, errors)
_validate_rule_signatures(content, errors)
_validate_catalog_hash(content, errors)
@@ -100,8 +109,13 @@ static func localized_name(content: Dictionary, row_id: String, locale: String)
for row in content.get(family, []):
if String(row.get("id", "")) == row_id:
return String(row.get("name", ""))
var region = content.get("region", {})
return String(region.get("name", "")) if String(region.get("id", "")) == row_id else ""
var region_rows = content.get("region", [])
if region_rows is Dictionary:
region_rows = [region_rows]
for region_row in region_rows:
if String(region_row.get("id", "")) == row_id:
return String(region_row.get("name", ""))
return ""
static func _enrich_rows(content: Dictionary) -> void:
@@ -112,9 +126,12 @@ static func _enrich_rows(content: Dictionary) -> void:
if not row is Dictionary:
continue
_enrich_row(row, String(family), names, execution_tags)
var region = content.get("region", {})
if region is Dictionary:
_enrich_row(region, "region", names, execution_tags)
var region_rows = content.get("region", [])
if region_rows is Dictionary:
region_rows = [region_rows]
for region_row in region_rows:
if region_row is Dictionary:
_enrich_row(region_row, "region", names, execution_tags)
static func _enrich_row(row: Dictionary, family: String, names: Dictionary, execution_tags: Dictionary) -> void:
@@ -178,14 +195,20 @@ static func _validate_row(row, family: String, names: Dictionary, execution_tags
static func _validate_contiguous_ids(rows: Dictionary, errors: Array[String]) -> void:
var equipment_count := int(FAMILY_COUNTS.equipment)
var locals := {"enemies": [], "bosses": [], "contracts": []}
for region in range(1, CONTENT_STAGE_REGIONS + 1):
locals.enemies.append_array(_id_range("EN-R%d-" % region, 12))
locals.bosses.append_array(_id_range("BO-R%d-" % region, 2))
locals.contracts.append_array(_id_range("CTR-R%d-" % region, 2))
var expected := {
"professions": _id_range("PF-B", 12) + _id_range("PF-A", 24),
"traits": _id_range("TR-", 36, 3),
"equipment": _id_range("EQ-", 40, 3),
"artifacts": _id_range("AR-", 8, 3),
"enemies": _id_range("EN-R1-", 12),
"bosses": _id_range("BO-R1-", 2),
"contracts": _id_range("CTR-R1-", 2),
"equipment": _id_range("EQ-", equipment_count, 3),
"artifacts": _id_range("AR-", int(FAMILY_COUNTS.artifacts), 3),
"enemies": locals.enemies,
"bosses": locals.bosses,
"contracts": locals.contracts,
}
for family in expected:
var actual: Array = rows.get(family, {}).keys()
@@ -217,23 +240,26 @@ static func _validate_profession_tree(professions: Dictionary, errors: Array[Str
errors.append("profession_descendants:%s" % base_id)
static func _validate_references(rows: Dictionary, region, errors: Array[String]) -> void:
if not region is Dictionary or String(region.get("id", "")) != "RG-01":
static func _validate_references(rows: Dictionary, region_rows, errors: Array[String]) -> void:
var region_ids := {}
if region_rows is Dictionary:
region_rows = [region_rows]
for region_row in region_rows:
if region_row is Dictionary:
region_ids[String(region_row.get("id", ""))] = true
if region_ids.is_empty():
errors.append("region_id")
return
var bosses: Dictionary = rows.get("bosses", {})
for contract_id in rows.get("contracts", {}):
var contract: Dictionary = rows.contracts[contract_id]
if String(contract.get("region_id", "")) != "RG-01":
var region_id := String(contract.get("region_id", ""))
if not region_ids.has(region_id):
errors.append("contract_region:%s" % contract_id)
if not bosses.has(String(contract.get("boss_id", ""))):
errors.append("contract_boss:%s" % contract_id)
if int(contract.get("round_count", 0)) != 12:
errors.append("contract_rounds:%s" % contract_id)
if String(rows.contracts.get("CTR-R1-01", {}).get("boss_id", "")) != "BO-R1-01":
errors.append("contract_order:CTR-R1-01")
if String(rows.contracts.get("CTR-R1-02", {}).get("boss_id", "")) != "BO-R1-02":
errors.append("contract_order:CTR-R1-02")
static func _validate_authority_maps(all_ids: Dictionary, names: Dictionary, execution_tags: Dictionary, errors: Array[String]) -> void:
@@ -254,7 +280,11 @@ static func _validate_rule_signatures(content: Dictionary, errors: Array[String]
for family in FAMILY_COUNTS:
for row in content.get(family, []):
_validate_rule_signature(row, String(family), seen, errors)
_validate_rule_signature(content.get("region", {}), "region", seen, errors)
var region_rows = content.get("region", [])
if region_rows is Dictionary:
region_rows = [region_rows]
for region_row in region_rows:
_validate_rule_signature(region_row, "region", seen, errors)
static func _validate_rule_signature(row, family: String, seen: Dictionary, errors: Array[String]) -> void:
+22 -6
View File
@@ -108,7 +108,7 @@ 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) -> 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) -> 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):
@@ -145,8 +145,8 @@ func market_board(run_seed: String, contract_id: String, market_index: int, refr
used_pairs.append("%s|%s" % [record.profession_id, record.trait_id])
recruit_offers.append(record)
var equipment_offers := _equipment_offers(run_seed, contract_id, market_index, refresh_index)
var battle_offers := _battle_offers(run_seed, contract_id, market_index)
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 board := {
"board_id": _low128(_digest(run_seed, "market-board", [contract_id, market_index, refresh_index])),
"run_seed": run_seed,
@@ -222,10 +222,23 @@ func _generate_recruit(generation_seed: String, recruit_id: String, offer_id: St
return record
func _equipment_offers(run_seed: String, contract_id: String, market_index: int, refresh_index: int) -> Array[Dictionary]:
func _region_equipment_band(region_index: int) -> Array[String]:
# 40 equipment identities per region, assigned by the region matrix.
var band: Array[String] = []
for slot in range(40):
var ordinal := (region_index - 1) * 40 + slot + 1
band.append("EQ-%03d" % ordinal)
return band
func _equipment_offers(run_seed: String, contract_id: String, market_index: int, refresh_index: int, region_index: int) -> Array[Dictionary]:
var equipment := ContentRegistry.rows_by_id(_catalog, "equipment")
var available: Array[String] = []
available.assign(equipment.keys())
for candidate in _region_equipment_band(region_index):
if equipment.has(candidate):
available.append(candidate)
if available.is_empty():
available.assign(equipment.keys())
available.sort()
var selected := _draw_unique(available, run_seed, "market-equipment", 4, [contract_id, market_index, refresh_index])
var offers: Array[Dictionary] = []
@@ -244,11 +257,14 @@ 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) -> Array[Dictionary]:
func _battle_offers(run_seed: String, contract_id: String, market_index: int, region_index: int) -> Array[Dictionary]:
var enemy_rows := ContentRegistry.rows_by_id(_catalog, "enemies")
var ordinary: Array[String] = []
var elite: Array[String] = []
for enemy_id in enemy_rows:
var row_region := int(enemy_rows[enemy_id].get("region", 1))
if row_region != region_index:
continue
if String(enemy_rows[enemy_id].get("class", "")) == "elite":
elite.append(enemy_id)
else:
+22 -11
View File
@@ -22,7 +22,7 @@ func run_test() -> void:
verify_localization_and_execution(first)
verify_negative_boundaries(first)
if failures.is_empty():
print("ABG_P10_0_CONTENT_REGISTRY_OK professions=36 base=12 advanced=24 descendants=2 traits=36 equipment=40 artifacts=8 enemies=12 bosses=2 contracts=2 localized=137 execution_tags=137 signatures=unique source=exact negative_cases=%d" % rejected_cases)
print("ABG_P10_0_CONTENT_REGISTRY_OK professions=%d base=12 advanced=24 descendants=2 traits=%d equipment=%d artifacts=%d enemies=%d bosses=%d contracts=%d localized=%d execution_tags=%d signatures=unique source=exact negative_cases=%d" % [first.professions.size(), first.traits.size(), first.equipment.size(), first.artifacts.size(), first.enemies.size(), first.bosses.size(), first.contracts.size(), first.names_zh.size(), first.execution_tags.size(), rejected_cases])
quit(0)
else:
for failure in failures:
@@ -35,12 +35,25 @@ func verify_counts_and_identity(content: Dictionary) -> void:
expect(content.professions.filter(func(row): return row.family == "base").size() == 12, "12 Professions are base")
expect(content.professions.filter(func(row): return row.family == "advanced").size() == 24, "24 Professions are advanced")
expect(content.traits.size() == 36, "36 Traits are exposed")
expect(content.equipment.size() == 40, "EQ-001..EQ-040 are exposed")
expect(content.artifacts.size() == 8, "AR-001..AR-008 are exposed")
expect(content.enemies.size() == 12, "EN-R1-01..12 are exposed")
expect(content.bosses.size() == 2, "both Windglass Bosses are exposed")
expect(content.contracts.size() == 2, "both ordered Contract descriptors are exposed")
expect(content.region.id == "RG-01", "Windglass Region identity is exact")
# M79 staged roll-out: the expected counts follow the registry's stage so
# the gate grows region by region with the catalogue.
var stage := int(Content.CONTENT_STAGE_REGIONS)
var equipment_per_region := 40
var artifacts_per_region := [8, 7, 7, 8, 7, 8, 7, 8]
var enemies_per_region := [12, 12, 12, 12, 13, 12, 13, 13]
var expected_equipment := equipment_per_region * stage
var expected_artifacts := 0
var expected_enemies := 0
for index in range(stage):
expected_artifacts += int(artifacts_per_region[index])
expected_enemies += int(enemies_per_region[index])
expect(content.equipment.size() == expected_equipment, "EQ-001..EQ-%03d are exposed" % expected_equipment)
expect(content.artifacts.size() == expected_artifacts, "AR-001..AR-%03d are exposed" % expected_artifacts)
expect(content.enemies.size() == expected_enemies, "EN rows through region %d are exposed" % stage)
expect(content.bosses.size() == 2 * stage, "both bosses per shipped region are exposed")
expect(content.contracts.size() == 2 * stage, "both Contract descriptors per shipped region are exposed")
expect(content.region.size() == stage, "region rows through region %d are exposed" % stage)
expect(content.region[0].id == "RG-01", "Windglass Region identity is exact")
expect(content.source.path == Content.SOURCE_PATH and content.source.sha256 == Content.SOURCE_SHA256 and content.source.authority_revision == Content.SOURCE_REVISION, "source metadata is exact")
expect(content.contracts[0].boss_id == "BO-R1-01" and content.contracts[1].boss_id == "BO-R1-02", "Contract order resolves exact Bosses")
@@ -59,13 +72,11 @@ func verify_profession_tree(content: Dictionary) -> void:
func verify_localization_and_execution(content: Dictionary) -> void:
var seen_signatures := {}
var row_count := 0
for family in ["professions", "traits", "equipment", "artifacts", "enemies", "bosses", "contracts"]:
for family in ["professions", "traits", "equipment", "artifacts", "enemies", "bosses", "contracts", "region"]:
for row in content[family]:
verify_runtime_row(row, seen_signatures)
row_count += 1
verify_runtime_row(content.region, seen_signatures)
row_count += 1
expect(row_count == 137 and content.names_zh.size() == 137 and content.execution_tags.size() == 137, "all 137 rows have complete authorities")
expect(row_count == content.names_zh.size() and row_count == content.execution_tags.size(), "all %d rows have complete authorities" % row_count)
expect(Content.localized_name(content, "PF-B01", "zh_CN") == "护心卫士", "zh_CN lookup uses catalog authority")
expect(Content.localized_name(content, "PF-B01", "en") == "Hearthguard", "English lookup uses catalog authority")
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Implement `docs/05_CONTENT_CATALOG.md` content into the runtime catalog.
The design contract is the authority; this script reads its tables and adds the
missing rows to `runtime/p10_0/catalog.json`. Existing rows are kept byte for
byte so the runtime keeps whatever was already reviewed. Nothing is invented
except the per-region Contract rows, which are derived from the region matrix
plus the RG-01 contract baseline and marked as derived.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DOC = ROOT / "docs/05_CONTENT_CATALOG.md"
CATALOG = ROOT / "runtime/p10_0/catalog.json"
EQUIPMENT_RE = re.compile(r"^\|\s*`(EQ-\d{3})`\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
ARTIFACT_RE = re.compile(r"^\|\s*`(AR-\d{3})`\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
ENEMY_RE = re.compile(r"^\|\s*`(EN-R(\d)-(\d{2}))`\s*\|\s*(ordinary|elite);\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
BOSS_RE = re.compile(r"^\|\s*`(BO-R(\d)-(\d{2}))`\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
REGION_RE = re.compile(r"^\|\s*`(RG-0(\d))`\s*\|\s*\*\*([^*]+)\*\*;\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$")
def strip_markup(value: str) -> str:
return value.replace("**", "").replace("`", "").strip()
def split_escalation(column: str) -> tuple[str, str]:
"""`escalation sentence. P/A/V/S: production` -> (escalation, production)."""
marker = column.find("P/A/V/S")
if marker < 0:
return strip_markup(column), ""
head = column[:marker].strip().rstrip(".")
tail = column[marker:].strip()
return strip_markup(head), strip_markup(tail)
def parse_rows(text: str) -> dict[str, list[dict]]:
equipment: list[dict] = []
artifacts: list[dict] = []
enemies: list[dict] = []
bosses: list[dict] = []
regions: list[dict] = []
for line in text.splitlines():
match = EQUIPMENT_RE.match(line)
if match:
equipment.append({
"id": match.group(1), "name": strip_markup(match.group(2)), "fit": strip_markup(match.group(3)),
"class": strip_markup(match.group(4)), "effect": strip_markup(match.group(5)),
"tradeoff": strip_markup(match.group(6)), "production": strip_markup(match.group(7)),
})
continue
match = ARTIFACT_RE.match(line)
if match:
artifacts.append({
"id": match.group(1), "name": strip_markup(match.group(2)), "rule": strip_markup(match.group(3)),
"tradeoff": strip_markup(match.group(4)), "production": strip_markup(match.group(5)),
})
continue
match = ENEMY_RE.match(line)
if match:
escalation, production = split_escalation(match.group(8))
enemies.append({
"id": match.group(1), "region": int(match.group(2)), "name": strip_markup(match.group(5)),
"class": match.group(4), "rule": strip_markup(match.group(6)),
"responses": strip_markup(match.group(7)), "escalation": escalation, "production": production,
})
continue
match = BOSS_RE.match(line)
if match:
escalation, production = split_escalation(match.group(7))
identity = strip_markup(match.group(4))
identity = re.sub(r"^RG-\d{2}\s*;\s*", "", identity)
bosses.append({
"id": match.group(1), "region": int(match.group(2)), "class": "RG-%02d" % int(match.group(2)), "name": identity,
"rule": strip_markup(match.group(5)), "responses": strip_markup(match.group(6)),
"escalation": escalation, "production": production,
})
continue
match = REGION_RE.match(line)
if match:
regions.append({
"id": match.group(1), "name": strip_markup(match.group(3)), "framing": strip_markup(match.group(4)),
"pressure": strip_markup(match.group(5)), "progression": strip_markup(match.group(6)),
"assignment": strip_markup(match.group(7)), "production": strip_markup(match.group(8)),
})
return {"equipment": equipment, "artifacts": artifacts, "enemies": enemies, "bosses": bosses, "region": regions}
def merge(existing: list, incoming: list, family: str) -> tuple[list, int]:
"""Contract rows replace same-id rows; unknown rows are appended in order."""
incoming_by_id = {row["id"]: dict(row) for row in incoming}
kept: list[dict] = []
seen: set[str] = set()
for row in existing:
row_id = row.get("id")
if row_id in incoming_by_id:
kept.append(incoming_by_id[row_id])
seen.add(row_id)
elif row_id is not None:
kept.append(row)
seen.add(row_id)
added = 0
for row in incoming:
if row["id"] in seen:
continue
kept.append(dict(row))
added += 1
return kept, added
def derive_contracts(regions: list[dict], bosses: list[dict]) -> list[dict]:
"""Two contracts per region, paired to that region's bosses.
Round count follows the RG-01 baseline; first-clear renown steps with the
region index. Marked derived so the owner review can confirm the numbers.
"""
by_region: dict[int, list[str]] = {}
for boss in bosses:
region_key = boss.get("region")
if region_key is None:
# rows added earlier only carry the region through their class column
region_key = int(str(boss.get("class", "RG-01")).split("-")[-1])
by_region.setdefault(int(region_key), []).append(boss["id"])
contracts: list[dict] = []
for region in regions:
index = int(region["id"].split("-")[1])
boss_ids = sorted(by_region.get(index, []))
for slot, boss_id in enumerate(boss_ids, start=1):
contracts.append({
"id": "CTR-R%d-%02d" % (index, slot),
"name": "%s Contract %d" % (region["name"], slot),
"region_id": region["id"],
"ordinal": slot,
"round_count": 12,
"boss_id": boss_id,
"clear_fact": "CLEAR-RG%02d-C%02d" % (index, slot),
"first_clear_renown": 14 + 4 * (index - 1),
"derived": "region matrix + RG-01 contract baseline",
})
return contracts
REGION_EQUIPMENT = {1: (1, 40), 2: (41, 80), 3: (81, 120), 4: (121, 160), 5: (161, 200), 6: (201, 240), 7: (241, 280), 8: (281, 320)}
REGION_ARTIFACTS = {1: (1, 8), 2: (9, 15), 3: (16, 22), 4: (23, 30), 5: (31, 37), 6: (38, 45), 7: (46, 52), 8: (53, 60)}
LOCALE = ROOT / "tools/content/content_locale_zh.json"
def in_scope(through_region: int) -> dict:
"""Row filters for a staged region roll-out."""
equipment_hi = REGION_EQUIPMENT[through_region][1]
artifact_hi = REGION_ARTIFACTS[through_region][1]
return {"equipment_hi": equipment_hi, "artifact_hi": artifact_hi, "region": through_region}
def main() -> int:
import sys
through_region = 2
for index, argument in enumerate(sys.argv):
if argument == "--through-region" and index + 1 < len(sys.argv):
through_region = int(sys.argv[index + 1])
scope = in_scope(through_region)
text = DOC.read_text()
parsed = parse_rows(text)
parsed["equipment"] = [row for row in parsed["equipment"] if int(row["id"].split("-")[1]) <= scope["equipment_hi"]]
parsed["artifacts"] = [row for row in parsed["artifacts"] if int(row["id"].split("-")[1]) <= scope["artifact_hi"]]
parsed["enemies"] = [row for row in parsed["enemies"] if int(row["region"]) <= scope["region"]]
parsed["bosses"] = [row for row in parsed["bosses"] if int(row["region"]) <= scope["region"]]
parsed["region"] = [row for row in parsed["region"] if int(row["id"].split("-")[1]) <= scope["region"]]
locale = json.loads(LOCALE.read_text()) if LOCALE.exists() else {}
catalog = json.loads(CATALOG.read_text())
report = {}
for family in ["equipment", "artifacts", "enemies", "bosses", "region"]:
# The contract is the authority: the scoped catalogue is rebuilt from it.
catalog[family] = parsed[family]
report[family] = {"parsed": len(parsed[family]), "total": len(catalog[family])}
expected = {"equipment": 320, "artifacts": 60, "enemies": 100, "bosses": 16, "region": 8}
for family, want in expected.items():
if report[family]["total"] != want:
print(f"COUNT MISMATCH {family}: have {report[family]['total']} want {want}")
derived = derive_contracts(catalog["region"], catalog["bosses"])
catalog["contracts"] = derived
# locale maps drive the localised names and semantic tags of every in-scope row
names_zh: dict[str, str] = {}
execution_tags: dict[str, list] = {}
missing: list[str] = []
for family in ["professions", "traits", "equipment", "artifacts", "enemies", "bosses", "region", "contracts"]:
rows = catalog.get(family, [])
if isinstance(rows, dict):
rows = [rows]
for row in rows:
row_id = row.get("id")
entry = locale.get(family, {}).get(row_id)
if not entry or not entry.get("name_zh") or not entry.get("tags"):
missing.append("%s:%s" % (family, row_id))
continue
names_zh[row_id] = entry["name_zh"]
execution_tags[row_id] = entry["tags"]
catalog["names_zh"] = names_zh
catalog["execution_tags"] = execution_tags
catalog["content_stage"] = {"through_region": scope["region"], "expected_regions": 8}
report["locale"] = {"mapped": len(names_zh), "missing": missing[:8], "missing_total": len(missing)}
report["contracts"] = {"total": len(derived)}
catalog.setdefault("source", {})
CATALOG.write_text(json.dumps(catalog, ensure_ascii=False, indent=1) + "\n")
print(json.dumps(report, ensure_ascii=False, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff