284 lines
14 KiB
Python
284 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""pbl_compiler.api —— PBL Compiler v1:Blueprint → Game Definition(第9章,M3a/M3b)。
|
||
|
||
29.6 确定性三要素(可复现证明 = content_fingerprint 相同):
|
||
1) 输入锁定:blueprint_id + blueprint_version_no(版本快照,不读可变主表);
|
||
2) 规则锁定:compiler_version_id(规则集哈希 rules_hash);
|
||
3) 无副作用:不依赖随机数/时间戳/网络;集合一律按稳定键排序后遍历。
|
||
落库 world/scene/entity 走 pbl_domain_ext 契约(薄扩展,不改基表结构)。
|
||
"""
|
||
import hashlib
|
||
import json
|
||
|
||
from pbl_common.api import (PblError, actor_id, crud, json_dump, now_str, sql_exec,
|
||
sql_rows, sql_scalar, tenant_id)
|
||
|
||
COMPILER_VERSION = 'pbl-compiler-v1.0.0'
|
||
RULES_HASH_SEED = 'pbl.compiler.rules.v1'
|
||
GAME_DEF_SCHEMA = 'pbl.game_definition.v1'
|
||
|
||
|
||
def _loads(v, d=None):
|
||
if isinstance(v, (dict, list)):
|
||
return v
|
||
try:
|
||
return json.loads(v) if v else (d or {})
|
||
except ValueError:
|
||
return d or {}
|
||
|
||
|
||
def _canon(obj):
|
||
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(',', ':'))
|
||
|
||
|
||
def _sha(obj):
|
||
return hashlib.sha256(_canon(obj).encode('utf-8')).hexdigest()
|
||
|
||
|
||
async def _tid():
|
||
return await tenant_id()
|
||
|
||
|
||
async def _compiler_version(tid, requested=None):
|
||
if requested:
|
||
rows = await sql_rows('SELECT * FROM `pbl_compiler_version` WHERE `tenant_id` = ${t}$'
|
||
' AND (`id` = ${r}$ OR `semver` = ${r2}$) LIMIT 1',
|
||
{'t': tid, 'r': requested, 'r2': requested}, 'pbl')
|
||
if rows:
|
||
return rows[0]
|
||
rows = await sql_rows('SELECT * FROM `pbl_compiler_version` WHERE `tenant_id` = ${t}$'
|
||
' AND `enabled` = 1 ORDER BY `id` ASC LIMIT 1', {'t': tid}, 'pbl')
|
||
if rows:
|
||
return rows[0]
|
||
cv = {'id': None, 'semver': COMPILER_VERSION, 'code': 'PBL_COMPILER_V1',
|
||
'rules_hash': hashlib.sha256(RULES_HASH_SEED.encode()).hexdigest()[:16],
|
||
'entrypoint': 'pbl_compiler.compile'}
|
||
return cv
|
||
|
||
|
||
async def _snapshot(tid, bp_id, version_no):
|
||
rows = await sql_rows('SELECT * FROM `pbl_blueprint_version` WHERE `tenant_id` = ${t}$'
|
||
' AND `blueprint_id` = ${b}$ AND `version_no` = ${v}$ LIMIT 1',
|
||
{'t': tid, 'b': bp_id, 'v': version_no}, 'pbl')
|
||
if rows:
|
||
return _loads(rows[0]['snapshot_json'])
|
||
bp = await sql_rows('SELECT * FROM `pbl_blueprint` WHERE `tenant_id` = ${t}$'
|
||
' AND `id` = ${id}$ LIMIT 1', {'t': tid, 'id': bp_id}, 'pbl')
|
||
if not bp:
|
||
raise PblError('PBL_NOT_FOUND', '蓝图不存在')
|
||
return _loads(bp[0]['content_json'])
|
||
|
||
|
||
async def compile_model(model, cv, bp_id, version_no):
|
||
"""纯函数式编译(无 DB、无时间、无随机)——确定性核心,可单测复算。"""
|
||
goals = sorted(_loads(model.get('learning_goals'), []), key=lambda x: _canon(x))
|
||
roles = sorted(_loads(model.get('roles'), []), key=lambda x: _canon(x))
|
||
missions = sorted(_loads(model.get('missions'), []), key=lambda x: _canon(x))
|
||
tasks = sorted(_loads(model.get('tasks'), []), key=lambda x: _canon(x))
|
||
artifacts = sorted(_loads(model.get('artifacts'), []), key=lambda x: _canon(x))
|
||
ev_specs = sorted(_loads(model.get('evidence_specs'), []), key=lambda x: _canon(x))
|
||
rules_hash = cv.get('rules_hash') or hashlib.sha256(RULES_HASH_SEED.encode()).hexdigest()[:16]
|
||
|
||
entities, events, unlocks = [], [], []
|
||
for m in missions:
|
||
mid = m.get('obj_uid') or m.get('code') or _sha(m)[:12]
|
||
entities.append({'entity_id': 'mission:%s' % mid, 'kind': 'mission',
|
||
'capability_key': 'pbl.mission.show',
|
||
'initial_state': {'completed': False, 'order': m.get('order_no', 0)},
|
||
'server_authoritative': True})
|
||
for t in [x for x in tasks if (x.get('mission_id') == m.get('id')
|
||
or x.get('mission_uid') == mid)]:
|
||
tid_ = t.get('obj_uid') or t.get('code') or _sha(t)[:12]
|
||
entities.append({'entity_id': 'task:%s' % tid_, 'kind': 'task',
|
||
'capability_key': 'pbl.task.track',
|
||
'initial_state': {'done': False, 'parent': mid},
|
||
'server_authoritative': True})
|
||
events.append({'event_type': 'task.completed', 'source': 'task:%s' % tid_,
|
||
'condition': {'all_of': ['task:%s.done == true' % tid_]},
|
||
'response': [{'op': 'emit', 'event': 'task.completed'}]})
|
||
unlocks.append({'from_event': 'mission.completed:%s' % mid, 'target': mid,
|
||
'effect': 'pbl.unlockMission'})
|
||
for r in roles:
|
||
rid = r.get('obj_uid') or r.get('code') or _sha(r)[:12]
|
||
entities.append({'entity_id': 'role:%s' % rid, 'kind': 'role',
|
||
'capability_key': 'pbl.role.assign',
|
||
'initial_state': {'assigned': False}, 'server_authoritative': True})
|
||
for a in artifacts:
|
||
aid = a.get('obj_uid') or a.get('code') or _sha(a)[:12]
|
||
entities.append({'entity_id': 'artifact:%s' % aid, 'kind': 'artifact_spec',
|
||
'capability_key': 'pbl.artifact.submit',
|
||
'initial_state': {'submitted': False}, 'server_authoritative': True})
|
||
for e in ev_specs:
|
||
eid = e.get('obj_uid') or _sha(e)[:12]
|
||
events.append({'event_type': 'evidence.required', 'source': 'evidence_spec:%s' % eid,
|
||
'condition': {'min_count': e.get('min_count', 1)},
|
||
'response': [{'op': 'collect', 'evidence_type':
|
||
e.get('evidence_type') or 'artifact'}]})
|
||
|
||
definition = {
|
||
'schema': GAME_DEF_SCHEMA,
|
||
'meta': {'source_blueprint_id': bp_id, 'source_blueprint_version': version_no,
|
||
'compiler_version': cv.get('semver') or COMPILER_VERSION,
|
||
'compiler_rules_hash': rules_hash, 'deterministic': True},
|
||
'world': {'mode': 'shared', 'authority': 'server', 'max_members': 8,
|
||
'heartbeat_seconds': 15, 'poll_fallback_seconds': 3},
|
||
'learning_goals': goals,
|
||
'entities': sorted(entities, key=lambda x: x['entity_id']),
|
||
'events': sorted(events, key=lambda x: _canon(x)),
|
||
'unlock_graph': sorted(unlocks, key=lambda x: _canon(x)),
|
||
'script_bindings': [{'event': ev['event_type'], 'script_type': 1,
|
||
'rule_json': _canon(ev)}
|
||
for ev in sorted(events, key=lambda x: _canon(x))],
|
||
'capabilities': sorted({e['capability_key'] for e in entities}),
|
||
}
|
||
fp = _sha(definition)
|
||
definition['meta']['content_fingerprint'] = fp
|
||
return definition, fp
|
||
|
||
|
||
async def pbl_compiler_compile(**kw):
|
||
import time
|
||
t0 = time.time()
|
||
tid = await _tid()
|
||
bp_id = kw.get('blueprint_id')
|
||
bp = await sql_rows('SELECT * FROM `pbl_blueprint` WHERE `tenant_id` = ${t}$'
|
||
' AND `id` = ${id}$ LIMIT 1', {'t': tid, 'id': bp_id}, 'pbl')
|
||
if not bp:
|
||
raise PblError('PBL_NOT_FOUND', '蓝图不存在')
|
||
bp = bp[0]
|
||
min_state = kw.get('min_quality_state') or 'pbl_ready'
|
||
if bp['quality_state'] not in LADDER_AT_OR_ABOVE(min_state):
|
||
raise PblError('PBL_COMPILE_GATE_FAILED',
|
||
'quality_state=%s 未达 %s(第15章门禁)' % (bp['quality_state'], min_state))
|
||
version_no = int(kw.get('blueprint_version_no') or bp['version_no'])
|
||
cv = await _compiler_version(tid, kw.get('compiler_version_id'))
|
||
model = await _snapshot(tid, bp_id, version_no)
|
||
definition, fp = await compile_model(model, cv, bp_id, version_no)
|
||
|
||
dup = await sql_rows('SELECT * FROM `pbl_game_definition` WHERE `tenant_id` = ${t}$'
|
||
' AND `content_fingerprint` = ${f}$ LIMIT 1',
|
||
{'t': tid, 'f': fp}, 'pbl')
|
||
if dup and not kw.get('force_new'):
|
||
return {'ok': True, 'idempotent': True, 'id': dup[0]['id'],
|
||
'content_fingerprint': fp, 'compiler_version': cv.get('semver'),
|
||
'note': '同输入+同编译版本 → 同指纹(29.6 可复现)',
|
||
'latency_ms': int((time.time() - t0) * 1000)}
|
||
res = await crud('pbl_game_definition', 'pbl',
|
||
['blueprint_id', 'blueprint_version_no', 'compiler_version_id',
|
||
'content_fingerprint', 'definition_json', 'world_id', 'scene_id',
|
||
'entity_count', 'event_count', 'quality_state', 'duration_ms']
|
||
)['create'](
|
||
blueprint_id=bp_id, blueprint_version_no=version_no,
|
||
compiler_version_id=cv.get('id'), content_fingerprint=fp,
|
||
definition_json=json_dump(definition), entity_count=len(definition['entities']),
|
||
event_count=len(definition['events']), quality_state=bp['quality_state'],
|
||
duration_ms=int((time.time() - t0) * 1000))
|
||
if cv.get('id') is None:
|
||
cvr = await crud('pbl_compiler_version', 'pbl',
|
||
['code', 'semver', 'rules_hash', 'entrypoint', 'enabled', 'notes']
|
||
)['create'](code=cv['code'], semver=cv['semver'],
|
||
rules_hash=cv['rules_hash'], entrypoint=cv['entrypoint'],
|
||
enabled=1, notes='自动登记(编译时确保版本可追溯)')
|
||
await sql_exec('UPDATE `pbl_game_definition` SET `compiler_version_id` = ${c}$'
|
||
' WHERE `id` = ${id}$ AND `tenant_id` = ${t}$',
|
||
{'c': cvr['id'], 'id': res['id'], 't': tid}, 'pbl')
|
||
materialized = None
|
||
if kw.get('materialize'):
|
||
from pbl_domain_ext.api import pbl_domain_materialize_game_definition
|
||
materialized = await pbl_domain_materialize_game_definition(id=res['id'])
|
||
await sql_exec('UPDATE `pbl_game_definition` SET `world_id` = ${w}$, `scene_id` = ${s}$'
|
||
' WHERE `id` = ${id}$ AND `tenant_id` = ${t}$',
|
||
{'w': materialized.get('world_id'), 's': materialized.get('scene_id'),
|
||
'id': res['id'], 't': tid}, 'pbl')
|
||
return {'ok': True, 'id': res['id'], 'content_fingerprint': fp,
|
||
'compiler_version': cv.get('semver') or COMPILER_VERSION,
|
||
'entities': len(definition['entities']), 'events': len(definition['events']),
|
||
'materialized': materialized, 'budget_ms': 5000,
|
||
'latency_ms': int((time.time() - t0) * 1000)}
|
||
|
||
|
||
def LADDER_AT_OR_ABOVE(state):
|
||
order = ['draft', 'needs_work', 'pbl_ready', 'playtest_ready', 'publish_ready']
|
||
i = order.index(state) if state in order else 0
|
||
return order[i:]
|
||
|
||
|
||
async def pbl_compiler_preview(**kw):
|
||
"""干跑(不落库),返回定义 + 指纹,供教师预览与 diff。"""
|
||
tid = await _tid()
|
||
bp = await sql_rows('SELECT * FROM `pbl_blueprint` WHERE `tenant_id` = ${t}$'
|
||
' AND `id` = ${id}$ LIMIT 1', {'t': tid, 'id': kw.get('blueprint_id')},
|
||
'pbl')
|
||
if not bp:
|
||
raise PblError('PBL_NOT_FOUND', '蓝图不存在')
|
||
cv = await _compiler_version(tid, kw.get('compiler_version_id'))
|
||
model = await _snapshot(tid, bp[0]['id'], int(kw.get('blueprint_version_no')
|
||
or bp[0]['version_no']))
|
||
definition, fp = await compile_model(model, cv, bp[0]['id'],
|
||
int(kw.get('blueprint_version_no') or bp[0]['version_no']))
|
||
return {'ok': True, 'preview': True, 'content_fingerprint': fp,
|
||
'definition': definition, 'would_write_rows': 1}
|
||
|
||
|
||
async def pbl_compiler_compare(**kw):
|
||
"""两版定义结构化 diff(确定性验证:同输入两次编译指纹必须一致)。"""
|
||
tid = await _tid()
|
||
ids = [kw.get('a'), kw.get('b')]
|
||
rows = await sql_rows('SELECT * FROM `pbl_game_definition` WHERE `tenant_id` = ${t}$'
|
||
' AND `id` IN (${a}$,${b}$)', {'t': tid, 'a': ids[0], 'b': ids[1]}, 'pbl')
|
||
if len(rows) < 2:
|
||
raise PblError('PBL_GD_NOT_FOUND', '需两个已编译的 Game Definition')
|
||
a, b = sorted(rows, key=lambda r: r['id'])
|
||
da, db = _loads(a['definition_json']), _loads(b['definition_json'])
|
||
diff = {'added': [], 'removed': [], 'changed': []}
|
||
ka = {x['entity_id'] for x in da.get('entities', [])}
|
||
kb = {x['entity_id'] for x in db.get('entities', [])}
|
||
diff['added'], diff['removed'] = sorted(kb - ka), sorted(ka - kb)
|
||
for x in da.get('entities', []):
|
||
y = next((z for z in db.get('entities', []) if z['entity_id'] == x['entity_id']), None)
|
||
if y and _canon(x) != _canon(y):
|
||
diff['changed'].append(x['entity_id'])
|
||
return {'ok': True, 'a': {'id': a['id'], 'fp': a['content_fingerprint']},
|
||
'b': {'id': b['id'], 'fp': b['content_fingerprint']},
|
||
'identical': a['content_fingerprint'] == b['content_fingerprint'], 'diff': diff}
|
||
|
||
|
||
async def pbl_compiler_version_list(**kw):
|
||
return await crud('pbl_compiler_version', 'pbl',
|
||
['code', 'semver', 'rules_hash', 'entrypoint', 'enabled', 'notes']
|
||
)['list'](**kw)
|
||
|
||
|
||
async def pbl_compiler_version_save(**kw):
|
||
sc = crud('pbl_compiler_version', 'pbl',
|
||
['code', 'semver', 'rules_hash', 'entrypoint', 'enabled', 'notes'])
|
||
payload = dict(kw)
|
||
payload.setdefault('rules_hash',
|
||
hashlib.sha256((RULES_HASH_SEED + str(kw.get('semver', ''))).encode())
|
||
.hexdigest()[:16])
|
||
if kw.get('id'):
|
||
return await sc['update'](**payload)
|
||
return await sc['create'](**payload)
|
||
|
||
|
||
async def pbl_capability_list(**kw):
|
||
return await crud('pbl_capability_registry', 'pbl',
|
||
['capability_key', 'category', 'args_schema_json', 'permission_required',
|
||
'is_enabled', 'version_no', 'description'])['list'](**kw)
|
||
|
||
|
||
async def pbl_capability_register(**kw):
|
||
key = kw.get('capability_key')
|
||
if not key:
|
||
raise PblError('PBL_PARAM_MISSING', '缺少 capability_key')
|
||
sc = crud('pbl_capability_registry', 'pbl',
|
||
['capability_key', 'category', 'args_schema_json', 'permission_required',
|
||
'is_enabled', 'version_no', 'description'])
|
||
payload = dict(kw)
|
||
payload['args_schema_json'] = json_dump(kw.get('args_schema_json'))
|
||
payload.setdefault('version_no', 1)
|
||
r = await sc['upsert'](['capability_key', 'version_no'], **payload)
|
||
r['capability_key'] = key
|
||
return r
|