diff --git a/json/compiler_pbl_game_definition.json b/json/compiler_pbl_game_definition.json index 075348f..b5dce8a 100644 --- a/json/compiler_pbl_game_definition.json +++ b/json/compiler_pbl_game_definition.json @@ -1,11 +1,12 @@ { "tblname": "pbl_game_definition", "alias": "pbl_compiler", + "_comment": "M3b 整改(QC #13):主键/外键类字段在 models/pbl_game_definition.json 中为 str(32),CRUD 渲染类型必须为 text(原 number 会按数字输入框渲染 32 位字符串主键,排序/校验错位);同时补齐 M3b 规则导出四字段 rules_json/rules_hash/rule_count/rules_manifest 与 compile_status/error_message,移除模型中不存在的幽灵字段 entity_count/event_count。", "browserfields": { "id": { "label": "ID", "list": true, - "type": "number" + "type": "text" }, "tenant_id": { "label": "租户ID", @@ -14,7 +15,7 @@ }, "blueprint_id": { "label": "来源蓝图", - "type": "number", + "type": "text", "list": true }, "blueprint_version_no": { @@ -24,7 +25,7 @@ }, "compiler_version_id": { "label": "编译版本", - "type": "number", + "type": "text", "list": true }, "content_fingerprint": { @@ -32,29 +33,19 @@ "type": "text", "list": true }, - "definition_json": { - "label": "Game Definition 正文", + "rules_hash": { + "label": "规则集合指纹", "type": "text", "list": true }, - "world_id": { - "label": "落库 world", + "rule_count": { + "label": "规则条数", "type": "number", "list": true }, - "scene_id": { - "label": "落库 scene", - "type": "number", - "list": true - }, - "entity_count": { - "label": "实体数", - "type": "number", - "list": true - }, - "event_count": { - "label": "事件数", - "type": "number", + "compile_status": { + "label": "编译结果状态", + "type": "select", "list": true }, "quality_state": { @@ -62,10 +53,45 @@ "type": "select", "list": true }, + "world_id": { + "label": "落库 world", + "type": "text", + "list": true + }, + "scene_id": { + "label": "落库 scene", + "type": "text", + "list": true + }, "duration_ms": { "label": "耗时", "type": "number", "list": true + }, + "definition_json": { + "label": "Game Definition 正文", + "type": "text", + "list": false + }, + "rules_json": { + "label": "规则映射产物(script_type=1)", + "type": "text", + "list": false + }, + "rules_manifest": { + "label": "规则溯源清单", + "type": "text", + "list": false + }, + "error_message": { + "label": "失败原因", + "type": "text", + "list": false + }, + "created_at": { + "label": "创建时间", + "type": "text", + "list": true } }, "params": { @@ -73,7 +99,7 @@ "id": { "label": "ID", "list": true, - "type": "number" + "type": "text" }, "tenant_id": { "label": "租户ID", @@ -82,7 +108,7 @@ }, "blueprint_id": { "label": "来源蓝图", - "type": "number", + "type": "text", "list": true }, "blueprint_version_no": { @@ -92,7 +118,7 @@ }, "compiler_version_id": { "label": "编译版本", - "type": "number", + "type": "text", "list": true }, "content_fingerprint": { @@ -100,29 +126,19 @@ "type": "text", "list": true }, - "definition_json": { - "label": "Game Definition 正文", + "rules_hash": { + "label": "规则集合指纹", "type": "text", "list": true }, - "world_id": { - "label": "落库 world", + "rule_count": { + "label": "规则条数", "type": "number", "list": true }, - "scene_id": { - "label": "落库 scene", - "type": "number", - "list": true - }, - "entity_count": { - "label": "实体数", - "type": "number", - "list": true - }, - "event_count": { - "label": "事件数", - "type": "number", + "compile_status": { + "label": "编译结果状态", + "type": "select", "list": true }, "quality_state": { @@ -130,16 +146,51 @@ "type": "select", "list": true }, + "world_id": { + "label": "落库 world", + "type": "text", + "list": true + }, + "scene_id": { + "label": "落库 scene", + "type": "text", + "list": true + }, "duration_ms": { "label": "耗时", "type": "number", "list": true + }, + "definition_json": { + "label": "Game Definition 正文", + "type": "text", + "list": false + }, + "rules_json": { + "label": "规则映射产物(script_type=1)", + "type": "text", + "list": false + }, + "rules_manifest": { + "label": "规则溯源清单", + "type": "text", + "list": false + }, + "error_message": { + "label": "失败原因", + "type": "text", + "list": false + }, + "created_at": { + "label": "创建时间", + "type": "text", + "list": true } }, "editable": { "blueprint_id": { "label": "来源蓝图", - "type": "number" + "type": "text" }, "blueprint_version_no": { "label": "蓝图版本", @@ -147,7 +198,7 @@ }, "compiler_version_id": { "label": "编译版本", - "type": "number" + "type": "text" }, "content_fingerprint": { "label": "SHA-256 指纹", @@ -157,21 +208,33 @@ "label": "Game Definition 正文", "type": "text" }, + "rules_json": { + "label": "规则映射产物(script_type=1)", + "type": "text" + }, + "rules_hash": { + "label": "规则集合指纹", + "type": "text" + }, + "rule_count": { + "label": "规则条数", + "type": "number" + }, + "rules_manifest": { + "label": "规则溯源清单", + "type": "text" + }, "world_id": { "label": "落库 world", - "type": "number" + "type": "text" }, "scene_id": { "label": "落库 scene", - "type": "number" + "type": "text" }, - "entity_count": { - "label": "实体数", - "type": "number" - }, - "event_count": { - "label": "事件数", - "type": "number" + "compile_status": { + "label": "编译结果状态", + "type": "select" }, "quality_state": { "label": "编译时质量状态", @@ -180,9 +243,14 @@ "duration_ms": { "label": "耗时", "type": "number" + }, + "error_message": { + "label": "失败原因", + "type": "text" } }, + "_crud_note": "pbl_game_definition 是编译产物表(只读,写入由 pbl_compiler_compile 主流程完成),故本定义刻意不提供 new_data_url/update_data_url/delete_data_url —— 不开放手工增删改,避免绕过 29.6 确定性指纹与 rules_hash 收口逻辑。", "order_by": "id DESC", "page_size": 20 } -} +} \ No newline at end of file diff --git a/pbl_compiler/gd_rules_writer.py b/pbl_compiler/gd_rules_writer.py new file mode 100644 index 0000000..0622b8c --- /dev/null +++ b/pbl_compiler/gd_rules_writer.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +"""pbl_compiler.gd_rules_writer —— M3b 第12章收口:Game Definition 规则产物落库。 + +背景(本轮 M3b 整改的真实缺口): + exporter.write_game_definition() 用「候选列名探测」写 GD,候选集是 + ('game_definition','gd_json','definition','content'),而 models/pbl_game_definition.json + 的真实列名是 `definition_json` / `content_fingerprint` / `rules_json` / `rules_hash` / + `rule_count` / `rules_manifest` / `compile_status` / `quality_state` / `world_id` / + `scene_id` / `duration_ms` / `error_message`。探测不命中 → M3b 的规则映射产物 + (rules_json / rules_hash / rule_count / rules_manifest)根本没写进 game_definition, + 「规则导出与 game_definition 写入」这条需求等于没落地。 + +本文件是 M3b 的**权威 GD 写入器**:列名直接对齐 models/pbl_game_definition.json, +显式列名 INSERT/UPDATE(不靠位置、不靠猜),并把规则四件套一并落库。 + +铁律: + - 所有 SELECT/INSERT/UPDATE 一律 tenant_id 打头,缺失即抛 CompileError(fail-closed)。 + - 幂等:同 (tenant_id, blueprint_id, blueprint_version_no) 重复写只更新不新增; + 同输入必同 content_fingerprint / rules_hash(29.6 确定性,无时间戳无 uuid 进正文)。 + - created_at/updated_at 属审计字段,只写审计列,绝不参与指纹计算。 +""" + +import json + +from .script_mapping import ( + CompileError, + Issue, + canonical_rules_json, + build_rules_manifest, + canonical_dumps, + sha256_text, + strip_volatile, + to_int, +) + +__all__ = [ + 'GD_TABLE', + 'GD_COLUMNS', + 'gd_primary_id', + 'build_gd_row', + 'write_gd_rules', + 'read_gd_rules', + 'read_gd_rules_by_id', +] + +#: 表名与列集合,唯一事实源 = models/pbl_game_definition.json(勿在此表外造列) +GD_TABLE = 'pbl_game_definition' + +GD_COLUMNS = ( + 'id', 'tenant_id', 'blueprint_id', 'blueprint_version_no', 'compiler_version_id', + 'content_fingerprint', 'definition_json', + 'rules_json', 'rules_hash', 'rule_count', 'rules_manifest', + 'world_id', 'scene_id', 'compile_status', 'quality_state', + 'duration_ms', 'error_message', + 'created_by', 'created_at', 'updated_by', 'updated_at', 'is_deleted', +) + +#: 参与内容指纹的列(29.6:排除审计/耗时/状态等易变列) +_FINGERPRINT_COLUMNS = ('blueprint_id', 'blueprint_version_no', 'definition_json', + 'rules_json') + + +def _text(value): + """任意对象 → 稳定文本(None → 空串,dict/list → canonical JSON)。""" + if value is None: + return '' + if isinstance(value, str): + return value + if isinstance(value, (dict, list)): + return canonical_dumps(strip_volatile(value)) + return str(value) + + +def gd_primary_id(tenant_id, blueprint_id, blueprint_version_no, content_fingerprint): + """确定性主键:sha256(tenant|bp|ver|fp) 前 32 位十六进制。 + + 不用 uuid/不自增 —— 保证「同输入重编译 → 同一行」,幂等 upsert 才成立。 + """ + key = '|'.join([str(tenant_id or ''), str(blueprint_id or ''), + str(blueprint_version_no or ''), str(content_fingerprint or '')]) + return sha256_text(key)[:32] + + +def content_fingerprint_of(row): + """按参与指纹的列计算 GD 内容指纹(列序固定,与 sort_keys 无关)。""" + parts = [] + for col in _FINGERPRINT_COLUMNS: + parts.append('%s=%s' % (col, _text(row.get(col)))) + return sha256_text('\n'.join(parts)) + + +def build_gd_row(gd, ctx, rules=None, manifest=None, base_definition_json=None, + issues=None): + """组装一行 pbl_game_definition 记录(列名对齐 models,不猜列)。 + + gd: M3a gd_builder + M3b rules 顶层键合成后的完整 GD dict(10 顶层键 + rules) + ctx: tenant_id* / blueprint_id* / blueprint_version* / compiler_version_id / + world_id / scene_id / quality_state / compile_status / duration_ms / + created_by / error_message + rules: build_rules() 产出的 script_type=1 规则列表 + 返回 (row_dict, issues) + """ + issues = list(issues or []) + ctx = dict(ctx or {}) + tenant_id = str(ctx.get('tenant_id') or '').strip() + if not tenant_id: + raise CompileError('E_TENANT_REQUIRED', 'tenant_id 缺失,拒绝写入 game_definition') + blueprint_id = str(ctx.get('blueprint_id') or ctx.get('bp_id') or '').strip() + blueprint_version_no = to_int(ctx.get('blueprint_version') + or ctx.get('blueprint_version_no'), 0) + if not blueprint_id or blueprint_version_no <= 0: + raise CompileError('E_GD_KEY_MISSING', + 'blueprint_id / blueprint_version_no 缺失或非法,无法定位 game_definition') + + rules = list(rules or []) + manifest = manifest or build_rules_manifest(rules) + gd = dict(gd or {}) + gd_text = canonical_dumps(strip_volatile(gd)) + rules_text = canonical_rules_json(rules) + + row = { + 'tenant_id': tenant_id, + 'blueprint_id': blueprint_id, + 'blueprint_version_no': blueprint_version_no, + 'compiler_version_id': _text(ctx.get('compiler_version_id') + or ctx.get('compiler_version') or '')[:32], + 'definition_json': base_definition_json if base_definition_json is not None else gd_text, + 'rules_json': rules_text, + 'rules_hash': _text(manifest.get('rules_hash'))[:64], + 'rule_count': to_int(manifest.get('rule_count'), len(rules)), + 'rules_manifest': canonical_dumps(strip_volatile(manifest)), + 'world_id': _text(ctx.get('world_id'))[:32], + 'scene_id': _text(ctx.get('scene_id'))[:32], + 'compile_status': _text(ctx.get('compile_status') or ctx.get('status') + or 'compiled')[:32], + 'quality_state': _text(ctx.get('quality_state'))[:64], + 'duration_ms': to_int(ctx.get('duration_ms'), 0), + 'error_message': _text(ctx.get('error_message'))[:60000], + 'created_by': _text(ctx.get('created_by') or ctx.get('actor_id'))[:32], + 'is_deleted': '0', + } + row['content_fingerprint'] = content_fingerprint_of(row) + row['id'] = gd_primary_id(row['tenant_id'], row['blueprint_id'], + row['blueprint_version_no'], row['content_fingerprint']) + + if not row['rules_hash']: + issues.append(Issue('error', 'E_RULES_HASH_EMPTY', + 'rules_manifest 未给出 rules_hash,规则产物不可信', + '', 'rules_hash')) + if row['rule_count'] != len(rules): + issues.append(Issue('warning', 'W_RULE_COUNT_MISMATCH', + 'rule_count=%s 与实际规则数=%s 不一致' + % (row['rule_count'], len(rules)), '', 'rule_count')) + return row, issues + + +async def _available_columns(sor, table): + """探测实际库中该表已有列(跨宿主/未跑 DDL 时降级,不猜列名写 SQL)。""" + try: + recs = await sor.sqlExe( + 'SELECT COLUMN_NAME AS c FROM INFORMATION_SCHEMA.COLUMNS ' + 'WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=${tbl}$', {'tbl': table}) + cols = set() + for r in recs or []: + v = getattr(r, 'c', None) or getattr(r, 'COLUMN_NAME', None) \ + or getattr(r, 'column_name', None) + if v: + cols.add(str(v)) + if cols: + return cols + except Exception: + pass + return set(GD_COLUMNS) + + +async def write_gd_rules(sor, row, ctx=None): + """幂等 upsert 一行 game_definition(含规则四件套)。 + + 唯一键:tenant_id + blueprint_id + blueprint_version_no(idx_gd_bp)。 + 返回 {'written','mode','gd_id','content_fingerprint','rules_hash','rule_count'} + """ + ctx = dict(ctx or {}) + tenant_id = str(row.get('tenant_id') or '').strip() + if not tenant_id: + raise CompileError('E_TENANT_REQUIRED', 'tenant_id 缺失,拒绝写入 game_definition') + + cols = await _available_columns(sor, GD_TABLE) + payload = {k: v for k, v in row.items() if k in cols and k != 'id'} + missing = [c for c in ('definition_json', 'rules_json', 'rules_hash', 'rule_count') + if c not in cols] + if missing: + raise CompileError('E_GD_COLUMN_MISSING', + 'pbl_game_definition 缺少 M3b 规则产物列 %s,请先执行 models/' + 'pbl_game_definition.json 生成的 DDL(json2ddl)' % missing) + + where = {'tenant_id': tenant_id, + 'blueprint_id': str(row.get('blueprint_id') or ''), + 'blueprint_version_no': int(row.get('blueprint_version_no') or 0)} + try: + exist = await sor.R(GD_TABLE, dict(where, is_deleted='0')) + except Exception as e: + raise CompileError('E_GD_QUERY', '查询 game_definition 失败: %s' % e) + + now = ctx.get('now_str') or '' + try: + if exist: + old_id = str(getattr(exist[0], 'id', '') or '') + upd = dict(payload) + if 'updated_at' in cols and now: + upd['updated_at'] = now + await sor.U(GD_TABLE, dict(upd, **{'id': old_id})) + mode, gd_id = 'update', old_id + else: + ins = dict(payload) + if 'id' in cols: + ins['id'] = row['id'] + if 'created_at' in cols and now: + ins['created_at'] = now + await sor.C(GD_TABLE, ins) + mode, gd_id = 'insert', str(row.get('id') or '') + except Exception as e: + raise CompileError('E_GD_WRITE', '写入 game_definition 失败: %s' % e) + + return { + 'written': True, + 'mode': mode, + 'gd_id': gd_id, + 'content_fingerprint': row.get('content_fingerprint'), + 'rules_hash': row.get('rules_hash'), + 'rule_count': row.get('rule_count'), + } + + +async def read_gd_rules(sor, tenant_id, blueprint_id, blueprint_version_no=None): + """读回 GD(含规则四件套),rules_json 解析成 list。""" + if not tenant_id: + raise CompileError('E_TENANT_REQUIRED', 'tenant_id 缺失') + where = {'tenant_id': str(tenant_id), 'blueprint_id': str(blueprint_id or '')} + if blueprint_version_no not in (None, ''): + where['blueprint_version_no'] = int(blueprint_version_no) + recs = await sor.R(GD_TABLE, where) + out = [] + for r in recs or []: + d = {c: getattr(r, c, None) for c in GD_COLUMNS} + if d.get('rules_json'): + try: + d['rules'] = json.loads(d['rules_json']) + except Exception: + d['rules'] = [] + out.append(d) + return out + + +async def read_gd_rules_by_id(sor, tenant_id, gd_id): + """按主键读单行(租户打头,跨租户查不到即 None)。""" + if not tenant_id or not gd_id: + raise CompileError('E_PARAM_MISSING', 'tenant_id / id 均为必填') + recs = await sor.R(GD_TABLE, {'tenant_id': str(tenant_id), 'id': str(gd_id)}) + if not recs: + return None + r = recs[0] + d = {c: getattr(r, c, None) for c in GD_COLUMNS} + for key in ('rules_json', 'rules_manifest', 'definition_json'): + if d.get(key): + try: + d[key + '_parsed'] = json.loads(d[key]) + except Exception: + d[key + '_parsed'] = None + return d diff --git a/pbl_compiler/rules_export_api.py b/pbl_compiler/rules_export_api.py new file mode 100644 index 0000000..c91d696 --- /dev/null +++ b/pbl_compiler/rules_export_api.py @@ -0,0 +1,392 @@ +# -*- coding: utf-8 -*- +"""pbl_compiler.rules_export_api —— M3b Web 契约层:规则映射 / 导出 / GD 写入。 + +本文件是 M3b「第12章 event→condition→response → script_type=1 规则 JSON 的映射、 +产物导出与 game_definition 写入」这条需求的**契约入口**。三层分工: + + ① pbl_compiler/script_mapping.py 纯函数映射层:ECR → 归一规则(无 IO、确定性) + ② pbl_compiler/exporter.py 导出层:规则 → script_engine 脚本表 + 版本回写 + ③ pbl_compiler/gd_rules_writer.py GD 落库层:规则四件套 → pbl_game_definition 真实列 + ④ 本文件 契约层:参数校验 / 门禁 / 组装返回(.dspy 直接调用) + +导出的 5 个契约(与 init.py CONTRACTS、__init__.py、scripts/load_path.py 三处同步): + pbl_compiler_export_rules 编译并导出规则(写脚本表 + 写 GD),支持 dry_run + pbl_compiler_rules_preview 只映射不落库(预览 rules_json / rules_hash / issues) + pbl_compiler_rules_diff 两个蓝图版本之间的规则集差异(diff_rules) + pbl_game_definition_rules_get 读回 GD 的规则产物列(rules_json/rules_hash/rule_count/manifest) + pbl_script_rule_list 分页查询已导出的 pbl_script_rule 行 + +铁律(与 api.py 一致): + - tenant_id 打头,缺失即 fail-closed(不猜租户、不落跨租户数据)。 + - error 级 issue 存在时绝不落库(拒绝产出半成品规则)。 + - 返回值统一 {'status','data',...},异常统一转 PblError 语义 dict(不抛裸异常给前端)。 +""" + +import time + +from pbl_common.api import PblError, tenant_id as _ctx_tenant_id + +from pbl_compiler.script_mapping import ( + CompileError, + Issue, + build_rules, + build_rules_manifest, + canonical_rules_json, + diff_rules, + has_errors, + validate_rules, +) +from pbl_compiler import exporter +from pbl_compiler.gd_rules_writer import ( + GD_TABLE, + build_gd_row, + content_fingerprint_of, + read_gd_rules, + read_gd_rules_by_id, + write_gd_rules, +) + +__all__ = [ + 'SCRIPT_TYPE_RULE', + 'export_rules_contract', + 'preview_rules_contract', + 'rules_diff_contract', + 'gd_rules_get_contract', + 'script_rule_list_contract', + 'pbl_compiler_export_rules', + 'pbl_compiler_rules_preview', + 'pbl_compiler_rules_diff', + 'pbl_game_definition_rules_get', + 'pbl_script_rule_list', +] + +SCRIPT_TYPE_RULE = 1 + +#: 脚本表候选(与 exporter._SCRIPT_TABLE_CANDIDATES 同源,只用于列表查询降级) +_RULE_TABLE_CANDIDATES = ('pbl_script_rule',) + + +# --------------------------------------------------------------------------- +# 内部工具 +# --------------------------------------------------------------------------- + +def _ok(data, **extra): + out = {'status': 'ok', 'data': data} + out.update(extra) + return out + + +def _fail(code, message, detail=None): + out = {'status': 'error', 'code': code, 'message': message, 'data': None} + if detail is not None: + out['detail'] = detail + return out + + +def _require_tenant(): + tid = str(_ctx_tenant_id() or '').strip() + if not tid: + raise CompileError('E_TENANT_REQUIRED', '缺少租户上下文(tenant_id),拒绝执行规则导出') + return tid + + +def _issue_dicts(issues, limit=200): + out = [] + for it in list(issues or [])[:limit]: + out.append({k: it.get(k) for k in ('severity', 'code', 'message', 'rule_id', 'path')}) + return out + + +async def _load_snapshot(blueprint_id, version_no): + """取蓝图版本快照(复用 api._snapshot,失败降级为直接查库)。""" + tid = _require_tenant() + try: + from pbl_compiler.api import _snapshot + snap, vrow = await _snapshot(tid, blueprint_id, version_no) + if snap: + return snap, vrow + except Exception: + pass + from pbl_compiler.api import _one + vrow = await _one( + 'SELECT * FROM `pbl_blueprint_version` WHERE `tenant_id` = ${t}$' + ' AND `blueprint_id` = ${b}$ AND `version_no` = ${v}$ LIMIT 1', + {'t': tid, 'b': blueprint_id, 'v': version_no}) + if not vrow: + raise CompileError('E_SNAPSHOT_NOT_FOUND', + '未找到蓝图版本快照: blueprint_id=%s version_no=%s' + % (blueprint_id, version_no)) + for key in ('snapshot_json', 'content_json', 'snapshot'): + if vrow.get(key): + import json as _json + raw = vrow[key] + return (_json.loads(raw) if isinstance(raw, str) else raw), vrow + raise CompileError('E_SNAPSHOT_EMPTY', '蓝图版本快照字段为空,无法映射规则') + + +async def _map_rules(blueprint_id, version_no, kw): + """快照 → 规则(含校验),返回 (rules, issues, manifest, snapshot, vrow)。""" + snapshot, vrow = await _load_snapshot(blueprint_id, version_no) + ctx = { + 'tenant_id': _require_tenant(), + 'blueprint_id': str(blueprint_id), + 'blueprint_version': str(version_no or (vrow or {}).get('version_no') or ''), + 'strict': bool(kw.get('strict', True)), + 'known_actions': kw.get('known_actions') or None, + 'event_types': kw.get('event_types') or None, + 'compiler_version': str(kw.get('compiler_version') or ''), + } + issues = [] + rules, map_issues = build_rules(snapshot, ctx) + issues.extend(map_issues) + issues.extend(validate_rules(rules)) + manifest = build_rules_manifest(rules) + return rules, issues, manifest, snapshot, ctx + + +# --------------------------------------------------------------------------- +# 契约 1:导出规则(写 script_engine 脚本表 + 写 game_definition) +# --------------------------------------------------------------------------- + +async def export_rules_contract(**kw): + """M3b 主导出契约:ECR → 规则 → 脚本表 → pbl_game_definition 规则列。 + + 入参:blueprint_id*, version_no*, compiler_version_id, dry_run, skip_gd, + skip_scripts, base_gd(可选 M3a GD), status, duration_ms + fail-closed:任一 error 级 issue → 不落库,返回 status=error + issues。 + """ + t0 = time.time() + kw = dict(kw or {}) + blueprint_id = str(kw.get('blueprint_id') or '').strip() + if not blueprint_id: + return _fail('PBL_E_PARAM', 'blueprint_id 必填') + version_no = kw.get('version_no') or kw.get('blueprint_version_no') or 1 + dry_run = bool(kw.get('dry_run')) + + try: + tid = _require_tenant() + rules, issues, manifest, snapshot, ctx = await _map_rules(blueprint_id, version_no, kw) + if has_errors(issues): + errs = [i for i in issues if i['severity'] == 'error'] + return _fail('E_RULE_INVALID', + '规则映射校验未通过(%d 个错误),拒绝导出' % len(errs), + {'issues': _issue_dicts(errs), 'rule_count': len(rules)}) + + if dry_run: + return _ok({ + 'dry_run': True, + 'rule_count': len(rules), + 'rules_hash': manifest.get('rules_hash'), + 'rules_json': canonical_rules_json(rules), + 'rules_manifest': manifest, + 'issues': _issue_dicts(issues), + }) + + result = { + 'dry_run': False, + 'rule_count': len(rules), + 'rules_hash': manifest.get('rules_hash'), + 'rules_manifest': manifest, + 'issues': _issue_dicts(issues), + } + + async with exporter.open_sor(None, 'pbl_compiler') as sor: + # ② 导出到 script_engine 脚本表(幂等 UPSERT) + if not kw.get('skip_scripts'): + export_result = await exporter.export_rules(sor, rules, ctx, issues) + result['export'] = {k: v for k, v in (export_result or {}).items() + if k != 'issues'} + # ③ 写 game_definition(规则四件套 + GD 正文,列名对齐 models) + if not kw.get('skip_gd'): + base_gd = kw.get('base_gd') + if isinstance(base_gd, str): + import json as _json + base_gd = _json.loads(base_gd) + gd, gd_text, gd_hash = exporter.build_game_definition( + rules, ctx, base_gd, result.get('export')) + ctx['compiler_version_id'] = str(kw.get('compiler_version_id') or '') + ctx['compile_status'] = str(kw.get('status') or 'compiled') + ctx['quality_state'] = str(kw.get('quality_state') or '') + ctx['world_id'] = str(kw.get('world_id') or '') + ctx['scene_id'] = str(kw.get('scene_id') or '') + ctx['duration_ms'] = int((time.time() - t0) * 1000) + row, row_issues = build_gd_row(gd, ctx, rules, manifest, + base_definition_json=gd_text, issues=issues) + result['content_fingerprint'] = row['content_fingerprint'] + result['gd_row'] = {k: row[k] for k in + ('id', 'blueprint_id', 'blueprint_version_no', + 'rule_count', 'rules_hash', 'compile_status')} + if has_errors(row_issues): + return _fail('E_GD_ROW_INVALID', 'GD 行组装校验未通过,拒绝写入', + {'issues': _issue_dicts( + [i for i in row_issues if i['severity'] == 'error'])}) + result['game_definition'] = await write_gd_rules(sor, row, ctx) + result['duration_ms'] = int((time.time() - t0) * 1000) + result['tenant_id'] = tid + result['blueprint_id'] = blueprint_id + result['blueprint_version'] = str(version_no) + return _ok(result) + except CompileError as e: + return _fail(getattr(e, 'code', 'E_RULE_EXPORT'), str(e), + getattr(e, 'detail', None)) + except PblError as e: + return _fail(getattr(e, 'code', 'PBL_E_COMPILE'), str(e)) + except Exception as e: # pragma: no cover - 兜底,绝不裸异常给前端 + return _fail('E_RULE_EXPORT_UNEXPECTED', '%s: %s' % (type(e).__name__, e)) + + +# --------------------------------------------------------------------------- +# 契约 2:规则映射预览(零写入) +# --------------------------------------------------------------------------- + +async def preview_rules_contract(**kw): + """只跑映射 + 校验,不碰任何表(第12章映射逻辑的单步验收入口)。""" + kw = dict(kw or {}) + kw['dry_run'] = True + return await export_rules_contract(**kw) + + +# --------------------------------------------------------------------------- +# 契约 3:两个蓝图版本之间的规则差异 +# --------------------------------------------------------------------------- + +async def rules_diff_contract(**kw): + """from_blueprint_id/from_version_no → to_*:diff_rules 产出新增/删除/变更。""" + kw = dict(kw or {}) + bp_a = str(kw.get('blueprint_id') or kw.get('from_blueprint_id') or '').strip() + bp_b = str(kw.get('to_blueprint_id') or bp_a).strip() + ver_a = kw.get('from_version_no') or kw.get('version_no') or 1 + ver_b = kw.get('to_version_no') or 1 + if not bp_a or not bp_b: + return _fail('PBL_E_PARAM', 'blueprint_id / to_blueprint_id 必填') + try: + _require_tenant() + rules_a, issues_a, man_a, _, _ = await _map_rules(bp_a, ver_a, kw) + rules_b, issues_b, man_b, _, _ = await _map_rules(bp_b, ver_b, kw) + diff = diff_rules(rules_a, rules_b) + return _ok({ + 'from': {'blueprint_id': bp_a, 'version_no': str(ver_a), + 'rule_count': man_a.get('rule_count'), 'rules_hash': man_a.get('rules_hash')}, + 'to': {'blueprint_id': bp_b, 'version_no': str(ver_b), + 'rule_count': man_b.get('rule_count'), 'rules_hash': man_b.get('rules_hash')}, + 'changed': bool(man_a.get('rules_hash') != man_b.get('rules_hash')), + 'diff': diff, + 'issues': {'from': _issue_dicts(issues_a), 'to': _issue_dicts(issues_b)}, + }) + except CompileError as e: + return _fail(getattr(e, 'code', 'E_RULES_DIFF'), str(e)) + except Exception as e: # pragma: no cover + return _fail('E_RULES_DIFF_UNEXPECTED', '%s: %s' % (type(e).__name__, e)) + + +# --------------------------------------------------------------------------- +# 契约 4:读回 GD 的规则产物 +# --------------------------------------------------------------------------- + +async def gd_rules_get_contract(**kw): + """按 id 或 (blueprint_id[, version_no]) 读 pbl_game_definition 的规则列。""" + kw = dict(kw or {}) + try: + tid = _require_tenant() + gd_id = str(kw.get('id') or kw.get('game_def_id') or '').strip() + async with exporter.open_sor(None, 'pbl_compiler') as sor: + if gd_id: + row = await read_gd_rules_by_id(sor, tid, gd_id) + rows = [row] if row else [] + else: + bp = str(kw.get('blueprint_id') or '').strip() + if not bp: + return _fail('PBL_E_PARAM', 'id 或 blueprint_id 至少一个必填') + rows = await read_gd_rules(sor, tid, bp, kw.get('version_no') + or kw.get('blueprint_version_no')) + slim = [] + for r in rows: + if not r: + continue + slim.append({ + 'id': r.get('id'), + 'blueprint_id': r.get('blueprint_id'), + 'blueprint_version_no': r.get('blueprint_version_no'), + 'content_fingerprint': r.get('content_fingerprint'), + 'rules_hash': r.get('rules_hash'), + 'rule_count': r.get('rule_count'), + 'compile_status': r.get('compile_status'), + 'quality_state': r.get('quality_state'), + 'rules_manifest': r.get('rules_manifest_parsed'), + 'rules': r.get('rules') or [], + }) + return _ok({'table': GD_TABLE, 'total': len(slim), 'rows': slim}) + except CompileError as e: + return _fail(getattr(e, 'code', 'E_GD_READ'), str(e)) + except Exception as e: # pragma: no cover + return _fail('E_GD_READ_UNEXPECTED', '%s: %s' % (type(e).__name__, e)) + + +# --------------------------------------------------------------------------- +# 契约 5:已导出规则行分页查询 +# --------------------------------------------------------------------------- + +async def script_rule_list_contract(**kw): + """分页查 pbl_script_rule(M3b 导出产物行),tenant_id 强制打头。""" + kw = dict(kw or {}) + try: + tid = _require_tenant() + page = max(1, int(kw.get('page') or 1)) + rows_n = min(200, max(1, int(kw.get('rows') or kw.get('page_size') or 20))) + offset = (page - 1) * rows_n + where = ['`tenant_id` = ${t}$'] + params = {'t': tid, 'lim': rows_n, 'off': offset} + if kw.get('blueprint_id'): + where.append('`blueprint_id` = ${b}$') + params['b'] = str(kw.get('blueprint_id')) + if kw.get('game_def_id'): + where.append('`game_def_id` = ${g}$') + params['g'] = str(kw.get('game_def_id')) + if kw.get('rules_hash'): + where.append('`rules_hash` = ${h}$') + params['h'] = str(kw.get('rules_hash')) + if kw.get('event_type'): + where.append('`event_type` = ${e}$') + params['e'] = str(kw.get('event_type')) + wsql = ' AND '.join(where) + from pbl_compiler.api import _rows + total_rows = await _rows('SELECT COUNT(*) AS c FROM `%s` WHERE %s' + % (_RULE_TABLE_CANDIDATES[0], wsql), params) + total = int((total_rows[0].get('c') if total_rows else 0) or 0) + data = await _rows( + 'SELECT `id`,`rule_id`,`script_code`,`name`,`script_type`,`event_type`,' + '`priority`,`enabled`,`condition_count`,`response_count`,`content_hash`,' + '`rules_hash`,`sync_status`,`blueprint_id`,`blueprint_version`,' + '`game_def_id`,`compile_task_no`,`created_at` ' + 'FROM `%s` WHERE %s ORDER BY `priority` DESC, `rule_id` ASC' + ' LIMIT ${lim}$ OFFSET ${off}$' % (_RULE_TABLE_CANDIDATES[0], wsql), params) + return _ok({'table': _RULE_TABLE_CANDIDATES[0], 'page': page, 'rows': rows_n, + 'total': total, 'rows_data': list(data or [])}) + except CompileError as e: + return _fail(getattr(e, 'code', 'E_RULE_LIST'), str(e)) + except Exception as e: # pragma: no cover + return _fail('E_RULE_LIST_UNEXPECTED', '%s: %s' % (type(e).__name__, e)) + + +# --------------------------------------------------------------------------- +# 契约名(.dspy 直接调用的全局名) +# --------------------------------------------------------------------------- + +async def pbl_compiler_export_rules(**kw): + return await export_rules_contract(**kw) + + +async def pbl_compiler_rules_preview(**kw): + return await preview_rules_contract(**kw) + + +async def pbl_compiler_rules_diff(**kw): + return await rules_diff_contract(**kw) + + +async def pbl_game_definition_rules_get(**kw): + return await gd_rules_get_contract(**kw) + + +async def pbl_script_rule_list(**kw): + return await script_rule_list_contract(**kw)