GET/dev/config/local-files
列出本地配置目录中的 JSON 文件,作为 source config 导入入口。
@@ -2257,6 +2499,159 @@ const devWorkbenchHTML = `
$('flow-regression-overall').textContent = overall;
}
+ function resetLaunchConfigSummary() {
+ $('launch-config-url').textContent = '-';
+ $('launch-config-release-id').textContent = '-';
+ $('launch-config-manifest-url').textContent = '-';
+ $('launch-config-schema-version').textContent = '-';
+ $('launch-config-playfield-kind').textContent = '-';
+ $('launch-config-game-mode').textContent = '-';
+ $('launch-config-verdict').textContent = '待执行';
+ }
+
+ async function resolveLaunchConfigSummary(launchPayload) {
+ const launchData = launchPayload && launchPayload.launch ? launchPayload.launch : {};
+ const config = launchData.config || {};
+ const resolvedRelease = launchData.resolvedRelease || {};
+ const configUrl = config.configUrl || '-';
+ const releaseId = config.releaseId || resolvedRelease.releaseId || '-';
+ const manifestUrl = resolvedRelease.manifestUrl || config.configUrl || '-';
+ const summary = {
+ configUrl: configUrl,
+ releaseId: releaseId,
+ manifestUrl: manifestUrl,
+ schemaVersion: '-',
+ playfieldKind: '-',
+ gameMode: '-',
+ verdict: '未读取 manifest'
+ };
+ const targetUrl = config.configUrl || resolvedRelease.manifestUrl;
+ if (!targetUrl) {
+ summary.verdict = '未通过:launch 未返回 configUrl / manifestUrl';
+ return summary;
+ }
+ try {
+ const proxy = await request('GET', '/dev/manifest-summary?url=' + encodeURIComponent(targetUrl));
+ const data = proxy && proxy.data ? proxy.data : {};
+ summary.schemaVersion = data.schemaVersion || '-';
+ summary.playfieldKind = data.playfieldKind || '-';
+ summary.gameMode = data.gameMode || '-';
+ if (summary.schemaVersion !== '-' && summary.playfieldKind !== '-' && summary.gameMode !== '-') {
+ summary.verdict = '通过:已读取 launch 实际 manifest 摘要';
+ } else {
+ summary.verdict = '未通过:manifest 已读取,但关键信息不完整';
+ }
+ return summary;
+ } catch (error) {
+ summary.verdict = '未通过:manifest 读取异常';
+ summary.error = error && error.message ? error.message : String(error);
+ return summary;
+ }
+ }
+
+ function setLaunchConfigSummary(summary) {
+ const data = summary || {};
+ $('launch-config-url').textContent = data.configUrl || '-';
+ $('launch-config-release-id').textContent = data.releaseId || '-';
+ $('launch-config-manifest-url').textContent = data.manifestUrl || '-';
+ $('launch-config-schema-version').textContent = data.schemaVersion || '-';
+ $('launch-config-playfield-kind').textContent = data.playfieldKind || '-';
+ $('launch-config-game-mode').textContent = data.gameMode || '-';
+ $('launch-config-verdict').textContent = data.verdict || '待执行';
+ }
+
+ function renderClientLogs(items) {
+ const logs = Array.isArray(items) ? items : [];
+ if (!logs.length) {
+ $('client-logs').textContent = '暂无前端调试日志';
+ return;
+ }
+ $('client-logs').textContent = logs.map(function(item) {
+ const lines = [];
+ lines.push('[' + (item.receivedAt || '-') + '] #' + (item.id || '-') + ' ' + (item.level || 'info').toUpperCase() + ' ' + (item.source || 'unknown'));
+ if (item.category) {
+ lines.push('category: ' + item.category);
+ }
+ lines.push('message: ' + (item.message || '-'));
+ if (item.eventId || item.releaseId || item.sessionId) {
+ lines.push('event/release/session: ' + (item.eventId || '-') + ' / ' + (item.releaseId || '-') + ' / ' + (item.sessionId || '-'));
+ }
+ if (item.manifestUrl) {
+ lines.push('manifest: ' + item.manifestUrl);
+ }
+ if (item.route) {
+ lines.push('route: ' + item.route);
+ }
+ if (item.details && Object.keys(item.details).length) {
+ lines.push('details: ' + JSON.stringify(item.details, null, 2));
+ }
+ return lines.join('\n');
+ }).join('\n\n---\n\n');
+ }
+
+ async function refreshClientLogs() {
+ const result = await request('GET', '/dev/client-logs?limit=50');
+ renderClientLogs(result.data);
+ return result;
+ }
+
+ function selectBootstrapContextForEvent(bootstrap, eventId) {
+ const data = bootstrap || {};
+ if (eventId && data.scoreOEventId && eventId === data.scoreOEventId) {
+ return {
+ eventId: data.scoreOEventId,
+ releaseId: data.scoreOReleaseId || '',
+ sourceId: data.scoreOSourceId || '',
+ buildId: data.scoreOBuildId || '',
+ courseSetId: data.scoreOCourseSetId || '',
+ courseVariantId: data.scoreOCourseVariantId || '',
+ runtimeBindingId: data.scoreORuntimeBindingId || ''
+ };
+ }
+ if (eventId && data.variantManualEventId && eventId === data.variantManualEventId) {
+ return {
+ eventId: data.variantManualEventId,
+ releaseId: data.variantManualReleaseId || '',
+ sourceId: data.sourceId || '',
+ buildId: data.buildId || '',
+ courseSetId: data.courseSetId || '',
+ courseVariantId: data.courseVariantId || '',
+ runtimeBindingId: data.runtimeBindingId || ''
+ };
+ }
+ return {
+ eventId: data.eventId || '',
+ releaseId: data.releaseId || '',
+ sourceId: data.sourceId || '',
+ buildId: data.buildId || '',
+ courseSetId: data.courseSetId || '',
+ courseVariantId: data.courseVariantId || '',
+ runtimeBindingId: data.runtimeBindingId || ''
+ };
+ }
+
+ function applyBootstrapContext(bootstrap, explicitEventId) {
+ const eventId = explicitEventId || $('event-id').value || $('admin-event-ref-id').value || '';
+ const selected = selectBootstrapContextForEvent(bootstrap, eventId);
+ state.sourceId = selected.sourceId || state.sourceId;
+ state.buildId = selected.buildId || state.buildId;
+ state.releaseId = selected.releaseId || state.releaseId;
+ $('admin-pipeline-source-id').value = selected.sourceId || $('admin-pipeline-source-id').value;
+ $('admin-pipeline-build-id').value = selected.buildId || $('admin-pipeline-build-id').value;
+ $('admin-pipeline-release-id').value = selected.releaseId || $('admin-pipeline-release-id').value;
+ $('event-release-id').value = selected.releaseId || $('event-release-id').value;
+ $('prod-runtime-event-id').value = selected.eventId || $('prod-runtime-event-id').value;
+ $('prod-place-id').value = bootstrap.placeId || $('prod-place-id').value;
+ $('prod-map-asset-id').value = bootstrap.mapAssetId || $('prod-map-asset-id').value;
+ $('prod-tile-release-id').value = bootstrap.tileReleaseId || $('prod-tile-release-id').value;
+ $('prod-course-source-id').value = bootstrap.courseSourceId || $('prod-course-source-id').value;
+ $('prod-course-set-id').value = selected.courseSetId || $('prod-course-set-id').value;
+ $('prod-course-variant-id').value = selected.courseVariantId || $('prod-course-variant-id').value;
+ $('prod-runtime-binding-id').value = selected.runtimeBindingId || $('prod-runtime-binding-id').value;
+ syncState();
+ return selected;
+ }
+
function extractList(payload) {
if (Array.isArray(payload)) {
return payload;
@@ -2306,20 +2701,7 @@ const devWorkbenchHTML = `
writeLog(flowTitle + '.step', { step: 'bootstrap-demo' });
const bootstrap = await request('POST', '/dev/bootstrap-demo');
if (bootstrap.data) {
- state.sourceId = bootstrap.data.sourceId || state.sourceId;
- state.buildId = bootstrap.data.buildId || state.buildId;
- state.releaseId = bootstrap.data.releaseId || state.releaseId;
- $('admin-pipeline-source-id').value = bootstrap.data.sourceId || $('admin-pipeline-source-id').value;
- $('admin-pipeline-build-id').value = bootstrap.data.buildId || $('admin-pipeline-build-id').value;
- $('admin-pipeline-release-id').value = bootstrap.data.releaseId || $('admin-pipeline-release-id').value;
- $('prod-runtime-event-id').value = bootstrap.data.eventId || $('prod-runtime-event-id').value;
- $('prod-place-id').value = bootstrap.data.placeId || $('prod-place-id').value;
- $('prod-map-asset-id').value = bootstrap.data.mapAssetId || $('prod-map-asset-id').value;
- $('prod-tile-release-id').value = bootstrap.data.tileReleaseId || $('prod-tile-release-id').value;
- $('prod-course-source-id').value = bootstrap.data.courseSourceId || $('prod-course-source-id').value;
- $('prod-course-set-id').value = bootstrap.data.courseSetId || $('prod-course-set-id').value;
- $('prod-course-variant-id').value = bootstrap.data.courseVariantId || $('prod-course-variant-id').value;
- $('prod-runtime-binding-id').value = bootstrap.data.runtimeBindingId || $('prod-runtime-binding-id').value;
+ applyBootstrapContext(bootstrap.data, eventId);
}
}
@@ -2336,6 +2718,9 @@ const devWorkbenchHTML = `
if (eventDetail.data.currentRuntime && eventDetail.data.currentRuntime.runtimeBindingId) {
$('admin-release-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId;
$('prod-runtime-binding-id').value = eventDetail.data.currentRuntime.runtimeBindingId;
+ } else {
+ $('admin-release-runtime-binding-id').value = '';
+ $('prod-runtime-binding-id').value = '';
}
}
@@ -2539,6 +2924,9 @@ const devWorkbenchHTML = `
launch.data.launch.resolvedRelease &&
launch.data.launch.resolvedRelease.manifestUrl
);
+ const launchConfigSummary = await resolveLaunchConfigSummary(launch.data);
+ setLaunchConfigSummary(launchConfigSummary);
+ writeLog(flowTitle + '.launch-summary', launchConfigSummary);
writeLog(flowTitle + '.step', {
step: 'session-start',
@@ -2618,6 +3006,41 @@ const devWorkbenchHTML = `
};
}
+ function applyFrontendDemoSelection(options) {
+ resetLaunchConfigSummary();
+ $('entry-channel-code').value = 'mini-demo';
+ $('entry-channel-type').value = 'wechat_mini';
+ $('event-id').value = options.eventId;
+ $('event-release-id').value = options.releaseId;
+ $('event-variant-id').value = options.variantId || '';
+ $('config-event-id').value = options.eventId;
+ $('admin-event-ref-id').value = options.eventId;
+ $('local-config-file').value = options.localConfigFile || $('local-config-file').value;
+ if (options.gameModeCode) {
+ $('admin-game-mode-code').value = options.gameModeCode;
+ $('prod-course-mode').value = options.gameModeCode;
+ }
+ $('prod-runtime-event-id').value = options.eventId;
+ $('prod-course-set-id').value = options.courseSetId || $('prod-course-set-id').value;
+ $('prod-course-variant-id').value = options.courseVariantId || $('prod-course-variant-id').value;
+ $('prod-runtime-binding-id').value = options.runtimeBindingId || '';
+ $('admin-release-runtime-binding-id').value = options.runtimeBindingId || '';
+ $('admin-pipeline-source-id').value = options.sourceId || '';
+ $('admin-pipeline-build-id').value = options.buildId || '';
+ state.sourceId = options.sourceId || '';
+ state.buildId = options.buildId || '';
+ state.releaseId = options.releaseId || state.releaseId;
+ localStorage.setItem(MODE_KEY, 'frontend');
+ syncWorkbenchMode();
+ writeLog(options.logTitle, {
+ eventId: $('event-id').value,
+ releaseId: $('event-release-id').value,
+ variantId: $('event-variant-id').value || null,
+ localConfigFile: $('local-config-file').value
+ });
+ setStatus(options.statusText);
+ }
+
function setStatus(text, isError = false) {
statusEl.textContent = text;
statusEl.className = isError ? 'status error' : 'status';
@@ -3485,6 +3908,10 @@ const devWorkbenchHTML = `
}, true);
state.sessionId = result.data.launch.business.sessionId;
state.sessionToken = result.data.launch.business.sessionToken;
+ syncState();
+ const configSummary = await resolveLaunchConfigSummary(result.data);
+ setLaunchConfigSummary(configSummary);
+ writeLog('event-launch.summary', configSummary);
return result;
});
@@ -4221,6 +4648,16 @@ const devWorkbenchHTML = `
setStatus('ok: history cleared');
};
+ $('btn-client-logs-refresh').onclick = () => run('dev/client-logs', async () => {
+ return await refreshClientLogs();
+ });
+
+ $('btn-client-logs-clear').onclick = () => run('dev/client-logs/clear', async () => {
+ const result = await request('DELETE', '/dev/client-logs');
+ renderClientLogs([]);
+ return result;
+ });
+
$('btn-scenario-save').onclick = saveCurrentScenario;
$('btn-scenario-load').onclick = loadSelectedScenario;
$('btn-scenario-delete').onclick = deleteSelectedScenario;
@@ -4228,7 +4665,10 @@ const devWorkbenchHTML = `
$('btn-scenario-import').onclick = importScenarioFromJSON;
$('btn-flow-home').onclick = () => run('flow-home', async () => {
- await request('POST', '/dev/bootstrap-demo');
+ const bootstrap = await request('POST', '/dev/bootstrap-demo');
+ if (bootstrap.data) {
+ applyBootstrapContext(bootstrap.data);
+ }
const login = await request('POST', '/auth/login/wechat-mini', {
code: $('wechat-code').value,
clientType: 'wechat',
@@ -4241,43 +4681,70 @@ const devWorkbenchHTML = `
$('btn-bootstrap').onclick = () => run('bootstrap-demo', async () => {
const result = await request('POST', '/dev/bootstrap-demo');
- state.sourceId = result.data.sourceId || '';
- state.buildId = result.data.buildId || '';
- state.releaseId = result.data.releaseId || state.releaseId || '';
- if (result.data.releaseId) {
- $('event-release-id').value = result.data.releaseId;
- }
- $('prod-runtime-event-id').value = result.data.eventId || $('prod-runtime-event-id').value;
- $('prod-place-id').value = result.data.placeId || $('prod-place-id').value;
- $('prod-map-asset-id').value = result.data.mapAssetId || $('prod-map-asset-id').value;
- $('prod-tile-release-id').value = result.data.tileReleaseId || $('prod-tile-release-id').value;
- $('prod-course-source-id').value = result.data.courseSourceId || $('prod-course-source-id').value;
- $('prod-course-set-id').value = result.data.courseSetId || $('prod-course-set-id').value;
- $('prod-course-variant-id').value = result.data.courseVariantId || $('prod-course-variant-id').value;
- $('prod-runtime-binding-id').value = result.data.runtimeBindingId || $('prod-runtime-binding-id').value;
+ applyBootstrapContext(result.data);
+ return result;
+ });
+
+ $('btn-use-classic-demo').onclick = () => run('use-classic-demo', async () => {
+ const result = await request('POST', '/dev/bootstrap-demo');
+ applyFrontendDemoSelection({
+ eventId: result.data.eventId || 'evt_demo_001',
+ releaseId: result.data.releaseId || 'rel_demo_001',
+ localConfigFile: 'classic-sequential.json',
+ gameModeCode: 'classic-sequential',
+ sourceId: result.data.sourceId || '',
+ buildId: result.data.buildId || '',
+ courseSetId: result.data.courseSetId || '',
+ courseVariantId: result.data.courseVariantId || '',
+ runtimeBindingId: result.data.runtimeBindingId || '',
+ logTitle: 'classic-demo-ready',
+ statusText: 'ok: classic demo loaded'
+ });
+ return result;
+ });
+
+ $('btn-use-score-o-demo').onclick = () => run('use-score-o-demo', async () => {
+ const result = await request('POST', '/dev/bootstrap-demo');
+ applyFrontendDemoSelection({
+ eventId: result.data.scoreOEventId || 'evt_demo_score_o_001',
+ releaseId: result.data.scoreOReleaseId || 'rel_demo_score_o_001',
+ localConfigFile: 'score-o.json',
+ gameModeCode: 'score-o',
+ sourceId: result.data.scoreOSourceId || '',
+ buildId: result.data.scoreOBuildId || '',
+ courseSetId: result.data.scoreOCourseSetId || '',
+ courseVariantId: result.data.scoreOCourseVariantId || '',
+ runtimeBindingId: result.data.scoreORuntimeBindingId || '',
+ logTitle: 'score-o-demo-ready',
+ statusText: 'ok: score-o demo loaded'
+ });
return result;
});
$('btn-use-variant-manual-demo').onclick = () => run('use-variant-manual-demo', async () => {
const result = await request('POST', '/dev/bootstrap-demo');
- $('entry-channel-code').value = 'mini-demo';
- $('entry-channel-type').value = 'wechat_mini';
- $('event-id').value = result.data.variantManualEventId || 'evt_demo_variant_manual_001';
- $('event-release-id').value = result.data.variantManualReleaseId || 'rel_demo_variant_manual_001';
- $('event-variant-id').value = 'variant_b';
- localStorage.setItem(MODE_KEY, 'frontend');
- syncWorkbenchMode();
- writeLog('variant-manual-demo-ready', {
- eventId: $('event-id').value,
- releaseId: $('event-release-id').value,
- variantId: $('event-variant-id').value
+ applyFrontendDemoSelection({
+ eventId: result.data.variantManualEventId || 'evt_demo_variant_manual_001',
+ releaseId: result.data.variantManualReleaseId || 'rel_demo_variant_manual_001',
+ variantId: 'variant_b',
+ localConfigFile: 'classic-sequential.json',
+ gameModeCode: 'classic-sequential',
+ sourceId: result.data.sourceId || '',
+ buildId: result.data.buildId || '',
+ courseSetId: result.data.courseSetId || '',
+ courseVariantId: result.data.courseVariantId || '',
+ runtimeBindingId: '',
+ logTitle: 'variant-manual-demo-ready',
+ statusText: 'ok: manual variant demo loaded'
});
- setStatus('ok: manual variant demo loaded');
return result;
});
$('btn-flow-launch').onclick = () => run('flow-launch', async () => {
- await request('POST', '/dev/bootstrap-demo');
+ const bootstrap = await request('POST', '/dev/bootstrap-demo');
+ if (bootstrap.data) {
+ applyBootstrapContext(bootstrap.data);
+ }
const smsSend = await request('POST', '/auth/sms/send', {
countryCode: $('sms-country').value,
mobile: $('sms-mobile').value,
@@ -4388,6 +4855,7 @@ const devWorkbenchHTML = `
renderScenarioOptions();
applyAPIFilter();
syncAPICounts();
+ renderClientLogs([]);
writeLog('workbench-ready', { ok: true, hint: 'Use Bootstrap Demo first on a fresh database.' });
diff --git a/backend/internal/httpapi/router.go b/backend/internal/httpapi/router.go
index dc1948e..d642b41 100644
--- a/backend/internal/httpapi/router.go
+++ b/backend/internal/httpapi/router.go
@@ -105,6 +105,10 @@ func NewRouter(
if appEnv != "production" {
mux.HandleFunc("GET /dev/workbench", devHandler.Workbench)
mux.HandleFunc("POST /dev/bootstrap-demo", devHandler.BootstrapDemo)
+ mux.HandleFunc("POST /dev/client-logs", devHandler.CreateClientLog)
+ mux.HandleFunc("GET /dev/client-logs", devHandler.ListClientLogs)
+ mux.HandleFunc("DELETE /dev/client-logs", devHandler.ClearClientLogs)
+ mux.HandleFunc("GET /dev/manifest-summary", devHandler.ManifestSummary)
mux.HandleFunc("GET /dev/config/local-files", configHandler.ListLocalFiles)
mux.HandleFunc("POST /dev/events/{eventPublicID}/config-sources/import-local", configHandler.ImportLocal)
mux.HandleFunc("POST /dev/config-builds/preview", configHandler.BuildPreview)
diff --git a/backend/internal/service/dev_service.go b/backend/internal/service/dev_service.go
index 92050d7..d9043b8 100644
--- a/backend/internal/service/dev_service.go
+++ b/backend/internal/service/dev_service.go
@@ -3,6 +3,9 @@ package service
import (
"context"
"net/http"
+ "sort"
+ "sync"
+ "time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/store/postgres"
@@ -11,6 +14,39 @@ import (
type DevService struct {
appEnv string
store *postgres.Store
+ mu sync.Mutex
+ logSeq int64
+ logs []ClientDebugLogEntry
+}
+
+type ClientDebugLogEntry struct {
+ ID int64 `json:"id"`
+ Source string `json:"source"`
+ Level string `json:"level"`
+ Category string `json:"category,omitempty"`
+ Message string `json:"message"`
+ EventID string `json:"eventId,omitempty"`
+ ReleaseID string `json:"releaseId,omitempty"`
+ SessionID string `json:"sessionId,omitempty"`
+ ManifestURL string `json:"manifestUrl,omitempty"`
+ Route string `json:"route,omitempty"`
+ OccurredAt time.Time `json:"occurredAt"`
+ ReceivedAt time.Time `json:"receivedAt"`
+ Details map[string]any `json:"details,omitempty"`
+}
+
+type CreateClientDebugLogInput struct {
+ Source string `json:"source"`
+ Level string `json:"level"`
+ Category string `json:"category"`
+ Message string `json:"message"`
+ EventID string `json:"eventId"`
+ ReleaseID string `json:"releaseId"`
+ SessionID string `json:"sessionId"`
+ ManifestURL string `json:"manifestUrl"`
+ Route string `json:"route"`
+ OccurredAt string `json:"occurredAt"`
+ Details map[string]any `json:"details"`
}
func NewDevService(appEnv string, store *postgres.Store) *DevService {
@@ -30,3 +66,83 @@ func (s *DevService) BootstrapDemo(ctx context.Context) (*postgres.DemoBootstrap
}
return s.store.EnsureDemoData(ctx)
}
+
+func (s *DevService) AddClientDebugLog(_ context.Context, input CreateClientDebugLogInput) (*ClientDebugLogEntry, error) {
+ if !s.Enabled() {
+ return nil, apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled")
+ }
+ if input.Message == "" {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_request", "message is required")
+ }
+ if input.Source == "" {
+ input.Source = "unknown"
+ }
+ if input.Level == "" {
+ input.Level = "info"
+ }
+
+ occurredAt := time.Now().UTC()
+ if input.OccurredAt != "" {
+ parsed, err := time.Parse(time.RFC3339, input.OccurredAt)
+ if err != nil {
+ return nil, apperr.New(http.StatusBadRequest, "invalid_request", "occurredAt must be RFC3339")
+ }
+ occurredAt = parsed.UTC()
+ }
+
+ entry := ClientDebugLogEntry{
+ Source: input.Source,
+ Level: input.Level,
+ Category: input.Category,
+ Message: input.Message,
+ EventID: input.EventID,
+ ReleaseID: input.ReleaseID,
+ SessionID: input.SessionID,
+ ManifestURL: input.ManifestURL,
+ Route: input.Route,
+ OccurredAt: occurredAt,
+ ReceivedAt: time.Now().UTC(),
+ Details: input.Details,
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.logSeq++
+ entry.ID = s.logSeq
+ s.logs = append(s.logs, entry)
+ if len(s.logs) > 200 {
+ s.logs = append([]ClientDebugLogEntry(nil), s.logs[len(s.logs)-200:]...)
+ }
+ copyEntry := entry
+ return ©Entry, nil
+}
+
+func (s *DevService) ListClientDebugLogs(_ context.Context, limit int) ([]ClientDebugLogEntry, error) {
+ if !s.Enabled() {
+ return nil, apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled")
+ }
+ if limit <= 0 || limit > 200 {
+ limit = 50
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ items := append([]ClientDebugLogEntry(nil), s.logs...)
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].ID > items[j].ID
+ })
+ if len(items) > limit {
+ items = items[:limit]
+ }
+ return items, nil
+}
+
+func (s *DevService) ClearClientDebugLogs(_ context.Context) error {
+ if !s.Enabled() {
+ return apperr.New(http.StatusNotFound, "not_found", "dev client logs are disabled")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.logs = nil
+ return nil
+}
diff --git a/backend/internal/store/postgres/dev_store.go b/backend/internal/store/postgres/dev_store.go
index 3ed2fd5..235d2ea 100644
--- a/backend/internal/store/postgres/dev_store.go
+++ b/backend/internal/store/postgres/dev_store.go
@@ -6,24 +6,32 @@ import (
)
type DemoBootstrapSummary struct {
- TenantCode string `json:"tenantCode"`
- ChannelCode string `json:"channelCode"`
- EventID string `json:"eventId"`
- ReleaseID string `json:"releaseId"`
- SourceID string `json:"sourceId"`
- BuildID string `json:"buildId"`
- CardID string `json:"cardId"`
- PlaceID string `json:"placeId"`
- MapAssetID string `json:"mapAssetId"`
- TileReleaseID string `json:"tileReleaseId"`
- CourseSourceID string `json:"courseSourceId"`
- CourseSetID string `json:"courseSetId"`
- CourseVariantID string `json:"courseVariantId"`
- RuntimeBindingID string `json:"runtimeBindingId"`
- VariantManualEventID string `json:"variantManualEventId"`
- VariantManualRelease string `json:"variantManualReleaseId"`
- VariantManualCardID string `json:"variantManualCardId"`
- CleanedSessionCount int64 `json:"cleanedSessionCount"`
+ TenantCode string `json:"tenantCode"`
+ ChannelCode string `json:"channelCode"`
+ EventID string `json:"eventId"`
+ ReleaseID string `json:"releaseId"`
+ SourceID string `json:"sourceId"`
+ BuildID string `json:"buildId"`
+ CardID string `json:"cardId"`
+ PlaceID string `json:"placeId"`
+ MapAssetID string `json:"mapAssetId"`
+ TileReleaseID string `json:"tileReleaseId"`
+ CourseSourceID string `json:"courseSourceId"`
+ CourseSetID string `json:"courseSetId"`
+ CourseVariantID string `json:"courseVariantId"`
+ RuntimeBindingID string `json:"runtimeBindingId"`
+ ScoreOEventID string `json:"scoreOEventId"`
+ ScoreOReleaseID string `json:"scoreOReleaseId"`
+ ScoreOCardID string `json:"scoreOCardId"`
+ ScoreOSourceID string `json:"scoreOSourceId"`
+ ScoreOBuildID string `json:"scoreOBuildId"`
+ ScoreOCourseSetID string `json:"scoreOCourseSetId"`
+ ScoreOCourseVariantID string `json:"scoreOCourseVariantId"`
+ ScoreORuntimeBindingID string `json:"scoreORuntimeBindingId"`
+ VariantManualEventID string `json:"variantManualEventId"`
+ VariantManualRelease string `json:"variantManualReleaseId"`
+ VariantManualCardID string `json:"variantManualCardId"`
+ CleanedSessionCount int64 `json:"cleanedSessionCount"`
}
func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) {
@@ -361,7 +369,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
)
VALUES (
'tile_demo_001', $1, 'v2026-04-03', 'published',
- 'https://example.com/tiles/demo/', 'https://example.com/tiles/demo/meta.json', NOW()
+ 'https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/', 'https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json', NOW()
)
ON CONFLICT (map_asset_id, version_code) DO UPDATE SET
status = EXCLUDED.status,
@@ -387,7 +395,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
course_source_public_id, source_type, file_url, import_status
)
VALUES (
- 'csource_demo_001', 'kml', 'https://example.com/course/demo.kml', 'imported'
+ 'csource_demo_001', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml', 'imported'
)
ON CONFLICT (course_source_public_id) DO UPDATE SET
source_type = EXCLUDED.source_type,
@@ -398,6 +406,23 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
return nil, fmt.Errorf("ensure demo course source: %w", err)
}
+ var courseSourceVariantBID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_sources (
+ course_source_public_id, source_type, file_url, import_status
+ )
+ VALUES (
+ 'csource_demo_002', 'kml', 'https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c02.kml', 'imported'
+ )
+ ON CONFLICT (course_source_public_id) DO UPDATE SET
+ source_type = EXCLUDED.source_type,
+ file_url = EXCLUDED.file_url,
+ import_status = EXCLUDED.import_status
+ RETURNING id, course_source_public_id
+ `).Scan(&courseSourceVariantBID, new(string)); err != nil {
+ return nil, fmt.Errorf("ensure demo course source variant b: %w", err)
+ }
+
var courseSetID, courseSetPublicID string
if err := tx.QueryRow(ctx, `
INSERT INTO course_sets (
@@ -439,6 +464,28 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
return nil, fmt.Errorf("ensure demo course variant: %w", err)
}
+ var courseVariantBID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_variants (
+ course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
+ )
+ VALUES (
+ 'cvariant_demo_002', $1, $2, 'Demo Variant B', 'route-demo-b', 'classic-sequential', 10, 'active', false
+ )
+ ON CONFLICT (course_variant_public_id) DO UPDATE SET
+ course_set_id = EXCLUDED.course_set_id,
+ source_id = EXCLUDED.source_id,
+ name = EXCLUDED.name,
+ route_code = EXCLUDED.route_code,
+ mode = EXCLUDED.mode,
+ control_count = EXCLUDED.control_count,
+ status = EXCLUDED.status,
+ is_default = EXCLUDED.is_default
+ RETURNING id, course_variant_public_id
+ `, courseSetID, courseSourceVariantBID).Scan(&courseVariantBID, new(string)); err != nil {
+ return nil, fmt.Errorf("ensure demo course variant b: %w", err)
+ }
+
if _, err := tx.Exec(ctx, `
UPDATE course_sets
SET current_variant_id = $2
@@ -529,14 +576,14 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
{
"id": "variant_a",
"name": "A 线",
- "description": "短线体验版",
+ "description": "短线体验版(c01.kml)",
"routeCode": "route-variant-a",
"selectable": true
},
{
"id": "variant_b",
"name": "B 线",
- "description": "长线挑战版",
+ "description": "长线挑战版(c02.kml)",
"routeCode": "route-variant-b",
"selectable": true
}
@@ -598,6 +645,273 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
return nil, fmt.Errorf("ensure variant manual demo card: %w", err)
}
+ var scoreOEventID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO events (
+ tenant_id, event_public_id, slug, display_name, summary, status
+ )
+ VALUES ($1, 'evt_demo_score_o_001', 'demo-score-o-run', 'Demo Score-O Run', '积分赛联调活动', 'active')
+ ON CONFLICT (event_public_id) DO UPDATE SET
+ tenant_id = EXCLUDED.tenant_id,
+ slug = EXCLUDED.slug,
+ display_name = EXCLUDED.display_name,
+ summary = EXCLUDED.summary,
+ status = EXCLUDED.status
+ RETURNING id
+ `, tenantID).Scan(&scoreOEventID); err != nil {
+ return nil, fmt.Errorf("ensure score-o demo event: %w", err)
+ }
+
+ var scoreOReleaseRow struct {
+ ID string
+ PublicID string
+ }
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO event_releases (
+ release_public_id,
+ event_id,
+ release_no,
+ config_label,
+ manifest_url,
+ manifest_checksum_sha256,
+ route_code,
+ status
+ )
+ VALUES (
+ 'rel_demo_score_o_001',
+ $1,
+ 1,
+ 'Demo Score-O Config v1',
+ 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json',
+ 'demo-score-o-checksum-001',
+ 'route-score-o-001',
+ 'published'
+ )
+ ON CONFLICT (release_public_id) DO UPDATE SET
+ event_id = EXCLUDED.event_id,
+ config_label = EXCLUDED.config_label,
+ manifest_url = EXCLUDED.manifest_url,
+ manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256,
+ route_code = EXCLUDED.route_code,
+ status = EXCLUDED.status
+ RETURNING id, release_public_id
+ `, scoreOEventID).Scan(&scoreOReleaseRow.ID, &scoreOReleaseRow.PublicID); err != nil {
+ return nil, fmt.Errorf("ensure score-o demo release: %w", err)
+ }
+
+ if _, err := tx.Exec(ctx, `
+ UPDATE events
+ SET current_release_id = $2
+ WHERE id = $1
+ `, scoreOEventID, scoreOReleaseRow.ID); err != nil {
+ return nil, fmt.Errorf("attach score-o demo release: %w", err)
+ }
+
+ scoreOSourceNotes := "demo source config imported from local event sample score-o"
+ scoreOSource, err := s.UpsertEventConfigSource(ctx, tx, UpsertEventConfigSourceParams{
+ EventID: scoreOEventID,
+ SourceVersionNo: 1,
+ SourceKind: "event_bundle",
+ SchemaID: "event-source",
+ SchemaVersion: "1",
+ Status: "active",
+ Notes: &scoreOSourceNotes,
+ Source: map[string]any{
+ "schemaVersion": "1",
+ "app": map[string]any{
+ "id": "sample-score-o-001",
+ "title": "积分赛示例",
+ },
+ "branding": map[string]any{
+ "tenantCode": "tenant_demo",
+ "entryChannel": "mini-demo",
+ },
+ "map": map[string]any{
+ "tiles": "../map/lxcb-001/tiles/",
+ "mapmeta": "../map/lxcb-001/tiles/meta.json",
+ },
+ "playfield": map[string]any{
+ "kind": "control-set",
+ "source": map[string]any{
+ "type": "kml",
+ "url": "../kml/lxcb-001/10/c01.kml",
+ },
+ },
+ "game": map[string]any{
+ "mode": "score-o",
+ },
+ "content": map[string]any{
+ "h5Template": "content-h5-test-template.html",
+ },
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ensure score-o demo event config source: %w", err)
+ }
+
+ scoreOBuildLog := "demo build generated from sample score-o.json"
+ scoreOBuild, err := s.UpsertEventConfigBuild(ctx, tx, UpsertEventConfigBuildParams{
+ EventID: scoreOEventID,
+ SourceID: scoreOSource.ID,
+ BuildNo: 1,
+ BuildStatus: "success",
+ BuildLog: &scoreOBuildLog,
+ Manifest: map[string]any{
+ "schemaVersion": "1",
+ "releaseId": "rel_demo_score_o_001",
+ "version": "2026.04.01",
+ "app": map[string]any{
+ "id": "sample-score-o-001",
+ "title": "积分赛示例",
+ },
+ "map": map[string]any{
+ "tiles": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/",
+ "mapmeta": "https://oss-mbh5.colormaprun.com/gotomars/map/lxcb-001/tiles/meta.json",
+ },
+ "playfield": map[string]any{
+ "kind": "control-set",
+ "source": map[string]any{
+ "type": "kml",
+ "url": "https://oss-mbh5.colormaprun.com/gotomars/kml/lxcb-001/10/c01.kml",
+ },
+ },
+ "game": map[string]any{
+ "mode": "score-o",
+ },
+ "assets": map[string]any{
+ "contentHtml": "https://oss-mbh5.colormaprun.com/gotomars/event/content-h5-test-template.html",
+ },
+ },
+ AssetIndex: []map[string]any{
+ {"assetType": "manifest", "assetKey": "manifest"},
+ {"assetType": "mapmeta", "assetKey": "mapmeta"},
+ {"assetType": "playfield", "assetKey": "playfield-kml"},
+ {"assetType": "content_html", "assetKey": "content-html"},
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("ensure score-o demo event config build: %w", err)
+ }
+
+ if err := s.AttachBuildToRelease(ctx, tx, scoreOReleaseRow.ID, scoreOBuild.ID); err != nil {
+ return nil, fmt.Errorf("attach score-o demo build to release: %w", err)
+ }
+
+ var scoreOCardPublicID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO cards (
+ card_public_id,
+ tenant_id,
+ entry_channel_id,
+ card_type,
+ title,
+ subtitle,
+ cover_url,
+ event_id,
+ display_slot,
+ display_priority,
+ status
+ )
+ VALUES (
+ 'card_demo_score_o_001',
+ $1,
+ $2,
+ 'event',
+ 'Demo Score-O Run',
+ '积分赛联调入口',
+ 'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
+ $3,
+ 'home_primary',
+ 98,
+ 'active'
+ )
+ ON CONFLICT (card_public_id) DO UPDATE SET
+ tenant_id = EXCLUDED.tenant_id,
+ entry_channel_id = EXCLUDED.entry_channel_id,
+ card_type = EXCLUDED.card_type,
+ title = EXCLUDED.title,
+ subtitle = EXCLUDED.subtitle,
+ cover_url = EXCLUDED.cover_url,
+ event_id = EXCLUDED.event_id,
+ display_slot = EXCLUDED.display_slot,
+ display_priority = EXCLUDED.display_priority,
+ status = EXCLUDED.status
+ RETURNING card_public_id
+ `, tenantID, channelID, scoreOEventID).Scan(&scoreOCardPublicID); err != nil {
+ return nil, fmt.Errorf("ensure score-o demo card: %w", err)
+ }
+
+ var scoreOCourseSetID, scoreOCourseSetPublicID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_sets (
+ course_set_public_id, place_id, map_asset_id, code, mode, name, status
+ )
+ VALUES (
+ 'cset_demo_score_o_001', $1, $2, 'cset-demo-score-o-001', 'score-o', 'Demo Score-O Course Set', 'active'
+ )
+ ON CONFLICT (code) DO UPDATE SET
+ place_id = EXCLUDED.place_id,
+ map_asset_id = EXCLUDED.map_asset_id,
+ mode = EXCLUDED.mode,
+ name = EXCLUDED.name,
+ status = EXCLUDED.status
+ RETURNING id, course_set_public_id
+ `, placeID, mapAssetID).Scan(&scoreOCourseSetID, &scoreOCourseSetPublicID); err != nil {
+ return nil, fmt.Errorf("ensure score-o demo course set: %w", err)
+ }
+
+ var scoreOCourseVariantID, scoreOCourseVariantPublicID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO course_variants (
+ course_variant_public_id, course_set_id, source_id, name, route_code, mode, control_count, status, is_default
+ )
+ VALUES (
+ 'cvariant_demo_score_o_001', $1, $2, 'Demo Score-O Variant', 'route-score-o-001', 'score-o', 10, 'active', true
+ )
+ ON CONFLICT (course_variant_public_id) DO UPDATE SET
+ course_set_id = EXCLUDED.course_set_id,
+ source_id = EXCLUDED.source_id,
+ name = EXCLUDED.name,
+ route_code = EXCLUDED.route_code,
+ mode = EXCLUDED.mode,
+ control_count = EXCLUDED.control_count,
+ status = EXCLUDED.status,
+ is_default = EXCLUDED.is_default
+ RETURNING id, course_variant_public_id
+ `, scoreOCourseSetID, courseSourceID).Scan(&scoreOCourseVariantID, &scoreOCourseVariantPublicID); err != nil {
+ return nil, fmt.Errorf("ensure score-o demo course variant: %w", err)
+ }
+
+ if _, err := tx.Exec(ctx, `
+ UPDATE course_sets
+ SET current_variant_id = $2
+ WHERE id = $1
+ `, scoreOCourseSetID, scoreOCourseVariantID); err != nil {
+ return nil, fmt.Errorf("attach score-o demo course variant: %w", err)
+ }
+
+ var scoreORuntimeBindingID, scoreORuntimeBindingPublicID string
+ if err := tx.QueryRow(ctx, `
+ INSERT INTO map_runtime_bindings (
+ runtime_binding_public_id, event_id, place_id, map_asset_id, tile_release_id, course_set_id, course_variant_id, status, notes
+ )
+ VALUES (
+ 'runtime_demo_score_o_001', $1, $2, $3, $4, $5, $6, 'active', 'demo score-o runtime binding'
+ )
+ ON CONFLICT (runtime_binding_public_id) DO UPDATE SET
+ event_id = EXCLUDED.event_id,
+ place_id = EXCLUDED.place_id,
+ map_asset_id = EXCLUDED.map_asset_id,
+ tile_release_id = EXCLUDED.tile_release_id,
+ course_set_id = EXCLUDED.course_set_id,
+ course_variant_id = EXCLUDED.course_variant_id,
+ status = EXCLUDED.status,
+ notes = EXCLUDED.notes
+ RETURNING id, runtime_binding_public_id
+ `, scoreOEventID, placeID, mapAssetID, tileReleaseID, scoreOCourseSetID, scoreOCourseVariantID).Scan(&scoreORuntimeBindingID, &scoreORuntimeBindingPublicID); err != nil {
+ return nil, fmt.Errorf("ensure score-o demo runtime binding: %w", err)
+ }
+
var cleanedSessionCount int64
if err := tx.QueryRow(ctx, `
WITH cleaned AS (
@@ -611,7 +925,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
RETURNING 1
)
SELECT COUNT(*) FROM cleaned
- `, []string{eventID, manualEventID}).Scan(&cleanedSessionCount); err != nil {
+ `, []string{eventID, scoreOEventID, manualEventID}).Scan(&cleanedSessionCount); err != nil {
return nil, fmt.Errorf("cleanup demo ongoing sessions: %w", err)
}
@@ -620,23 +934,31 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
}
return &DemoBootstrapSummary{
- TenantCode: "tenant_demo",
- ChannelCode: "mini-demo",
- EventID: "evt_demo_001",
- ReleaseID: releaseRow.PublicID,
- SourceID: source.ID,
- BuildID: build.ID,
- CardID: cardPublicID,
- PlaceID: placePublicID,
- MapAssetID: mapAssetPublicID,
- TileReleaseID: tileReleasePublicID,
- CourseSourceID: courseSourcePublicID,
- CourseSetID: courseSetPublicID,
- CourseVariantID: courseVariantPublicID,
- RuntimeBindingID: runtimeBindingPublicID,
- VariantManualEventID: "evt_demo_variant_manual_001",
- VariantManualRelease: manualReleaseRow.PublicID,
- VariantManualCardID: manualCardPublicID,
- CleanedSessionCount: cleanedSessionCount,
+ TenantCode: "tenant_demo",
+ ChannelCode: "mini-demo",
+ EventID: "evt_demo_001",
+ ReleaseID: releaseRow.PublicID,
+ SourceID: source.ID,
+ BuildID: build.ID,
+ CardID: cardPublicID,
+ PlaceID: placePublicID,
+ MapAssetID: mapAssetPublicID,
+ TileReleaseID: tileReleasePublicID,
+ CourseSourceID: courseSourcePublicID,
+ CourseSetID: courseSetPublicID,
+ CourseVariantID: courseVariantPublicID,
+ RuntimeBindingID: runtimeBindingPublicID,
+ ScoreOEventID: "evt_demo_score_o_001",
+ ScoreOReleaseID: scoreOReleaseRow.PublicID,
+ ScoreOCardID: scoreOCardPublicID,
+ ScoreOSourceID: scoreOSource.ID,
+ ScoreOBuildID: scoreOBuild.ID,
+ ScoreOCourseSetID: scoreOCourseSetPublicID,
+ ScoreOCourseVariantID: scoreOCourseVariantPublicID,
+ ScoreORuntimeBindingID: scoreORuntimeBindingPublicID,
+ VariantManualEventID: "evt_demo_variant_manual_001",
+ VariantManualRelease: manualReleaseRow.PublicID,
+ VariantManualCardID: manualCardPublicID,
+ CleanedSessionCount: cleanedSessionCount,
}, nil
}
diff --git a/doc/gameplay/程序默认规则基线.md b/doc/gameplay/程序默认规则基线.md
index dd2664f..e7512dc 100644
--- a/doc/gameplay/程序默认规则基线.md
+++ b/doc/gameplay/程序默认规则基线.md
@@ -1,6 +1,6 @@
# 程序默认规则基线
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.1
+> 最后更新:2026-04-03 20:40:00
本文档用于定义当前客户端在**不依赖活动配置细项**时,程序层应该内建的默认规则。
@@ -129,6 +129,7 @@
- 成功打开始点后开始计时
- 起点完成后只给短反馈,并更新引导和 HUD
- 默认不弹白色开始卡
+- 从准备页进入地图即视为进入对局,不再额外要求点击开始按钮
- 默认不弹答题卡
### 3.2 普通点
diff --git a/doc/gameplay/联调架构阶段总结.md b/doc/gameplay/联调架构阶段总结.md
new file mode 100644
index 0000000..cd948e0
--- /dev/null
+++ b/doc/gameplay/联调架构阶段总结.md
@@ -0,0 +1,182 @@
+# 联调架构阶段总结
+> 文档版本:v1.0
+> 最后更新:2026-04-03 16:59:19
+
+## 1. 当前结论
+
+当前联调架构已经从“能联”升级为“可诊断、可回归、可收口”。
+
+这次阶段性进步的核心不是多了几个接口,而是三条链一起立住了:
+
+1. 标准测试链
+2. 结构化诊断链
+3. 多线程协作链
+
+也就是说,当前联调已经不再主要依赖:
+
+- 截图
+- 口头描述
+- 各自猜测
+
+而是可以依赖统一入口、统一日志和统一回写口径来定位问题。
+
+---
+
+## 2. 标准测试链
+
+backend 当前已经把联调入口收敛成标准路径:
+
+```text
+Bootstrap Demo
+-> 一键补齐 Runtime 并发布
+-> 一键标准回归
+-> play / launch / result / history 验证
+```
+
+当前这条链的价值是:
+
+- 从空白环境直接起链
+- 不再手工预铺多份 demo 对象
+- 同一条测试链可以反复执行
+- 回归结果有统一出口
+
+当前 workbench 已具备:
+
+- `Bootstrap Demo`
+- `一键补齐 Runtime 并发布`
+- `一键标准回归`
+- `回归结果汇总`
+
+---
+
+## 3. 稳定测试数据链
+
+当前联调环境已经不再只靠临时假数据,而是开始切入更接近生产的真实输入。
+
+当前已接入:
+
+- 真实 KML / 赛道文件
+- 真实地图资源 URL
+- manual 多赛道双 KML 输入
+- 三类显式 demo 入口:
+ - `evt_demo_001`
+ - `evt_demo_score_o_001`
+ - `evt_demo_variant_manual_001`
+
+当前阶段这条链的意义是:
+
+- 前后端终于在测同一套对象
+- demo 数据不再漂
+- 联调结果更接近生产环境
+
+---
+
+## 4. 结构化诊断链
+
+这次联调真正发生质变的关键,是结构化诊断口径已经建立。
+
+backend 当前已提供:
+
+- 分步执行日志
+- 真实错误
+- stack
+- 最后一次 curl
+- 预期判定
+- `当前 Launch 实际配置摘要`
+
+frontend 当前已配合提供:
+
+- `POST /dev/client-logs`
+- 首页、活动页、准备页、地图关键链路的主动上报
+- 更明确的本地诊断字段:
+ - `details.seq`
+ - `launchVariantId`
+ - `runtimeCourseVariantId`
+
+当前这条结构化诊断链意味着:
+
+- 不再只知道“失败了”
+- 可以知道:
+ - 卡在哪一步
+ - 当前 launch 实际拿到了什么
+ - 前端消费到了什么
+ - 是后端发布问题、前端消费问题,还是规则理解问题
+
+这也是为什么最近某些问题反复修改多轮仍未命中,而补上结构化日志后能一次定位成功。
+
+---
+
+## 5. 多线程协作链
+
+当前多线程联调已经形成稳定协作方式:
+
+- 总控 -> 后端:
+ - [t2b.md](D:/dev/cmr-mini/t2b.md)
+- 后端 -> 总控:
+ - [b2t.md](D:/dev/cmr-mini/b2t.md)
+- 总控 -> 前端:
+ - [t2f.md](D:/dev/cmr-mini/t2f.md)
+- 前端 -> 总控:
+ - [f2t.md](D:/dev/cmr-mini/f2t.md)
+
+这条协作链的作用是:
+
+- 前后端不再互相口头转述
+- 总控能统一收口
+- 阶段性结论能及时沉淀回:
+ - [文档索引](D:/dev/cmr-mini/doc/文档索引.md)
+ - [readme-develop.md](D:/dev/cmr-mini/readme-develop.md)
+
+---
+
+## 6. 当前阶段结论
+
+可以把当前状态明确成:
+
+### 6.1 已完成
+
+- 基础骨架
+- 活动运营域摘要第一刀
+- 联调标准化第一版
+
+### 6.2 正在推进
+
+- 真实输入替换
+- 更接近生产的联调环境
+
+### 6.3 暂不启动
+
+- 活动卡片(列表)产品化
+- 新玩家侧页面扩张
+- 更复杂后台运营功能
+
+---
+
+## 7. 下一步建议
+
+当前下一步不再是继续搭骨架,而是继续把真实输入往活动层推进。
+
+优先顺序建议:
+
+1. `content manifest`
+2. `presentation schema`
+3. 活动文案样例
+
+同时继续保持:
+
+- 前端只做联调回归和小修
+- 后端继续保证一键回归链稳定
+- 排障优先看:
+ - `回归结果汇总`
+ - `当前 Launch 实际配置摘要`
+ - `前端调试日志`
+
+---
+
+## 8. 一句话总结
+
+当前联调架构已经从“人肉协作”升级成:
+
+**标准测试链 + 结构化诊断链 + 多线程协作链**
+
+这代表系统已经从“能跑”进入“可持续联调、可持续收口、可逐步逼近生产”的阶段。
diff --git a/doc/games/积分赛/规则说明文档.md b/doc/games/积分赛/规则说明文档.md
index f2b0fb9..01d93de 100644
--- a/doc/games/积分赛/规则说明文档.md
+++ b/doc/games/积分赛/规则说明文档.md
@@ -1,6 +1,6 @@
# 积分赛规则说明文档
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.1
+> 最后更新:2026-04-03 20:40:00
本文档用于定义 `score-o` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。
@@ -46,6 +46,7 @@
- 基础 HUD
- 所有积分点和结束点默认不显示
- 页面提示玩家:需要先打开始点,比赛才会正式开始并开始计时
+- 从准备页进入地图即视为进入本局,不再额外要求点击开始按钮
### 3.2 打开始点
diff --git a/doc/games/顺序打点/规则说明文档.md b/doc/games/顺序打点/规则说明文档.md
index 5f240d6..16fb326 100644
--- a/doc/games/顺序打点/规则说明文档.md
+++ b/doc/games/顺序打点/规则说明文档.md
@@ -1,6 +1,6 @@
# 顺序打点规则说明文档
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.1
+> 最后更新:2026-04-03 20:40:00
本文档用于定义 `classic-sequential` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。
@@ -46,6 +46,7 @@
- 基础 HUD
- 普通控制点、终点、路线和腿线默认不显示
- 页面提示玩家:需要先打开始点,比赛才会正式开始并开始计时
+- 从准备页进入地图即视为进入本局,不再额外要求点击开始按钮
- 最小模板下,点击检查点默认不弹详情卡
### 3.2 打开始点
diff --git a/doc/文档索引.md b/doc/文档索引.md
index 6eb28d4..3c2be5d 100644
--- a/doc/文档索引.md
+++ b/doc/文档索引.md
@@ -1,6 +1,6 @@
# 文档索引
-> 文档版本:v1.3
-> 最后更新:2026-04-03 19:38:00
+> 文档版本:v1.4
+> 最后更新:2026-04-03 16:59:19
维护约定:
@@ -44,6 +44,7 @@
- [多赛道 Variant 五层设计草案](/D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
- [多赛道 Variant 前后端最小契约](/D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
- [多线程联调协作方式](/D:/dev/cmr-mini/doc/gameplay/多线程联调协作方式.md)
+- [联调架构阶段总结](/D:/dev/cmr-mini/doc/gameplay/联调架构阶段总结.md)
- [APP全局产品架构草案](/D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.md)
- [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md)
- [活动运营域摘要第一刀联调回归清单](/D:/dev/cmr-mini/doc/gameplay/活动运营域摘要第一刀联调回归清单.md)
diff --git a/f2b.md b/f2b.md
index 32941f5..2efc39f 100644
--- a/f2b.md
+++ b/f2b.md
@@ -1,6 +1,6 @@
# F2B 协作清单
-> 文档版本:v1.5
-> 最后更新:2026-04-03 20:02:00
+> 文档版本:v1.9
+> 最后更新:2026-04-03 16:45:26
说明:
@@ -264,6 +264,87 @@
- 无
- 状态:已完成
+### F2B-D006
+
+- 时间:2026-04-03
+- 提出方:前端
+- 当前事实:
+ - 已按 backend `B2F-028` 的排查口径补充前端诊断链,当前地图信息面板/赛后结果里可直接查看:
+ - `launch.config.configUrl`
+ - `launch.resolvedRelease.manifestUrl`
+ - `launch.config.releaseId`
+ - `launch.resolvedRelease.releaseId`
+ - 最终加载后的:
+ - `Schema版本`
+ - `场地类型(playfield.kind)`
+ - `模式编码(game.mode)`
+ - 当前只补了诊断与观测,没有改动正式 launch 主链
+- 需要对方确认什么:
+ - 无
+- 状态:已完成
+
+### F2B-D007
+
+- 时间:2026-04-03 16:26:37
+- 提出方:前端
+- 当前事实:
+ - 已按 `B2F-030` 接入 backend `POST /dev/client-logs`
+ - 当前关键阶段会主动上报最小调试日志:
+ - `entry-home`
+ - `event-play`
+ - `event-prepare`
+ - `launch-diagnostic`
+ - `runtime-compiler`
+ - `session-recovery`
+ - 当前主日志字段已按 backend 建议最小口径回传:
+ - `source`
+ - `level`
+ - `category`
+ - `message`
+ - `eventId`
+ - `releaseId`
+ - `sessionId`
+ - `manifestUrl`
+ - `route`
+ - `details.phase`
+ - `details.schemaVersion`
+ - `details.playfield.kind`
+ - `details.game.mode`
+ - 模拟器日志不再作为当前联调主诊断口,保留地图内调试面板作为本地辅助能力
+- 需要对方确认什么:
+ - 无
+- 状态:已完成
+
+### F2B-D008
+
+- 时间:2026-04-03 16:45:26
+- 提出方:前端
+- 当前事实:
+ - backend 已通过 `B2F-031` 明确确认:积分赛误进顺序赛的根因不是前端解析,而是首页卡片入口配置错误
+ - 具体根因为:
+ - 首页卡片查询此前只取 `home_primary`
+ - 积分赛 demo 卡此前被种到 `home_secondary`
+ - 前端首页因此根本拿不到 `evt_demo_score_o_001`
+ - backend 已修复积分赛卡片入口配置
+ - 前端当前无需再为该问题修改玩法解析或 manifest 消费逻辑
+- 需要对方确认什么:
+ - 无
+- 状态:已完成
+
+### F2B-D009
+
+- 时间:2026-04-03 16:45:26
+- 提出方:前端
+- 当前事实:
+ - 已按 `B2F-032` 优化前端结构化调试日志口径:
+ - 非多赛道玩法时,不再上报空字符串形式的 `assignmentMode`
+ - 非手选赛道时,不再把空 `variantId` 伪装成已选赛道
+ - 所有 client log 现在都会附带前端本地递增 `details.seq`
+ - `launchVariantId` 与 `runtimeCourseVariantId` 已明确区分
+- 需要对方确认什么:
+ - 无
+- 状态:已完成
+
---
## 下一步
@@ -311,4 +392,15 @@
- 无
- 状态:前端执行中
+### F2B-N005
+
+- 时间:2026-04-03
+- 提出方:前端
+- 当前事实:
+ - 当前已具备积分赛 demo 发布链诊断信息,下一步将按 backend 一键测试环境回归 `evt_demo_score_o_001`
+ - 如仍表现为顺序赛,前端将回传 launch/config/runtime 三段事实,不再只报“现象”
+- 需要对方确认什么:
+ - 无
+- 状态:前端执行中
+
diff --git a/f2t.md b/f2t.md
index 1e69306..35795e8 100644
--- a/f2t.md
+++ b/f2t.md
@@ -1,6 +1,6 @@
# F2T 协作清单
-> 文档版本:v1.7
-> 最后更新:2026-04-03 19:48:00
+> 文档版本:v1.9
+> 最后更新:2026-04-03 16:45:26
说明:
@@ -172,6 +172,41 @@
- 无
- 是否已解决:是
+### F2T-D006
+
+- 时间:2026-04-03 16:26:37
+- 谁提的:frontend
+- 当前事实:
+ - 已按 backend 新增 dev 调试接口切换当前联调诊断主出口:
+ - `POST /dev/client-logs`
+ - 当前首页、活动页、准备页、地图关键链路会主动上报:
+ - `entry-home`
+ - `event-play`
+ - `event-prepare`
+ - `launch-diagnostic`
+ - `runtime-compiler`
+ - `session-recovery`
+ - 登录后自动连接模拟器日志的链路已撤掉
+ - 地图内调试面板继续保留,仅作为本地开发辅助,不再作为当前联调主诊断口
+- 需要确认什么:
+ - 无
+- 是否已解决:是
+
+### F2T-D007
+
+- 时间:2026-04-03 16:45:26
+- 谁提的:frontend
+- 当前事实:
+ - backend 已确认积分赛误进顺序赛的根因在 backend demo 首页卡片入口配置,不在前端玩法解析
+ - 前端本轮未再修改 runtime / manifest 消费主链
+ - 前端仅补了联调日志口径优化:
+ - 非多赛道玩法不再上报空字符串 `assignmentMode`
+ - 日志新增前端本地递增 `details.seq`
+ - `launchVariantId` 与 `runtimeCourseVariantId` 明确区分
+- 需要确认什么:
+ - 无
+- 是否已解决:是
+
---
## 下一步
diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts
index d70c25c..537676c 100644
--- a/miniprogram/engine/map/mapEngine.ts
+++ b/miniprogram/engine/map/mapEngine.ts
@@ -1108,6 +1108,7 @@ export class MapEngine {
configAppId: string
configSchemaVersion: string
configVersion: string
+ playfieldKind: string
controlScoreOverrides: Record
controlContentOverrides: Record
defaultControlContentOverride: GameControlDisplayContentOverride | null
@@ -1417,6 +1418,7 @@ export class MapEngine {
this.configAppId = ''
this.configSchemaVersion = '1'
this.configVersion = ''
+ this.playfieldKind = ''
this.controlScoreOverrides = {}
this.controlContentOverrides = {}
this.defaultControlContentOverride = null
@@ -1721,6 +1723,8 @@ export class MapEngine {
{ label: '比赛名称', value: title || '--' },
{ label: '配置版本', value: this.configVersion || '--' },
{ label: 'Schema版本', value: this.configSchemaVersion || '--' },
+ { label: '场地类型', value: this.playfieldKind || '--' },
+ { label: '模式编码', value: this.gameMode || '--' },
{ label: '活动ID', value: this.configAppId || '--' },
{ label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) },
{ label: '地图', value: this.state.mapName || '--' },
@@ -3423,8 +3427,8 @@ export class MapEngine {
this.courseOverlayVisible = true
const gameModeText = this.gameMode === 'score-o' ? '积分赛' : '顺序打点'
const defaultStatusText = this.currentGpsPoint
- ? `${gameModeText}已开始 (${this.buildVersion})`
- : `${gameModeText}已开始,GPS定位启动中 (${this.buildVersion})`
+ ? `已进入${gameModeText},请先打开始点 (${this.buildVersion})`
+ : `已进入${gameModeText},GPS定位启动中,请先打开始点 (${this.buildVersion})`
this.commitGameResult(gameResult, defaultStatusText)
}
@@ -3683,6 +3687,15 @@ export class MapEngine {
this.mockSimulatorDebugLogger.disconnect()
}
+ handleEmitMockDebugLog(
+ scope: string,
+ level: 'info' | 'warn' | 'error',
+ message: string,
+ payload?: Record,
+ ): void {
+ this.mockSimulatorDebugLogger.log(scope, level, message, payload)
+ }
+
handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
if (this.gameMode === nextMode) {
return
@@ -3882,6 +3895,7 @@ export class MapEngine {
this.configAppId = config.configAppId
this.configSchemaVersion = config.configSchemaVersion
this.configVersion = config.configVersion
+ this.playfieldKind = config.playfieldKind
this.controlScoreOverrides = config.controlScoreOverrides
this.controlContentOverrides = config.controlContentOverrides
this.defaultControlContentOverride = config.defaultControlContentOverride
diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts
index 14ca6b5..84e1698 100644
--- a/miniprogram/game/rules/classicSequentialRule.ts
+++ b/miniprogram/game/rules/classicSequentialRule.ts
@@ -114,7 +114,7 @@ function getGuidanceEffects(
function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
if (state.status === 'idle') {
- return '点击开始后先打开始点'
+ return '先打开始点即可正式开始比赛'
}
if (state.status === 'finished') {
diff --git a/miniprogram/game/rules/scoreORule.ts b/miniprogram/game/rules/scoreORule.ts
index c824923..7766ffe 100644
--- a/miniprogram/game/rules/scoreORule.ts
+++ b/miniprogram/game/rules/scoreORule.ts
@@ -271,7 +271,7 @@ function buildPunchHintText(
focusedTarget: GameControl | null,
): string {
if (state.status === 'idle') {
- return '点击开始后先打开始点'
+ return '先打开始点即可正式开始比赛'
}
if (state.status === 'finished') {
diff --git a/miniprogram/pages/event-prepare/event-prepare.ts b/miniprogram/pages/event-prepare/event-prepare.ts
index cd13710..1cb75d4 100644
--- a/miniprogram/pages/event-prepare/event-prepare.ts
+++ b/miniprogram/pages/event-prepare/event-prepare.ts
@@ -3,6 +3,7 @@ import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type Backe
import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
+import { reportBackendClientLog } from '../../utils/backendClientLogs'
import { HeartRateController } from '../../engine/sensor/heartRateController'
const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
@@ -290,12 +291,32 @@ Page({
result.play.assignmentMode,
result.play.courseVariants,
)
+ const assignmentMode = result.play.assignmentMode ? result.play.assignmentMode : null
+ const logVariantId = assignmentMode === 'manual' && selectedVariantId ? selectedVariantId : null
const selectableVariants = buildSelectableVariants(
selectedVariantId,
result.play.assignmentMode,
result.play.courseVariants,
)
const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null
+ reportBackendClientLog({
+ level: 'info',
+ category: 'event-prepare',
+ message: 'prepare play loaded',
+ eventId: result.event.id || this.data.eventId || '',
+ releaseId: result.resolvedRelease && result.resolvedRelease.releaseId
+ ? result.resolvedRelease.releaseId
+ : '',
+ manifestUrl: result.resolvedRelease && result.resolvedRelease.manifestUrl
+ ? result.resolvedRelease.manifestUrl
+ : '',
+ details: {
+ pageEventId: this.data.eventId || '',
+ resultEventId: result.event.id || '',
+ selectedVariantId: logVariantId,
+ assignmentMode,
+ },
+ })
this.setData({
loading: false,
titleText: `${result.event.displayName} / 开始前准备`,
@@ -586,6 +607,22 @@ Page({
})
try {
+ const assignmentMode = this.data.assignmentMode ? this.data.assignmentMode : null
+ const selectedVariantId = assignmentMode === 'manual' && this.data.selectedVariantId
+ ? this.data.selectedVariantId
+ : null
+ reportBackendClientLog({
+ level: 'info',
+ category: 'event-prepare',
+ message: 'launch requested',
+ eventId: this.data.eventId || '',
+ details: {
+ pageEventId: this.data.eventId || '',
+ selectedVariantId,
+ assignmentMode,
+ phase: 'launch-requested',
+ },
+ })
const app = getApp()
if (app.globalData) {
const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName
@@ -608,6 +645,32 @@ Page({
clientType: 'wechat',
deviceKey: 'mini-dev-device-001',
})
+ reportBackendClientLog({
+ level: 'info',
+ category: 'event-prepare',
+ message: 'launch response received',
+ eventId: result.launch.business && result.launch.business.eventId ? result.launch.business.eventId : this.data.eventId || '',
+ releaseId: result.launch.config && result.launch.config.releaseId ? result.launch.config.releaseId : '',
+ sessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '',
+ manifestUrl: result.launch.resolvedRelease && result.launch.resolvedRelease.manifestUrl
+ ? result.launch.resolvedRelease.manifestUrl
+ : '',
+ details: {
+ pageEventId: this.data.eventId || '',
+ launchEventId: result.launch.business && result.launch.business.eventId ? result.launch.business.eventId : '',
+ launchSessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '',
+ configUrl: result.launch.config && result.launch.config.configUrl ? result.launch.config.configUrl : '',
+ releaseId: result.launch.config && result.launch.config.releaseId ? result.launch.config.releaseId : '',
+ resolvedReleaseId: result.launch.resolvedRelease && result.launch.resolvedRelease.releaseId
+ ? result.launch.resolvedRelease.releaseId
+ : '',
+ resolvedManifestUrl: result.launch.resolvedRelease && result.launch.resolvedRelease.manifestUrl
+ ? result.launch.resolvedRelease.manifestUrl
+ : '',
+ launchVariantId: result.launch.variant && result.launch.variant.id ? result.launch.variant.id : null,
+ phase: 'launch-response',
+ },
+ })
const envelope = adaptBackendLaunchResultToEnvelope(result)
wx.navigateTo({
url: prepareMapPageUrlForLaunch(envelope),
diff --git a/miniprogram/pages/event/event.ts b/miniprogram/pages/event/event.ts
index 0b1ec8a..8df677b 100644
--- a/miniprogram/pages/event/event.ts
+++ b/miniprogram/pages/event/event.ts
@@ -1,6 +1,7 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getEventPlay, type BackendEventPlayResult } from '../../utils/backendApi'
import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
+import { reportBackendClientLog } from '../../utils/backendClientLogs'
type EventPageData = {
eventId: string
@@ -130,6 +131,26 @@ Page({
},
applyEventPlay(result: BackendEventPlayResult) {
+ const assignmentMode = result.play.assignmentMode ? result.play.assignmentMode : null
+ reportBackendClientLog({
+ level: 'info',
+ category: 'event-play',
+ message: 'event play loaded',
+ eventId: result.event.id || this.data.eventId || '',
+ releaseId: result.resolvedRelease && result.resolvedRelease.releaseId
+ ? result.resolvedRelease.releaseId
+ : '',
+ manifestUrl: result.resolvedRelease && result.resolvedRelease.manifestUrl
+ ? result.resolvedRelease.manifestUrl
+ : '',
+ details: {
+ pageEventId: this.data.eventId || '',
+ resultEventId: result.event.id || '',
+ primaryAction: result.play.primaryAction || '',
+ assignmentMode,
+ variantCount: result.play.courseVariants ? result.play.courseVariants.length : 0,
+ },
+ })
this.setData({
loading: false,
titleText: result.event.displayName,
diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts
index 5607d4e..93d6b4c 100644
--- a/miniprogram/pages/home/home.ts
+++ b/miniprogram/pages/home/home.ts
@@ -1,5 +1,7 @@
import { clearBackendAuthTokens, loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getEntryHome, type BackendCardResult, type BackendEntryHomeResult } from '../../utils/backendApi'
+import { reportBackendClientLog } from '../../utils/backendClientLogs'
+import { setGlobalMockDebugBridgeEnabled } from '../../utils/globalMockDebugBridge'
const DEFAULT_CHANNEL_CODE = 'mini-demo'
const DEFAULT_CHANNEL_TYPE = 'wechat_mini'
@@ -100,6 +102,18 @@ Page({
},
applyEntryHomeResult(result: BackendEntryHomeResult) {
+ reportBackendClientLog({
+ level: 'info',
+ category: 'entry-home',
+ message: 'entry home loaded',
+ details: {
+ ongoingSessionId: result.ongoingSession && result.ongoingSession.id ? result.ongoingSession.id : '',
+ ongoingEventId: result.ongoingSession && result.ongoingSession.eventId ? result.ongoingSession.eventId : '',
+ recentSessionId: result.recentSession && result.recentSession.id ? result.recentSession.id : '',
+ recentEventId: result.recentSession && result.recentSession.eventId ? result.recentSession.eventId : '',
+ cardEventIds: (result.cards || []).map((item) => (item.event && item.event.id ? item.event.id : '')),
+ },
+ })
this.setData({
loading: false,
statusText: '首页加载完成',
@@ -141,6 +155,7 @@ Page({
handleLogout() {
clearBackendAuthTokens()
+ setGlobalMockDebugBridgeEnabled(false)
const app = getApp()
if (app.globalData) {
app.globalData.backendAuthTokens = null
diff --git a/miniprogram/pages/login/login.ts b/miniprogram/pages/login/login.ts
index 2d07fa0..77b0e85 100644
--- a/miniprogram/pages/login/login.ts
+++ b/miniprogram/pages/login/login.ts
@@ -1,5 +1,6 @@
import { clearBackendAuthTokens, saveBackendAuthTokens, saveBackendBaseUrl } from '../../utils/backendAuth'
import { loginWechatMini } from '../../utils/backendApi'
+import { setGlobalMockDebugBridgeEnabled } from '../../utils/globalMockDebugBridge'
const DEFAULT_BACKEND_BASE_URL = 'https://api.gotomars.xyz'
const DEFAULT_DEVICE_KEY = 'mini-dev-device-001'
@@ -116,6 +117,7 @@ Page({
handleClearLoginState() {
clearBackendAuthTokens()
+ setGlobalMockDebugBridgeEnabled(false)
const app = getApp()
if (app.globalData) {
app.globalData.backendAuthTokens = null
diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts
index 958039f..f087573 100644
--- a/miniprogram/pages/map/map.ts
+++ b/miniprogram/pages/map/map.ts
@@ -16,6 +16,13 @@ import {
import { finishSession, startSession, type BackendSessionFinishSummaryPayload } from '../../utils/backendApi'
import { loadBackendBaseUrl } from '../../utils/backendAuth'
import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig'
+import {
+ persistStoredMockDebugLogBridgeUrl,
+ setGlobalMockDebugBridgeChannelId,
+ setGlobalMockDebugBridgeEnabled,
+ setGlobalMockDebugBridgeUrl,
+} from '../../utils/globalMockDebugBridge'
+import { reportBackendClientLog } from '../../utils/backendClientLogs'
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig'
import { type GpsMarkerColorPreset } from '../../game/presentation/gpsMarkerStyleConfig'
@@ -146,6 +153,7 @@ type MapPageData = MapEngineViewState & {
showLeftButtonGroup: boolean
showRightButtonGroups: boolean
showBottomDebugButton: boolean
+ showStartEntryButton: boolean
}
function getGlobalTelemetryProfile(): PlayerTelemetryProfile | null {
@@ -184,6 +192,7 @@ let systemSettingsLockLifetimeActive = false
let syncedBackendSessionStartId = ''
let syncedBackendSessionFinishId = ''
let shouldAutoRestoreRecoverySnapshot = false
+let shouldAutoStartSessionOnEnter = false
let redirectedToResultPage = false
let pendingHeartRateSwitchDeviceName: string | null = null
const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
@@ -828,6 +837,52 @@ function buildRuntimeSummaryRows(envelope: GameLaunchEnvelope): MapEngineGameInf
return rows
}
+function buildLaunchConfigSummaryRows(envelope: GameLaunchEnvelope): MapEngineGameInfoRow[] {
+ const rows: MapEngineGameInfoRow[] = []
+ rows.push({ label: '配置标签', value: envelope.config.configLabel || '--' })
+ rows.push({ label: '配置URL', value: envelope.config.configUrl || '--' })
+ rows.push({ label: '配置Release', value: envelope.config.releaseId || '--' })
+ rows.push({
+ label: 'Launch Event',
+ value: envelope.business && envelope.business.eventId
+ ? envelope.business.eventId
+ : '--',
+ })
+ rows.push({
+ label: 'Resolved Manifest',
+ value: envelope.resolvedRelease && envelope.resolvedRelease.manifestUrl
+ ? envelope.resolvedRelease.manifestUrl
+ : '--',
+ })
+ rows.push({
+ label: 'Resolved Release',
+ value: envelope.resolvedRelease && envelope.resolvedRelease.releaseId
+ ? envelope.resolvedRelease.releaseId
+ : '--',
+ })
+ return rows
+}
+
+function emitSimulatorLaunchDiagnostic(
+ stage: string,
+ payload: Record,
+) {
+ reportBackendClientLog({
+ level: 'info',
+ category: 'launch-diagnostic',
+ message: stage,
+ eventId: typeof payload.launchEventId === 'string' ? payload.launchEventId : '',
+ releaseId: typeof payload.configReleaseId === 'string'
+ ? payload.configReleaseId
+ : (typeof payload.resolvedReleaseId === 'string' ? payload.resolvedReleaseId : ''),
+ sessionId: typeof payload.launchSessionId === 'string' ? payload.launchSessionId : '',
+ manifestUrl: typeof payload.resolvedManifestUrl === 'string'
+ ? payload.resolvedManifestUrl
+ : (typeof payload.configUrl === 'string' ? payload.configUrl : ''),
+ details: payload,
+ })
+}
+
Page({
data: {
showDebugPanel: false,
@@ -967,6 +1022,7 @@ Page({
centerScaleRulerMajorMarks: [],
compassTicks: buildCompassTicks(),
compassLabels: buildCompassLabels(),
+ showStartEntryButton: true,
...buildSideButtonVisibility('shown'),
...buildSideButtonState({
sideButtonMode: 'shown',
@@ -989,10 +1045,15 @@ Page({
syncedBackendSessionFinishId = ''
redirectedToResultPage = false
shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1'
- currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
- if (!hasExplicitLaunchOptions(options)) {
- const recoverySnapshot = loadSessionRecoverySnapshot()
- if (recoverySnapshot) {
+ shouldAutoStartSessionOnEnter = !!(options && options.autoStartOnEnter === '1')
+ const recoverySnapshot = loadSessionRecoverySnapshot()
+ if (shouldAutoRestoreRecoverySnapshot && recoverySnapshot) {
+ // Recovery should trust the persisted session envelope first so it can
+ // survive launchId stash misses and still reconstruct the original round.
+ currentGameLaunchEnvelope = recoverySnapshot.launchEnvelope
+ } else {
+ currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
+ if (!hasExplicitLaunchOptions(options) && recoverySnapshot) {
currentGameLaunchEnvelope = recoverySnapshot.launchEnvelope
}
}
@@ -1005,6 +1066,9 @@ Page({
const statusBarHeight = systemInfo.statusBarHeight || 0
const menuButtonRect = wx.getMenuButtonBoundingClientRect()
const menuButtonBottom = menuButtonRect && typeof menuButtonRect.bottom === 'number' ? menuButtonRect.bottom : statusBarHeight
+ this.setData({
+ showStartEntryButton: !shouldAutoStartSessionOnEnter,
+ })
if (mapEngine) {
mapEngine.destroy()
@@ -1514,11 +1578,27 @@ Page({
systemSettingsLockLifetimeActive = false
currentGameLaunchEnvelope = getDemoGameLaunchEnvelope()
shouldAutoRestoreRecoverySnapshot = false
+ shouldAutoStartSessionOnEnter = false
redirectedToResultPage = false
stageCanvasAttached = false
},
loadGameLaunchEnvelope(envelope: GameLaunchEnvelope) {
+ emitSimulatorLaunchDiagnostic('loadGameLaunchEnvelope', {
+ launchEventId: envelope.business && envelope.business.eventId ? envelope.business.eventId : '',
+ launchSessionId: envelope.business && envelope.business.sessionId ? envelope.business.sessionId : '',
+ configUrl: envelope.config.configUrl || '',
+ configReleaseId: envelope.config.releaseId || '',
+ resolvedManifestUrl: envelope.resolvedRelease && envelope.resolvedRelease.manifestUrl
+ ? envelope.resolvedRelease.manifestUrl
+ : '',
+ resolvedReleaseId: envelope.resolvedRelease && envelope.resolvedRelease.releaseId
+ ? envelope.resolvedRelease.releaseId
+ : '',
+ launchVariantId: envelope.variant && envelope.variant.variantId ? envelope.variant.variantId : null,
+ launchVariantRouteCode: envelope.variant && envelope.variant.routeCode ? envelope.variant.routeCode : null,
+ runtimeCourseVariantId: envelope.runtime && envelope.runtime.courseVariantId ? envelope.runtime.courseVariantId : null,
+ })
this.loadMapConfigFromRemote(
envelope.config.configUrl,
envelope.config.configLabel,
@@ -1621,10 +1701,49 @@ Page({
reportAbandonedRecoverySnapshot(snapshot: SessionRecoverySnapshot) {
const sessionContext = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope)
if (!sessionContext) {
+ reportBackendClientLog({
+ level: 'warn',
+ category: 'session-recovery',
+ message: 'abandon recovery without valid session context',
+ eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId
+ ? snapshot.launchEnvelope.business.eventId
+ : '',
+ releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId
+ ? snapshot.launchEnvelope.config.releaseId
+ : '',
+ manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ ? snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl
+ ? snapshot.launchEnvelope.config.configUrl
+ : '',
+ details: {
+ phase: 'abandon-no-session',
+ },
+ })
clearSessionRecoverySnapshot()
return
}
+ reportBackendClientLog({
+ level: 'info',
+ category: 'session-recovery',
+ message: 'abandon recovery requested',
+ eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId
+ ? snapshot.launchEnvelope.business.eventId
+ : '',
+ releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId
+ ? snapshot.launchEnvelope.config.releaseId
+ : '',
+ sessionId: sessionContext.sessionId,
+ manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ ? snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl
+ ? snapshot.launchEnvelope.config.configUrl
+ : '',
+ details: {
+ phase: 'abandon-requested',
+ },
+ })
finishSession({
baseUrl: getCurrentBackendBaseUrl(),
sessionId: sessionContext.sessionId,
@@ -1634,6 +1753,26 @@ Page({
})
.then(() => {
syncedBackendSessionFinishId = sessionContext.sessionId
+ reportBackendClientLog({
+ level: 'info',
+ category: 'session-recovery',
+ message: 'abandon recovery synced as cancelled',
+ eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId
+ ? snapshot.launchEnvelope.business.eventId
+ : '',
+ releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId
+ ? snapshot.launchEnvelope.config.releaseId
+ : '',
+ sessionId: sessionContext.sessionId,
+ manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ ? snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl
+ ? snapshot.launchEnvelope.config.configUrl
+ : '',
+ details: {
+ phase: 'abandon-finished',
+ },
+ })
clearSessionRecoverySnapshot()
wx.showToast({
title: '已放弃上次对局',
@@ -1642,6 +1781,27 @@ Page({
})
})
.catch((error) => {
+ reportBackendClientLog({
+ level: 'warn',
+ category: 'session-recovery',
+ message: 'abandon recovery finish(cancelled) failed',
+ eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId
+ ? snapshot.launchEnvelope.business.eventId
+ : '',
+ releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId
+ ? snapshot.launchEnvelope.config.releaseId
+ : '',
+ sessionId: sessionContext.sessionId,
+ manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ ? snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl
+ ? snapshot.launchEnvelope.config.configUrl
+ : '',
+ details: {
+ phase: 'abandon-failed',
+ message: error && error.message ? error.message : '未知错误',
+ },
+ })
clearSessionRecoverySnapshot()
const message = error && error.message ? error.message : '未知错误'
this.setData({
@@ -1712,6 +1872,28 @@ Page({
this.applyRuntimeSystemSettings(true)
const restored = mapEngine ? mapEngine.restoreSessionRecoveryRuntimeSnapshot(snapshot.runtime) : false
if (!restored) {
+ reportBackendClientLog({
+ level: 'warn',
+ category: 'session-recovery',
+ message: 'recovery restore failed',
+ eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId
+ ? snapshot.launchEnvelope.business.eventId
+ : '',
+ releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId
+ ? snapshot.launchEnvelope.config.releaseId
+ : '',
+ sessionId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.sessionId
+ ? snapshot.launchEnvelope.business.sessionId
+ : '',
+ manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ ? snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl
+ ? snapshot.launchEnvelope.config.configUrl
+ : '',
+ details: {
+ phase: 'restore-failed',
+ },
+ })
clearSessionRecoverySnapshot()
wx.showToast({
title: '恢复失败,已回到初始状态',
@@ -1726,11 +1908,34 @@ Page({
showDebugPanel: false,
showGameInfoPanel: false,
showSystemSettingsPanel: false,
+ showStartEntryButton: false,
})
const sessionContext = getCurrentBackendSessionContext()
if (sessionContext) {
syncedBackendSessionStartId = sessionContext.sessionId
}
+ reportBackendClientLog({
+ level: 'info',
+ category: 'session-recovery',
+ message: 'recovery restored',
+ eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId
+ ? snapshot.launchEnvelope.business.eventId
+ : '',
+ releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId
+ ? snapshot.launchEnvelope.config.releaseId
+ : '',
+ sessionId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.sessionId
+ ? snapshot.launchEnvelope.business.sessionId
+ : '',
+ manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ ? snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl
+ ? snapshot.launchEnvelope.config.configUrl
+ : '',
+ details: {
+ phase: 'restored',
+ },
+ })
this.syncSessionRecoveryLifecycle('running')
return true
},
@@ -1752,24 +1957,77 @@ Page({
maybePromptSessionRecoveryRestore(config: RemoteMapConfig) {
const snapshot = loadSessionRecoverySnapshot()
if (!snapshot || !mapEngine) {
- return
+ return false
}
if (
snapshot.launchEnvelope.config.configUrl !== currentGameLaunchEnvelope.config.configUrl
|| snapshot.configAppId !== config.configAppId
- || snapshot.configVersion !== config.configVersion
) {
+ reportBackendClientLog({
+ level: 'warn',
+ category: 'session-recovery',
+ message: 'recovery snapshot dropped due to config mismatch',
+ eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId
+ ? snapshot.launchEnvelope.business.eventId
+ : '',
+ releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId
+ ? snapshot.launchEnvelope.config.releaseId
+ : '',
+ sessionId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.sessionId
+ ? snapshot.launchEnvelope.business.sessionId
+ : '',
+ manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ ? snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl
+ ? snapshot.launchEnvelope.config.configUrl
+ : '',
+ details: {
+ phase: 'config-mismatch',
+ currentConfigUrl: currentGameLaunchEnvelope.config.configUrl,
+ snapshotConfigUrl: snapshot.launchEnvelope.config.configUrl,
+ currentConfigAppId: config.configAppId,
+ snapshotConfigAppId: snapshot.configAppId,
+ },
+ })
clearSessionRecoverySnapshot()
- return
+ this.setData({
+ statusText: '检测到旧局恢复记录,但当前配置源已变化,已回到初始状态',
+ })
+ return false
}
if (shouldAutoRestoreRecoverySnapshot) {
shouldAutoRestoreRecoverySnapshot = false
+ reportBackendClientLog({
+ level: 'info',
+ category: 'session-recovery',
+ message: 'auto recovery requested',
+ eventId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.eventId
+ ? snapshot.launchEnvelope.business.eventId
+ : '',
+ releaseId: snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.releaseId
+ ? snapshot.launchEnvelope.config.releaseId
+ : '',
+ sessionId: snapshot.launchEnvelope.business && snapshot.launchEnvelope.business.sessionId
+ ? snapshot.launchEnvelope.business.sessionId
+ : '',
+ manifestUrl: snapshot.launchEnvelope.resolvedRelease && snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ ? snapshot.launchEnvelope.resolvedRelease.manifestUrl
+ : snapshot.launchEnvelope.config && snapshot.launchEnvelope.config.configUrl
+ ? snapshot.launchEnvelope.config.configUrl
+ : '',
+ details: {
+ phase: 'auto-restore',
+ },
+ })
this.restoreRecoverySnapshot(snapshot)
- return
+ return true
}
+ this.setData({
+ showStartEntryButton: true,
+ })
wx.showModal({
title: '恢复对局',
content: '检测到上次有未正常结束的对局,是否继续恢复?',
@@ -1784,6 +2042,21 @@ Page({
this.restoreRecoverySnapshot(snapshot)
},
})
+ return true
+ },
+
+ maybeAutoStartSessionOnEnter() {
+ if (!shouldAutoStartSessionOnEnter || !mapEngine) {
+ return
+ }
+
+ shouldAutoStartSessionOnEnter = false
+ systemSettingsLockLifetimeActive = true
+ this.applyRuntimeSystemSettings(true)
+ this.setData({
+ showStartEntryButton: false,
+ })
+ mapEngine.handleStartGame()
},
compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) {
@@ -1913,20 +2186,76 @@ Page({
return
}
+ emitSimulatorLaunchDiagnostic('loadRemoteMapConfig:resolved', {
+ launchEventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId
+ ? currentGameLaunchEnvelope.business.eventId
+ : '',
+ configUrl,
+ configVersion: config.configVersion || '',
+ schemaVersion: config.configSchemaVersion || '',
+ playfieldKind: config.playfieldKind || '',
+ gameMode: config.gameMode || '',
+ configTitle: config.configTitle || '',
+ })
+
currentEngine.applyRemoteMapConfig(config)
this.applyConfiguredSystemSettings(config)
- this.applyCompiledRuntimeProfiles(true, {
+ const compiledProfile = this.applyCompiledRuntimeProfiles(true, {
includeMap: true,
includeGame: true,
includePresentation: true,
})
- this.maybePromptSessionRecoveryRestore(config)
+ if (compiledProfile) {
+ reportBackendClientLog({
+ level: 'info',
+ category: 'runtime-compiler',
+ message: 'compiled runtime profile applied',
+ eventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId
+ ? currentGameLaunchEnvelope.business.eventId
+ : '',
+ releaseId: currentGameLaunchEnvelope.config && currentGameLaunchEnvelope.config.releaseId
+ ? currentGameLaunchEnvelope.config.releaseId
+ : '',
+ sessionId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.sessionId
+ ? currentGameLaunchEnvelope.business.sessionId
+ : '',
+ manifestUrl: currentGameLaunchEnvelope.resolvedRelease && currentGameLaunchEnvelope.resolvedRelease.manifestUrl
+ ? currentGameLaunchEnvelope.resolvedRelease.manifestUrl
+ : currentGameLaunchEnvelope.config && currentGameLaunchEnvelope.config.configUrl
+ ? currentGameLaunchEnvelope.config.configUrl
+ : '',
+ details: {
+ phase: 'compiled-runtime-applied',
+ schemaVersion: config.configSchemaVersion || '',
+ playfield: {
+ kind: config.playfieldKind || '',
+ },
+ game: {
+ mode: config.gameMode || '',
+ },
+ },
+ })
+ }
+ const recoveryHandled = this.maybePromptSessionRecoveryRestore(config)
+ if (!recoveryHandled) {
+ this.maybeAutoStartSessionOnEnter()
+ } else {
+ shouldAutoStartSessionOnEnter = false
+ }
})
.catch((error) => {
if (mapEngine !== currentEngine) {
return
}
+ emitSimulatorLaunchDiagnostic('loadRemoteMapConfig:error', {
+ launchEventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId
+ ? currentGameLaunchEnvelope.business.eventId
+ : '',
+ configUrl,
+ message: error && error.message ? error.message : '未知错误',
+ })
+
const rawErrorMessage = error && error.message ? error.message : '未知错误'
const errorMessage = rawErrorMessage.indexOf('404') >= 0
? `release manifest 不存在或未发布 (${configLabel})`
@@ -2115,6 +2444,10 @@ Page({
})
persistMockChannelId(channelId)
persistMockAutoConnectEnabled(true)
+ setGlobalMockDebugBridgeChannelId(channelId)
+ setGlobalMockDebugBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
+ persistStoredMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
+ setGlobalMockDebugBridgeEnabled(true)
mapEngine.handleSetMockChannelId(channelId)
mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft)
mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft)
@@ -2144,6 +2477,7 @@ Page({
mockChannelIdDraft: channelId,
})
persistMockChannelId(channelId)
+ setGlobalMockDebugBridgeChannelId(channelId)
if (mapEngine) {
mapEngine.handleSetMockChannelId(channelId)
}
@@ -2199,12 +2533,17 @@ Page({
},
handleSaveMockDebugLogBridgeUrl() {
+ persistStoredMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
+ setGlobalMockDebugBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
if (mapEngine) {
mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
}
},
handleConnectMockDebugLogBridge() {
+ setGlobalMockDebugBridgeChannelId((this.data.mockChannelIdDraft || '').trim() || 'default')
+ setGlobalMockDebugBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
+ setGlobalMockDebugBridgeEnabled(true)
if (mapEngine) {
mapEngine.handleConnectMockDebugLogBridge()
}
@@ -2212,6 +2551,7 @@ Page({
handleDisconnectMockDebugLogBridge() {
persistMockAutoConnectEnabled(false)
+ setGlobalMockDebugBridgeEnabled(false)
if (mapEngine) {
mapEngine.handleDisconnectMockDebugLogBridge()
}
@@ -2358,8 +2698,12 @@ Page({
handleStartGame() {
if (mapEngine) {
+ shouldAutoStartSessionOnEnter = false
systemSettingsLockLifetimeActive = true
this.applyRuntimeSystemSettings(true)
+ this.setData({
+ showStartEntryButton: false,
+ })
mapEngine.handleStartGame()
}
},
@@ -2443,6 +2787,7 @@ Page({
const snapshot = mapEngine.getGameInfoSnapshot()
const localRows = snapshot.localRows.concat([
...buildRuntimeSummaryRows(currentGameLaunchEnvelope),
+ ...buildLaunchConfigSummaryRows(currentGameLaunchEnvelope),
{ label: '比例尺开关', value: this.data.showCenterScaleRuler ? '开启' : '关闭' },
{ label: '比例尺锚点', value: this.data.centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心' },
{ label: '按钮习惯', value: this.data.sideButtonPlacement === 'right' ? '右手' : '左手' },
@@ -2471,7 +2816,9 @@ Page({
resultSceneSubtitle: snapshot.subtitle,
resultSceneHeroLabel: snapshot.heroLabel,
resultSceneHeroValue: snapshot.heroValue,
- resultSceneRows: snapshot.rows.concat(buildRuntimeSummaryRows(currentGameLaunchEnvelope)),
+ resultSceneRows: snapshot.rows
+ .concat(buildRuntimeSummaryRows(currentGameLaunchEnvelope))
+ .concat(buildLaunchConfigSummaryRows(currentGameLaunchEnvelope)),
})
},
diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml
index 98916f2..e979a26 100644
--- a/miniprogram/pages/map/map.wxml
+++ b/miniprogram/pages/map/map.wxml
@@ -158,7 +158,7 @@
{{pendingContentEntryText}}
-
+
开始
diff --git a/miniprogram/utils/backendApi.ts b/miniprogram/utils/backendApi.ts
index 4c1980a..5095a11 100644
--- a/miniprogram/utils/backendApi.ts
+++ b/miniprogram/utils/backendApi.ts
@@ -237,6 +237,20 @@ export interface BackendSessionResultView {
}
}
+export interface BackendClientLogInput {
+ source: string
+ level: 'debug' | 'info' | 'warn' | 'error'
+ category: string
+ message: string
+ eventId?: string
+ releaseId?: string
+ sessionId?: string
+ manifestUrl?: string
+ route?: string
+ occurredAt?: string
+ details?: Record
+}
+
type BackendEnvelope = {
data: T
}
@@ -428,3 +442,15 @@ export function getMyResults(input: {
authToken: input.accessToken,
})
}
+
+export function postClientLog(input: {
+ baseUrl: string
+ payload: BackendClientLogInput
+}): Promise {
+ return requestBackend({
+ method: 'POST',
+ baseUrl: input.baseUrl,
+ path: '/dev/client-logs',
+ body: input.payload as unknown as Record,
+ })
+}
diff --git a/miniprogram/utils/backendClientLogs.ts b/miniprogram/utils/backendClientLogs.ts
new file mode 100644
index 0000000..bc00a13
--- /dev/null
+++ b/miniprogram/utils/backendClientLogs.ts
@@ -0,0 +1,90 @@
+import { loadBackendBaseUrl } from './backendAuth'
+import { postClientLog, type BackendClientLogInput } from './backendApi'
+
+type ClientLogLevel = BackendClientLogInput['level']
+
+type ClientLogEntry = {
+ level: ClientLogLevel
+ category: string
+ message: string
+ eventId?: string
+ releaseId?: string
+ sessionId?: string
+ manifestUrl?: string
+ route?: string
+ details?: Record
+}
+
+const CLIENT_LOG_SOURCE = 'wechat-mini'
+const MAX_PENDING_CLIENT_LOGS = 100
+
+const pendingClientLogs: BackendClientLogInput[] = []
+let clientLogFlushInProgress = false
+let clientLogSequence = 0
+
+function getCurrentRoute(): string {
+ const pages = getCurrentPages()
+ if (!pages.length) {
+ return ''
+ }
+ const current = pages[pages.length - 1]
+ return current && current.route ? current.route : ''
+}
+
+function enqueueClientLog(payload: BackendClientLogInput) {
+ pendingClientLogs.push(payload)
+ if (pendingClientLogs.length > MAX_PENDING_CLIENT_LOGS) {
+ pendingClientLogs.shift()
+ }
+}
+
+function flushNextClientLog() {
+ if (clientLogFlushInProgress || !pendingClientLogs.length) {
+ return
+ }
+
+ const baseUrl = loadBackendBaseUrl()
+ if (!baseUrl) {
+ pendingClientLogs.length = 0
+ return
+ }
+
+ const payload = pendingClientLogs.shift()
+ if (!payload) {
+ return
+ }
+
+ clientLogFlushInProgress = true
+ postClientLog({
+ baseUrl,
+ payload,
+ }).catch(() => {
+ // 联调日志不打断主流程,失败时静默丢弃。
+ }).finally(() => {
+ clientLogFlushInProgress = false
+ if (pendingClientLogs.length) {
+ flushNextClientLog()
+ }
+ })
+}
+
+export function reportBackendClientLog(entry: ClientLogEntry) {
+ clientLogSequence += 1
+ const details = entry.details ? { ...entry.details } : {}
+ details.seq = clientLogSequence
+ const payload: BackendClientLogInput = {
+ source: CLIENT_LOG_SOURCE,
+ level: entry.level,
+ category: entry.category,
+ message: entry.message,
+ eventId: entry.eventId || '',
+ releaseId: entry.releaseId || '',
+ sessionId: entry.sessionId || '',
+ manifestUrl: entry.manifestUrl || '',
+ route: entry.route || getCurrentRoute(),
+ occurredAt: new Date().toISOString(),
+ details,
+ }
+ enqueueClientLog(payload)
+ flushNextClientLog()
+}
diff --git a/miniprogram/utils/backendLaunchAdapter.ts b/miniprogram/utils/backendLaunchAdapter.ts
index 5bc9c60..c194a4f 100644
--- a/miniprogram/utils/backendLaunchAdapter.ts
+++ b/miniprogram/utils/backendLaunchAdapter.ts
@@ -21,6 +21,18 @@ export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult):
sessionToken: result.launch.business.sessionToken,
sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt,
},
+ resolvedRelease: result.launch.resolvedRelease
+ ? {
+ launchMode: result.launch.resolvedRelease.launchMode || null,
+ source: result.launch.resolvedRelease.source || null,
+ eventId: result.launch.resolvedRelease.eventId || null,
+ releaseId: result.launch.resolvedRelease.releaseId || null,
+ configLabel: result.launch.resolvedRelease.configLabel || null,
+ manifestUrl: result.launch.resolvedRelease.manifestUrl || null,
+ manifestChecksumSha256: result.launch.resolvedRelease.manifestChecksumSha256 || null,
+ routeCode: result.launch.resolvedRelease.routeCode || null,
+ }
+ : null,
variant: result.launch.variant
? {
variantId: result.launch.variant.id,
diff --git a/miniprogram/utils/gameLaunch.ts b/miniprogram/utils/gameLaunch.ts
index ed8d723..dd95f8b 100644
--- a/miniprogram/utils/gameLaunch.ts
+++ b/miniprogram/utils/gameLaunch.ts
@@ -9,6 +9,17 @@ export interface GameConfigLaunchRequest {
routeCode?: string | null
}
+export interface GameResolvedReleaseLaunchContext {
+ launchMode?: string | null
+ source?: string | null
+ eventId?: string | null
+ releaseId?: string | null
+ configLabel?: string | null
+ manifestUrl?: string | null
+ manifestChecksumSha256?: string | null
+ routeCode?: string | null
+}
+
export interface BusinessLaunchContext {
source: BusinessLaunchSource
competitionId?: string | null
@@ -56,6 +67,7 @@ export interface GameContentBundleLaunchContext {
export interface GameLaunchEnvelope {
config: GameConfigLaunchRequest
business: BusinessLaunchContext | null
+ resolvedRelease?: GameResolvedReleaseLaunchContext | null
variant?: GameVariantLaunchContext | null
runtime?: GameRuntimeLaunchContext | null
presentation?: GamePresentationLaunchContext | null
@@ -65,6 +77,7 @@ export interface GameLaunchEnvelope {
export interface MapPageLaunchOptions {
launchId?: string
recoverSession?: string
+ autoStartOnEnter?: string
preset?: string
configUrl?: string
configLabel?: string
@@ -292,6 +305,7 @@ export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): G
business: {
source: 'demo',
},
+ resolvedRelease: null,
variant: null,
runtime: null,
presentation: null,
@@ -324,12 +338,24 @@ export function consumePendingGameLaunchEnvelope(launchId: string): GameLaunchEn
return envelope
}
-export function buildMapPageUrlWithLaunchId(launchId: string): string {
- return `/pages/map/map?launchId=${encodeURIComponent(launchId)}`
+export function buildMapPageUrlWithLaunchId(launchId: string, extraQuery?: Record): string {
+ const queryParts = [`launchId=${encodeURIComponent(launchId)}`]
+ if (extraQuery) {
+ Object.keys(extraQuery).forEach((key) => {
+ const value = extraQuery[key]
+ if (typeof value === 'string' && value) {
+ queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
+ }
+ })
+ }
+ return `/pages/map/map?${queryParts.join('&')}`
}
export function prepareMapPageUrlForLaunch(envelope: GameLaunchEnvelope): string {
- return buildMapPageUrlWithLaunchId(stashPendingGameLaunchEnvelope(envelope))
+ return buildMapPageUrlWithLaunchId(
+ stashPendingGameLaunchEnvelope(envelope),
+ { autoStartOnEnter: '1' },
+ )
}
export function prepareMapPageUrlForRecovery(envelope: GameLaunchEnvelope): string {
@@ -367,6 +393,7 @@ export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null)
routeCode: normalizeOptionalString(options ? options.routeCode : undefined),
},
business: buildBusinessLaunchContext(options),
+ resolvedRelease: null,
variant: buildVariantLaunchContext(options),
runtime: buildRuntimeLaunchContext(options),
presentation: buildPresentationLaunchContext(options),
diff --git a/miniprogram/utils/globalMockDebugBridge.ts b/miniprogram/utils/globalMockDebugBridge.ts
new file mode 100644
index 0000000..668ae48
--- /dev/null
+++ b/miniprogram/utils/globalMockDebugBridge.ts
@@ -0,0 +1,88 @@
+import { MockSimulatorDebugLogger, type MockSimulatorDebugLogLevel } from '../engine/debug/mockSimulatorDebugLogger'
+
+const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
+const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
+const DEBUG_MOCK_LOG_URL_STORAGE_KEY = 'cmr.debug.logBridgeUrl.v1'
+const DEFAULT_DEBUG_LOG_URL = 'wss://gs.gotomars.xyz/debug-log'
+
+let globalMockDebugLogger: MockSimulatorDebugLogger | null = null
+
+function ensureLogger(): MockSimulatorDebugLogger {
+ if (!globalMockDebugLogger) {
+ globalMockDebugLogger = new MockSimulatorDebugLogger()
+ }
+ return globalMockDebugLogger
+}
+
+export function loadStoredMockChannelIdForGlobalDebug(): string {
+ try {
+ const value = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY)
+ if (typeof value === 'string' && value.trim().length > 0) {
+ return value.trim()
+ }
+ } catch (_error) {
+ // Ignore storage read failures and fall back to default.
+ }
+ return 'default'
+}
+
+export function loadMockAutoConnectEnabledForGlobalDebug(): boolean {
+ try {
+ return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true
+ } catch (_error) {
+ return false
+ }
+}
+
+export function loadStoredMockDebugLogBridgeUrl(): string {
+ try {
+ const value = wx.getStorageSync(DEBUG_MOCK_LOG_URL_STORAGE_KEY)
+ if (typeof value === 'string' && value.trim().length > 0) {
+ return value.trim()
+ }
+ } catch (_error) {
+ // Ignore storage read failures and fall back to default.
+ }
+ return DEFAULT_DEBUG_LOG_URL
+}
+
+export function persistStoredMockDebugLogBridgeUrl(url: string) {
+ try {
+ wx.setStorageSync(DEBUG_MOCK_LOG_URL_STORAGE_KEY, url)
+ } catch (_error) {
+ // Ignore storage write failures.
+ }
+}
+
+export function syncGlobalMockDebugBridgeFromStorage(): void {
+ const logger = ensureLogger()
+ logger.setChannelId(loadStoredMockChannelIdForGlobalDebug())
+ logger.setUrl(loadStoredMockDebugLogBridgeUrl())
+ logger.setEnabled(loadMockAutoConnectEnabledForGlobalDebug())
+}
+
+export function setGlobalMockDebugBridgeChannelId(channelId: string): void {
+ const logger = ensureLogger()
+ logger.setChannelId(channelId)
+}
+
+export function setGlobalMockDebugBridgeEnabled(enabled: boolean): void {
+ const logger = ensureLogger()
+ logger.setEnabled(enabled)
+}
+
+export function setGlobalMockDebugBridgeUrl(url: string): void {
+ const logger = ensureLogger()
+ logger.setUrl(url)
+}
+
+export function emitGlobalMockDebugLog(
+ scope: string,
+ level: MockSimulatorDebugLogLevel,
+ message: string,
+ payload?: Record,
+): void {
+ const logger = ensureLogger()
+ logger.log(scope, level, message, payload)
+}
+
diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts
index b658a11..c8e3c93 100644
--- a/miniprogram/utils/remoteMapConfig.ts
+++ b/miniprogram/utils/remoteMapConfig.ts
@@ -68,6 +68,7 @@ export interface RemoteMapConfig {
configAppId: string
configSchemaVersion: string
configVersion: string
+ playfieldKind: string
tileSource: string
minZoom: number
maxZoom: number
@@ -122,6 +123,7 @@ interface ParsedGameConfig {
appId: string
schemaVersion: string
version: string
+ playfieldKind: string
mapRoot: string
mapMeta: string
course: string | null
@@ -1754,6 +1756,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1',
version: typeof parsed.version === 'string' ? parsed.version : '',
+ playfieldKind: rawPlayfield && typeof rawPlayfield.kind === 'string' ? rawPlayfield.kind : '',
mapRoot,
mapMeta,
course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string'
@@ -1855,6 +1858,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
appId: '',
schemaVersion: '1',
version: '',
+ playfieldKind: typeof config.playfieldkind === 'string' ? config.playfieldkind : '',
mapRoot,
mapMeta,
course: typeof config.course === 'string' ? config.course : null,
@@ -2157,6 +2161,7 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise 文档版本:v1.14
-> 最后更新:2026-04-03 14:16:17
+> 文档版本:v1.16
+> 最后更新:2026-04-03 16:59:19
文档维护约定:
@@ -13,6 +13,8 @@
当前补充约定:
- 多线程联调场景下,正式架构与长期结论优先沉淀到 `doc/`。
+- 当前联调架构的阶段总结见:
+ - [联调架构阶段总结](D:/dev/cmr-mini/doc/gameplay/联调架构阶段总结.md)
- 面向后端线程的阶段性实施说明,优先写入根目录 [t2b.md](D:/dev/cmr-mini/t2b.md)。
- backend 新增写给总控线程的回写板:
- [b2t.md](D:/dev/cmr-mini/b2t.md)
@@ -46,6 +48,12 @@
- `place / map asset / tile release / course source / course set / course variant / runtime binding`
- `一键补齐 Runtime 并发布` 已可从空白状态跑完整测试链
- `一键标准回归` 与 `回归结果汇总` 已接入 workbench
+ - `当前 Launch 实际配置摘要` 已接入 workbench
+ - `前端调试日志` 已接入 workbench
+ - 三类标准 demo 入口已显式挂出:
+ - `evt_demo_001`
+ - `evt_demo_score_o_001`
+ - `evt_demo_variant_manual_001`
- workbench 日志已具备:
- 分步日志
- 真实错误
@@ -54,13 +62,12 @@
- 预期判定
- 下一步建议:
- 联调标准化第一版视为已完成
- - 下一步进入“真实输入替换第一刀”
- - 逐步把 demo 输入替换成更接近生产的真实输入:
- - KML / 赛道文件
- - 地图资源 URL
- - 内容 manifest
- - presentation schema
- - 活动文案样例
+ - 当前主线进入“真实输入替换第二刀”
+ - 当前优先替换:
+ - `content manifest`
+ - `presentation schema`
+ - `活动文案样例`
+ - `KML / 赛道文件` 与 `地图资源 URL` 已接入,不再作为本轮重点
- backend 在联调标准化阶段应优先保证:
- 从空白环境直接可跑
- workbench 日志能明确定位失败步骤
@@ -83,8 +90,13 @@
- frontend 进入联调标准化配合与小范围修复阶段
- 只做字段修正、摘要打磨、一致性修复
- 优先复用 backend 一键测试环境做回归
+ - 优先复用:
+ - `回归结果汇总`
+ - `当前 Launch 实际配置摘要`
+ - `前端调试日志`
- 不继续扩新页面链
- 不做复杂运营样式
+ - 不启动活动卡片(列表)产品化开发
当前阶段的核心目标已经从“把地图画出来”升级为“建立一套可长期扩展的运动地图游戏底座”。
这套底座已经具备以下关键能力:
diff --git a/t2b.md b/t2b.md
index cedae95..95b9a1d 100644
--- a/t2b.md
+++ b/t2b.md
@@ -1,6 +1,6 @@
# T2B 协作清单
-> 文档版本:v1.11
-> 最后更新:2026-04-03 14:16:17
+> 文档版本:v1.12
+> 最后更新:2026-04-03 16:55:07
说明:
@@ -25,6 +25,11 @@ backend 当前已完成:
- `publish` 默认继承当前 active 三元组
- `Bootstrap Demo` 与 `一键补齐 Runtime 并发布` 已可从空白状态跑完整测试链
- `一键标准回归` 与 `回归结果汇总` 已接入标准联调入口
+- `前端调试日志` 与 `当前 Launch 实际配置摘要` 已接入 workbench
+- 三类标准 demo 入口已显式挂出:
+ - `evt_demo_001`
+ - `evt_demo_score_o_001`
+ - `evt_demo_variant_manual_001`
- workbench 日志已补齐:
- 分步日志
- 真实错误
@@ -42,16 +47,22 @@ backend 当前已完成:
2. 固化详细日志口径,失败时明确定位在哪一步
3. 固化稳定测试数据,并逐步支持更接近生产的真实输入
-当前认为“联调标准化第一版”已经基本到位,backend 下一步应进入:
+当前认为“联调标准化第一版”已经完成,backend 下一步应进入:
**真实输入替换第一刀**
优先顺序建议:
-1. 先替换真实 KML / 赛道文件
-2. 再替换真实地图资源 URL
-3. 再替换真实内容 manifest / presentation schema
-4. 最后再补真实活动文案样例
+1. 继续推进真实 `content manifest`
+2. 再推进真实 `presentation schema`
+3. 最后补真实 `活动文案样例`
+
+说明:
+
+- 真实 `KML / 赛道文件`
+- 真实 `地图资源 URL`
+
+这两类输入已接入,当前不再作为本轮重点。
原则:
@@ -59,6 +70,13 @@ backend 当前已完成:
- 不重新设计联调流程
- 只是把 demo 输入逐步换成更接近生产的真实输入
+当前 backend 不建议切去做:
+
+- 活动卡片列表产品化
+- 新的玩家侧页面入口
+- 更多管理对象
+- 更复杂后台 UI
+
当前进一步明确 backend 的执行口径如下:
### 0.1 一键测试链路
diff --git a/t2f.md b/t2f.md
index bb2bb02..86a0113 100644
--- a/t2f.md
+++ b/t2f.md
@@ -1,6 +1,6 @@
# T2F 协作清单
-> 文档版本:v1.6
-> 最后更新:2026-04-03 13:08:15
+> 文档版本:v1.7
+> 最后更新:2026-04-03 16:55:07
说明:
@@ -19,6 +19,10 @@
- 验证活动运营域摘要接线是否稳定
- 修正联调中发现的小范围字段、展示、一致性问题
- 使用 backend 当前统一的“一键测试环境”和稳定 demo 数据做回归
+- 使用 backend 当前统一的结构化诊断入口做回归:
+ - `回归结果汇总`
+ - `当前 Launch 实际配置摘要`
+ - `前端调试日志`
- 继续保持 runtime 主链稳定,不扩新页面链
---
@@ -48,7 +52,11 @@
- backend 当前测试能力已升级:
- `Bootstrap Demo`
- `一键补齐 Runtime 并发布`
+ - `一键标准回归`
+ - `回归结果汇总`
+ - `当前 Launch 实际配置摘要`
- 分步日志 / 真实错误 / stack / 最后一次 curl / 预期判定
+ - `POST /dev/client-logs`
---
@@ -93,6 +101,7 @@
- 不消费完整 `EventPresentation` 结构
- 不把 `ContentBundle` 展开成资源明细
- 不重构首页、结果页、历史页已有结构
+- 不启动活动卡片(列表)产品化开发
---
@@ -105,6 +114,7 @@
- 先做“看得见活动运营对象”,不先做复杂运营化样式
- 当前进入联调回归阶段,优先修问题,不主动扩新页面入口
- 当前联调应优先复用 backend 一键测试环境,不再各自手工铺多份 demo 对象
+- 当前联调应优先复用 backend 提供的结构化诊断链,不再依赖截图 + 口头描述排查
---
@@ -127,6 +137,10 @@
4. 不继续扩新页面链,不做复杂运营样式
5. 如果前端发现缺字段,再由总控统一回写给 backend
6. 当前前端下一步重点是配合 backend 的一键测试环境做稳定回归,不再新增玩家侧功能入口
+7. 当前前端继续只做:
+ - 联调回归
+ - 小范围修复
+ - 结构化日志补充
---
diff --git a/tools/runtime-smoke-test.ts b/tools/runtime-smoke-test.ts
index 29b149b..368daf6 100644
--- a/tools/runtime-smoke-test.ts
+++ b/tools/runtime-smoke-test.ts
@@ -307,6 +307,16 @@ function testLaunchRuntimeAdapter(): void {
},
launch: {
source: 'event',
+ resolvedRelease: {
+ launchMode: 'formal-release',
+ source: 'current-release',
+ eventId: 'evt_demo_variant_manual_001',
+ releaseId: 'rel_runtime_001',
+ configLabel: 'runtime demo',
+ manifestUrl: 'https://example.com/releases/rel_runtime_001/manifest.json',
+ manifestChecksumSha256: 'manifest-sha-001',
+ routeCode: 'route-variant-b',
+ },
config: {
configUrl: 'https://example.com/runtime.json',
configLabel: 'runtime demo',
@@ -352,6 +362,9 @@ function testLaunchRuntimeAdapter(): void {
}
const envelope = adaptBackendLaunchResultToEnvelope(launchResult)
+ assert(!!envelope.resolvedRelease, 'resolvedRelease 应映射到 GameLaunchEnvelope.resolvedRelease')
+ assert(envelope.resolvedRelease!.manifestUrl === 'https://example.com/releases/rel_runtime_001/manifest.json', 'resolvedRelease.manifestUrl 应正确适配')
+ assert(envelope.resolvedRelease!.releaseId === 'rel_runtime_001', 'resolvedRelease.releaseId 应正确适配')
assert(!!envelope.runtime, 'launch.runtime 应映射到 GameLaunchEnvelope.runtime')
assert(envelope.runtime!.runtimeBindingId === 'rtb_001', 'runtimeBindingId 应正确适配')
assert(envelope.runtime!.placeName === '示范校园', 'placeName 应正确适配')