/** * 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);