267 lines
12 KiB
JavaScript
267 lines
12 KiB
JavaScript
/**
|
||
* pbl_overlay_transport.js —— 覆盖层传输层(M9 / pbl_scense_ext)
|
||
*
|
||
* 职责:① 契约调用(bootstrap / 意图上行 / 证据 / 反馈);② 广播通道(SSE 优先,长轮询降级,
|
||
* Q-OPEN-6 定稿);③ seq 幂等去重 + 掉线补齐(get_state_snapshot + poll_events(since));
|
||
* ④ 广播渲染时延预算 ≤500ms 的计时与降级。
|
||
*
|
||
* 纪律:本层只搬运数据并派发 pbl:broadcast-received 事件,DOM 更新由 pbl_overlay.js 负责,
|
||
* 不触碰 scense/scense_game 渲染主循环。
|
||
*/
|
||
(function (global) {
|
||
'use strict';
|
||
|
||
var Core = global.PblOverlayCore;
|
||
if (!Core) { throw new Error('pbl_overlay_core.js 必须先于 pbl_overlay_transport.js 加载'); }
|
||
|
||
var BUDGETS = Core.BUDGETS;
|
||
|
||
// 契约端点表(模块自动路由 /{module}/api/{name}.dspy)
|
||
var ENDPOINTS = {
|
||
'bootstrap': '/pbl_scense_ext/api/pbl_overlay_bootstrap.dspy',
|
||
'page_registry': '/pbl_scense_ext/api/pbl_page_registry_list.dspy',
|
||
'session_ctx': '/pbl_scense_ext/api/pbl_session_context_get.dspy',
|
||
'feedback_save': '/pbl_scense_ext/api/pbl_playtest_feedback_save.dspy',
|
||
// 运行时(pbl_runtime_ext,M11a/M11b 权威实现)
|
||
'event_poll': '/pbl_runtime_ext/api/pbl_runtime_event_poll.dspy',
|
||
'broadcast_pull': '/pbl_runtime_ext/api/pbl_runtime_broadcast_pull.dspy',
|
||
'entity_state': '/pbl_runtime_ext/api/pbl_entity_state_get.dspy',
|
||
'world_broadcast':'/pbl_runtime_ext/api/pbl_world_broadcast.dspy',
|
||
// 意图上行 / 证据 / 评估(其余 pbl_* 模块契约)
|
||
'submit_intent': '/pbl_runtime_ext/api/pbl_world_broadcast.dspy',
|
||
'entity_apply': '/pbl_runtime_ext/api/pbl_entity_state_apply.dspy',
|
||
'evidence_collect': '/pbl_evidence/api/pbl_collect_evidence.dspy',
|
||
'artifact_create': '/pbl_evidence/api/pbl_create_artifact.dspy',
|
||
'artifact_submit': '/pbl_evidence/api/pbl_submit_artifact.dspy',
|
||
'assessment_report': '/pbl_assessment/api/pbl_assessment_report.dspy'
|
||
};
|
||
|
||
function apiBase() {
|
||
// 去掉页面路由尾部,得到应用根(与 scense_game game.js 同策略,不改基线)
|
||
return (global.__PBL_API_BASE__ || String(location.pathname).replace(/\/(pbl_overlay|game|scense_game)\/.*$/, '')).replace(/\/$/, '');
|
||
}
|
||
|
||
function Transport(opts) {
|
||
opts = opts || {};
|
||
this.sessionId = opts.session_id || null;
|
||
this.blueprintId = opts.blueprint_id || null;
|
||
this.lastSeq = Number(opts.since_seq || 0);
|
||
this.pending = {}; // seq -> true(去重)
|
||
this.sse = null;
|
||
this.timer = null;
|
||
this.stopped = false;
|
||
this.cache = {}; // 断网时供降级态展示缓存数据
|
||
this.onEvent = opts.onEvent || function () {};
|
||
this.onDegrade = opts.onDegrade || function () {};
|
||
this.onPerf = opts.onPerf || function () {};
|
||
}
|
||
|
||
/* ───────────── 1. 契约调用(POST,参数进 params_kw) ───────────── */
|
||
Transport.prototype.call = function (name, params) {
|
||
var url = ENDPOINTS[name];
|
||
if (!url) { return Promise.reject({ code: 'PBL_E_NOT_FOUND', message: '未知契约 ' + name }); }
|
||
var body = Object.assign({}, params || {});
|
||
if (this.sessionId && body.session_id === undefined) { body.session_id = this.sessionId; }
|
||
if (this.blueprintId && body.blueprint_id === undefined) { body.blueprint_id = this.blueprintId; }
|
||
var ctrl = ('AbortController' in global) ? new AbortController() : null;
|
||
var to = ctrl ? setTimeout(function () { ctrl.abort(); }, BUDGETS.intent_timeout_ms) : null;
|
||
var self = this;
|
||
return fetch(apiBase() + url, {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||
body: JSON.stringify(body),
|
||
signal: ctrl ? ctrl.signal : undefined
|
||
}).then(function (r) {
|
||
if (to) { clearTimeout(to); }
|
||
if (r.status === 401 || r.status === 403) { return { status: 'fail', code: 'PBL_E_FORBIDDEN', message: 'HTTP ' + r.status }; }
|
||
if (r.status === 429) { return { status: 'fail', code: 'PBL_RATE_LIMIT', message: '请求过于频繁' }; }
|
||
if (!r.ok) { return { status: 'fail', code: 'PBL_E_NETWORK', message: 'HTTP ' + r.status }; }
|
||
return r.json();
|
||
}).then(function (res) {
|
||
if (res && res.status === 'fail') {
|
||
self.onDegrade(res.code || 'PBL_E_NETWORK', res.message || res.msg || '');
|
||
var e = new Error(res.message || 'contract fail');
|
||
e.code = res.code; e.payload = res;
|
||
throw e;
|
||
}
|
||
self.cache[name] = { at: Date.now(), data: res };
|
||
return res;
|
||
}).catch(function (err) {
|
||
if (err && err.name === 'AbortError') { self.onDegrade('PBL_E_TIMEOUT', '契约超时 ' + name); }
|
||
else if (err && !err.code) { self.onDegrade('PBL_E_NETWORK', '网络中断'); }
|
||
throw err;
|
||
});
|
||
};
|
||
|
||
/* ───────────── 2. S1 进入世界:一次 bootstrap 拿齐上下文 ───────────── */
|
||
Transport.prototype.bootstrap = function (extra) {
|
||
var self = this;
|
||
return this.call('bootstrap', Object.assign({ recent_events: 20 }, extra || {})).then(function (ctx) {
|
||
var d = (ctx && ctx.data) || ctx || {};
|
||
self.context = d;
|
||
if (Array.isArray(d.recent_events)) {
|
||
d.recent_events.forEach(function (ev) { self._accept(ev, true); });
|
||
}
|
||
if (d.max_seq !== undefined) { self.lastSeq = Math.max(self.lastSeq, Number(d.max_seq) || 0); }
|
||
return d;
|
||
});
|
||
};
|
||
|
||
Transport.prototype.pageRegistry = function () {
|
||
return this.call('page_registry', { page_size: 50 }).then(function (r) {
|
||
var d = (r && r.data) || r || {};
|
||
return d.rows || d.data || d || [];
|
||
});
|
||
};
|
||
|
||
/* ───────────── 3. S2 意图上行(前端无权威状态,仅发起) ───────────── */
|
||
Transport.prototype.submitIntent = function (intent) {
|
||
var payload = {
|
||
intent_type: intent.type || 'action',
|
||
action: intent.action || '',
|
||
entity_id: intent.entity_id || '',
|
||
payload_json: JSON.stringify(intent.payload || {}),
|
||
client_ts: Date.now()
|
||
};
|
||
var self = this;
|
||
Core.Bus.emit('pbl:intent-submitted', payload);
|
||
return this.call(intent.channel || 'submit_intent', payload).then(function (r) {
|
||
var d = (r && r.data) || r || {};
|
||
// 服务端权威:只有回传 seq 后才允许更新覆盖层(不预测渲染)
|
||
if (d.seq !== undefined) { self._accept({ seq: d.seq, event_type: intent.action || 'ack', payload_json: d }, true); }
|
||
return d;
|
||
});
|
||
};
|
||
|
||
/* ───────────── 4. 广播通道:SSE 优先,长轮询降级 ───────────── */
|
||
Transport.prototype.subscribe = function () {
|
||
var self = this;
|
||
if (this.stopped) { return; }
|
||
var sseUrl = apiBase() + '/pbl_runtime_ext/api/pbl_runtime_broadcast_pull.dspy?session_id=' +
|
||
encodeURIComponent(this.sessionId || '') + '&since_seq=' + this.lastSeq;
|
||
var ok = false;
|
||
if (typeof global.EventSource === 'function' && !this.sse) {
|
||
try {
|
||
this.sse = new global.EventSource(sseUrl);
|
||
this.sse.onmessage = function (m) {
|
||
var ev = null;
|
||
try { ev = JSON.parse(m.data); } catch (e) { ev = null; }
|
||
if (ev) { self._accept(ev); }
|
||
};
|
||
this.sse.onerror = function () {
|
||
self.closeSse();
|
||
self.onDegrade('PBL_RENDER_DEGRADED', '实时通道降级为长轮询');
|
||
self.poll();
|
||
};
|
||
ok = true;
|
||
} catch (e) { ok = false; }
|
||
}
|
||
if (!ok) { this.poll(); }
|
||
};
|
||
|
||
Transport.prototype.closeSse = function () {
|
||
if (this.sse) { try { this.sse.close(); } catch (e) { } this.sse = null; }
|
||
};
|
||
|
||
/** 长轮询降级:固定节拍拉取增量(seq 单调,去重幂等) */
|
||
Transport.prototype.poll = function () {
|
||
var self = this;
|
||
if (this.stopped) { return; }
|
||
if (this.timer) { clearInterval(this.timer); }
|
||
this.timer = setInterval(function () { self.pollOnce(); }, BUDGETS.poll_interval_ms);
|
||
this.pollOnce();
|
||
};
|
||
|
||
Transport.prototype.pollOnce = function () {
|
||
var self = this;
|
||
return this.call('broadcast_pull', { since_seq: this.lastSeq, limit: 50 })
|
||
.then(function (r) {
|
||
var d = (r && r.data) || r || {};
|
||
var evs = d.events || d.rows || [];
|
||
for (var i = 0; i < evs.length; i++) { self._accept(evs[i]); }
|
||
if (d.max_seq !== undefined) { self.lastSeq = Math.max(self.lastSeq, Number(d.max_seq) || 0); }
|
||
})
|
||
.catch(function () { self.onDegrade('PBL_E_NETWORK', '广播拉取失败,使用缓存数据'); });
|
||
};
|
||
|
||
/** seq 去重 + 计时(广播到达 → 交给上层渲染,≤500ms 预算) */
|
||
Transport.prototype._accept = function (ev, silent) {
|
||
if (!ev) { return false; }
|
||
var seq = Number(ev.seq !== undefined ? ev.seq : (ev.event_seq || 0));
|
||
if (seq > 0) {
|
||
if (seq <= this.lastSeq || this.pending[seq]) { return false; }
|
||
this.pending[seq] = true;
|
||
this.lastSeq = Math.max(this.lastSeq, seq);
|
||
}
|
||
var payload = ev.payload_json;
|
||
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { payload = { raw: payload }; } }
|
||
var item = {
|
||
seq: seq,
|
||
event_type: ev.event_type || ev.type || 'unknown',
|
||
entity_id: ev.entity_id || (payload && payload.entity_id) || '',
|
||
payload: payload || {},
|
||
received_at: Core.now()
|
||
};
|
||
if (!silent) { Core.Bus.emit('pbl:broadcast-received', item); }
|
||
this.onEvent(item, !!silent);
|
||
return true;
|
||
};
|
||
|
||
/* ───────────── 5. 断线重连补齐(Q4:快照 + since 补发) ───────────── */
|
||
Transport.prototype.reconnect = function () {
|
||
var self = this;
|
||
return this.call('entity_state', { session_id: this.sessionId })
|
||
.then(function (r) {
|
||
var d = (r && r.data) || r || {};
|
||
self.snapshot = d;
|
||
Core.Bus.emit('pbl:snapshot-applied', d);
|
||
return self.call('event_poll', { since_event_id: self.lastSeq, limit: 100 });
|
||
})
|
||
.then(function (r) {
|
||
var d = (r && r.data) || r || {};
|
||
(d.events || d.rows || []).forEach(function (ev) { self._accept(ev); });
|
||
self.subscribe();
|
||
return true;
|
||
})
|
||
.catch(function () { self.onDegrade('PBL_E_NETWORK', '重连失败,保持只读缓存'); return false; });
|
||
};
|
||
|
||
/* ───────────── 6. S3/S4 产出物、证据、反馈 ───────────── */
|
||
Transport.prototype.createArtifact = function (a) {
|
||
return this.call('artifact_create', {
|
||
title: a.title || '', artifact_type: a.artifact_type || 'document',
|
||
content_txt: a.content_txt || '', session_id: this.sessionId
|
||
});
|
||
};
|
||
Transport.prototype.submitArtifact = function (artifactId) {
|
||
return this.call('artifact_submit', { artifact_id: artifactId, session_id: this.sessionId });
|
||
};
|
||
Transport.prototype.collectEvidence = function (e) {
|
||
return this.call('evidence_collect', {
|
||
evidence_type: e.evidence_type || 'artifact_version',
|
||
source_id: e.source_id || '', session_id: this.sessionId
|
||
});
|
||
};
|
||
Transport.prototype.saveFeedback = function (f) {
|
||
return this.call('feedback_save', {
|
||
feedback_type: f.feedback_type || 'feedback',
|
||
rating: f.rating === undefined ? '' : f.rating,
|
||
content_txt: f.content_txt || '',
|
||
context_json: f.context_json || {}
|
||
});
|
||
};
|
||
|
||
Transport.prototype.stop = function () {
|
||
this.stopped = true;
|
||
this.closeSse();
|
||
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||
};
|
||
|
||
global.PblOverlayTransport = {
|
||
ENDPOINTS: ENDPOINTS,
|
||
apiBase: apiBase,
|
||
Transport: Transport
|
||
};
|
||
})(window);
|