Browse Source

完善多赛道联调与全局产品架构

zhangyan 6 days ago
parent
commit
0e28f70bad
45 changed files with 4326 additions and 278 deletions
  1. 137 2
      b2f.md
  2. 5 2
      backend/README.md
  3. 34 23
      backend/docs/todolist.md
  4. 7 2
      backend/docs/开发说明.md
  5. 28 2
      backend/docs/接口清单.md
  6. 34 2
      backend/docs/核心流程.md
  7. 732 43
      backend/internal/httpapi/handlers/dev_handler.go
  8. 8 4
      backend/internal/service/entry_home_service.go
  9. 7 0
      backend/internal/service/event_play_service.go
  10. 28 3
      backend/internal/service/event_service.go
  11. 6 0
      backend/internal/service/session_service.go
  12. 189 0
      backend/internal/service/variant_contract.go
  13. 151 16
      backend/internal/store/postgres/dev_store.go
  14. 22 5
      backend/internal/store/postgres/event_store.go
  15. 12 0
      backend/internal/store/postgres/result_store.go
  16. 21 0
      backend/internal/store/postgres/session_store.go
  17. 11 0
      backend/migrations/0007_variant_minimal.sql
  18. 12 1
      backend/scripts/start-dev.ps1
  19. 1 1
      backend/start-backend.ps1
  20. 324 0
      doc/gameplay/APP全局产品架构草案.md
  21. 383 0
      doc/gameplay/多赛道Variant五层设计草案.md
  22. 294 0
      doc/gameplay/多赛道Variant前后端最小契约.md
  23. 3 1
      doc/gameplay/游戏规则架构.md
  24. 4 1
      doc/文档索引.md
  25. 107 18
      f2b.md
  26. 2 0
      miniprogram/app.json
  27. 2 0
      miniprogram/app.ts
  28. 575 0
      miniprogram/pages/event-prepare/event-prepare.ts
  29. 105 0
      miniprogram/pages/event-prepare/event-prepare.wxml
  30. 276 0
      miniprogram/pages/event-prepare/event-prepare.wxss
  31. 34 30
      miniprogram/pages/event/event.ts
  32. 3 1
      miniprogram/pages/event/event.wxml
  33. 14 7
      miniprogram/pages/home/home.ts
  34. 222 31
      miniprogram/pages/map/map.ts
  35. 50 23
      miniprogram/pages/map/map.wxml
  36. 145 0
      miniprogram/pages/map/map.wxss
  37. 56 49
      miniprogram/pages/result/result.ts
  38. 1 11
      miniprogram/pages/result/result.wxml
  39. 104 0
      miniprogram/pages/results/results.ts
  40. 25 0
      miniprogram/pages/results/results.wxml
  41. 76 0
      miniprogram/pages/results/results.wxss
  42. 24 0
      miniprogram/utils/backendApi.ts
  43. 12 0
      miniprogram/utils/backendLaunchAdapter.ts
  44. 35 0
      miniprogram/utils/gameLaunch.ts
  45. 5 0
      typings/index.d.ts

+ 137 - 2
b2f.md

@@ -1,6 +1,6 @@
 # b2f
-> 文档版本:v1.0
-> 最后更新:2026-04-02 09:01:17
+> 文档版本:v1.3
+> 最后更新:2026-04-02 15:25:40
 
 
 说明:
@@ -54,6 +54,51 @@
   - frontend 是否确认正式流程只消费上述字段,不再自行推断 release URL
 - 是否已解决:否
 
+### B2F-015
+
+- 时间:2026-04-02
+- 谁提的:backend
+- 当前事实:
+  - backend 已阅读前端多赛道文档:
+    - [多赛道 Variant 五层设计草案](D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
+    - [多赛道 Variant 前后端最小契约](D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
+  - backend 认可第一阶段先做“最小契约”,不先做完整后台模型
+  - backend 当前建议的第一阶段正式口径为:
+    - `play.assignmentMode`
+    - `play.courseVariants[]`
+      - `id`
+      - `name`
+      - `description`
+      - `routeCode`
+      - `selectable`
+    - `launch.variant.id`
+    - `launch.variant.name`
+    - `launch.variant.routeCode`
+    - `launch.variant.assignmentMode`
+    - `session / ongoing / recent / result` 摘要中补:
+      - `variantId`
+      - `variantName`
+      - `routeCode`
+  - backend 第一阶段实现目标仍然保持保守:
+    - 一个 session 只绑定一个最终 `variantId`
+    - `launch` 返回最终绑定结果
+    - 恢复链不重新分配 variant
+  - 当前兼容性约束:
+    - 如果 `assignmentMode=manual` 且前端暂时未传 `variantId`
+    - backend 当前会先回退到首个可选 variant,避免旧主链直接被打断
+  - backend 当前已完成第一阶段最小实现:
+    - `GET /events/{eventPublicID}/play`
+    - `POST /events/{eventPublicID}/launch`
+    - `GET /me/entry-home`
+    - `GET /sessions/{sessionPublicID}`
+    - `GET /sessions/{sessionPublicID}/result`
+    - `GET /me/results`
+    - `GET /me/sessions`
+    - 上述链路已能携带第一阶段 variant 摘要字段
+- 需要对方确认什么:
+  - frontend 可按这组字段开始第一阶段联调
+- 是否已解决:是
+
 ---
 
 ## 已确认
@@ -121,6 +166,49 @@
   - frontend 继续按当前补报 / 重试逻辑联调
 - 是否已解决:是
 
+### B2F-016
+
+- 时间:2026-04-02
+- 谁提的:backend
+- 当前事实:
+  - backend 已确认 `launch` 当前关键字段为前端正式联调契约:
+    - `resolvedRelease.manifestUrl`
+    - `resolvedRelease.releaseId`
+    - `business.sessionId`
+    - `business.sessionToken`
+    - `business.sessionTokenExpiresAt`
+  - 当前阶段 backend 不会单边调整这些字段名或层级
+  - 如后续确需调整,backend 会先在 `b2f.md` 明确通知,再安排联调变更
+- 需要对方确认什么:
+  - frontend 继续按当前字段接入,不做额外推断
+- 是否已解决:是
+
+### B2F-017
+
+- 时间:2026-04-02
+- 谁提的:backend
+- 当前事实:
+  - backend 已完成对 ongoing 口径的代码回归确认
+  - 当前实现中:
+    - 只有 `launched` 和 `running` 会被识别为 ongoing
+    - `cancelled`、`failed`、`finished` 都不会再进入 ongoing
+  - `/me/entry-home` 与 `/events/{eventPublicID}/play` 当前都复用同一 ongoing 判定逻辑
+  - `/me/results` 当前只返回终态 session:
+    - `finished`
+    - `failed`
+    - `cancelled`
+  - 当前首页摘要、play 摘要、result 详情都会复用同一组 session 基础摘要字段:
+    - `id`
+    - `status`
+    - `eventId`
+    - `eventName`
+    - `releaseId`
+    - `configLabel`
+    - `routeCode`
+- 需要对方确认什么:
+  - frontend 可以按这套 ongoing / result 口径继续回归
+- 是否已解决:是
+
 ---
 
 ## 阻塞
@@ -226,6 +314,52 @@
   - 无
 - 是否已解决:是
 
+### B2F-018
+
+- 时间:2026-04-02
+- 谁提的:backend
+- 当前事实:
+  - backend 已补一条可联调的 `manual` 多赛道 demo 活动:
+    - `eventPublicID = evt_demo_variant_manual_001`
+    - `releaseId = rel_demo_variant_manual_001`
+    - `channelCode = mini-demo`
+    - `channelType = wechat_mini`
+  - 当前 demo 配置为:
+    - `assignmentMode = manual`
+    - `courseVariants = [variant_a, variant_b]`
+  - 当前两条可选赛道:
+    - `variant_a`
+      - `name = A 线`
+      - `routeCode = route-variant-a`
+    - `variant_b`
+      - `name = B 线`
+      - `routeCode = route-variant-b`
+  - 该活动已由 `POST /dev/bootstrap-demo` 自动准备
+- 需要对方确认什么:
+  - 无
+- 是否已解决:是
+
+### B2F-019
+
+- 时间:2026-04-02
+- 谁提的:backend
+- 当前事实:
+  - backend 已完成 `variant_b` 的 service 层回归验证
+  - 已确认从 `launch` 选定的 `variantId` 会稳定回流到:
+    - `GET /me/entry-home`
+    - `GET /sessions/{sessionPublicID}/result`
+    - `GET /me/results`
+  - 实测链路为:
+    - `play.assignmentMode=manual`
+    - `play.courseVariants=2`
+    - `launch.variant.id=variant_b`
+    - `entry-home recent.variantId=variant_b`
+    - `result.session.variantId=variant_b`
+    - `results[0].session.variantId=variant_b`
+- 需要对方确认什么:
+  - 无
+- 是否已解决:是
+
 ---
 
 ## 下一步
@@ -244,6 +378,7 @@
   - frontend 当前优先配合:
     - 用当前 demo release 回归 `play -> launch -> map load`
     - 回归“继续恢复 / 放弃恢复”两条路径
+    - 如确认进入多赛道第一阶段联调,请先回复 `B2F-015`
     - 如发现状态口径不一致,直接在 `f2b.md` 标具体接口和返回值
 - 是否已解决:否
 

+ 5 - 2
backend/README.md

@@ -1,6 +1,6 @@
 # Backend
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.1
+> 最后更新:2026-04-02 09:35:44
 
 
 这套后端现在已经能支撑一条完整主链:
@@ -46,5 +46,8 @@ go run .\cmd\api
 - 局生命周期:`start / finish / detail`
 - 局后结果:`/sessions/{id}/result`、`/me/results`
 - 开发工作台:`/dev/workbench`
+  - 用户主链调试
+  - 资源对象与 Event 组装调试
+  - Build / Publish / Rollback 调试
 
 

+ 34 - 23
backend/docs/todolist.md

@@ -1,6 +1,6 @@
 # Backend TodoList
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.2
+> 最后更新:2026-04-02 11:03:02
 
 
 ## 1. 目标
@@ -37,6 +37,8 @@
 
 - `evt_demo_001` 的 release manifest 现已可正常加载
 - 小程序已能进入地图
+- `launch` 关键字段在当前阶段不再单边漂移
+- `cancelled / failed / finished` 已从 ongoing 口径里收稳
 - 模拟定位 / 调试日志问题已回到小程序与模拟器侧,不再属于 backend 当前阻塞
 
 前端当前需要配合的事项:
@@ -160,27 +162,36 @@ backend 现在需要做的是:
 
 ## 4. P1 应尽快做
 
-## 4.1 给首页 / play / result 的 ongoing 语义再做一次回归确认
+## 4.1 多赛道 Variant 第一阶段最小契约
 
-当前前端已经开始走
+当前前端已给出
 
-- 首页聚合
-- `event play`
-- `launch`
-- `session start / finish`
-- 本地故障恢复
+- [多赛道 Variant 五层设计草案](D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
+- [多赛道 Variant 前后端最小契约](D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
 
-backend 建议再回归确认这几个接口对“进行中 session”的口径一致
+backend 当前建议第一阶段只做最小闭环:
 
-- `/me/entry-home`
-- `/events/{eventPublicID}/play`
-- `/sessions/{sessionPublicID}/result`
+- `play.assignmentMode`
+- `play.courseVariants[]`
+- `launch.variant.*`
+- `session / result / ongoing / recent` 补 `variantId / variantName / routeCode`
 
-重点确认
+当前目标
 
-1. `cancelled` 后不再继续出现在 ongoing 入口
-2. `failed` 后不再继续出现在 ongoing 入口
-3. `finished` 后结果页与首页摘要字段一致
+1. 一个 session 最终只绑定一个 `variantId`
+2. `launch` 返回最终绑定结果
+3. 恢复链不重新分配 variant
+4. 结果页、ongoing、历史结果都能追溯 variant
+
+备注:
+
+- 当前只先定最小契约,不先做完整后台 variant 编排模型
+- 当前第一阶段最小后端链路已补入:
+  - `play.assignmentMode`
+  - `play.courseVariants[]`
+  - `launch.variant.*`
+  - `session / result / ongoing / recent` 的 `variantId / variantName / routeCode`
+- 下一步应由前端按该契约联调,不再继续扩后台 variant 模型
 
 ## 4.2 增加用户身体资料读取接口
 
@@ -318,18 +329,18 @@ backend 后面如果要接业务结果页,最好提前定:
 
 ## 7. 我建议的最近动作
 
-backend 现在最值得先做的,不是扩接口,而是先确认下面 3 条:
+backend 现在最值得先做的,不是继续铺更多页面接口,而是先推进下面 3 条:
 
-1. `finished / failed / cancelled` 三态语义
-2. 放弃恢复是否写 `cancelled`
-3. `start / finish` 是否按幂等处理
+1. 与前端确认多赛道第一阶段最小契约
+2. 已按最小契约扩完 `play -> launch -> session/result`
+3. 再补用户身体资料接口和 workbench 恢复场景按钮
 
-这 3 条一旦确定,前后端联调会顺很多
+这样不会打断当前主链,同时能把下一阶段多赛道联调接上
 
 ## 8. 一句话结论
 
 当前 backend 最重要的任务不是“再加更多接口”,而是:
 
-> 先把 session 运行态语义、放弃恢复语义和 ongoing session 口径定稳,再继续扩后台配置系统
+> 在不破坏当前稳定主链的前提下,先把多赛道 Variant 第一阶段最小契约定稳,再继续向配置与后台模型延伸
 
 

+ 7 - 2
backend/docs/开发说明.md

@@ -1,6 +1,6 @@
 # 开发说明
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.1
+> 最后更新:2026-04-02 09:35:44
 
 
 ## 1. 环境变量
@@ -51,6 +51,11 @@ cd D:\dev\cmr-mini\backend
 
 - [http://127.0.0.1:18090/dev/workbench](http://127.0.0.1:18090/dev/workbench)
 
+当前 workbench 已覆盖两类调试链:
+
+- 用户主链:`bootstrap -> auth -> entry/home -> event play/launch -> session -> result`
+- 后台运营链:`maps/playfields/resource-packs -> admin event source -> build -> publish -> rollback`
+
 ## 3. 当前开发约定
 
 ### 3.1 开发阶段先不用 Redis

+ 28 - 2
backend/docs/接口清单.md

@@ -1,6 +1,6 @@
 # API 清单
-> 文档版本:v1.0
-> 最后更新:2026-04-02 09:01:17
+> 文档版本:v1.1
+> 最后更新:2026-04-02 11:05:32
 
 
 本文档只记录当前 backend 已实现接口,不写未来规划接口。
@@ -121,6 +121,12 @@
 - `ongoingSession`
 - `recentSession`
 
+`ongoingSession / recentSession` 当前会额外带:
+
+- `variantId`
+- `variantName`
+- `routeCode`
+
 ## 4. Event
 
 ### `GET /events/{eventPublicID}`
@@ -150,6 +156,8 @@
 - `event`
 - `release`
 - `resolvedRelease`
+- `play.assignmentMode`
+- `play.courseVariants`
 - `play.canLaunch`
 - `play.primaryAction`
 - `play.launchSource`
@@ -169,13 +177,21 @@
 请求体重点:
 
 - `releaseId`
+- `variantId`
 - `clientType`
 - `deviceKey`
 
+补充说明:
+
+- 如果当前 release 声明了 `play.courseVariants[]`
+- `launch` 会返回最终绑定的 `launch.variant`
+- 当前为兼容旧调用方,`assignmentMode=manual` 且未传 `variantId` 时,backend 会先回退到首个可选 variant
+
 返回重点:
 
 - `launch.source`
 - `launch.resolvedRelease`
+- `launch.variant`
 - `launch.config`
 - `launch.business.sessionId`
 - `launch.business.sessionToken`
@@ -228,6 +244,13 @@
 - `event`
 - `resolvedRelease`
 
+`session` 当前会额外带:
+
+- `assignmentMode`
+- `variantId`
+- `variantName`
+- `routeCode`
+
 ### `POST /sessions/{sessionPublicID}/start`
 
 鉴权:
@@ -312,6 +335,9 @@
 
 - `releaseId`
 - `configLabel`
+- `variantId`
+- `variantName`
+- `routeCode`
 
 ### `GET /me/results`
 

+ 34 - 2
backend/docs/核心流程.md

@@ -1,6 +1,6 @@
 # 核心流程
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.1
+> 最后更新:2026-04-02 11:03:02
 
 
 ## 1. 总流程
@@ -100,6 +100,7 @@ APP 当前主链是手机号验证码:
 
 - 当前是否可启动
 - 当前会落到哪份 `release`
+- 当前是否存在多赛道 `variant` 编排
 - 是否有 ongoing session
 - 当前推荐动作是什么
 
@@ -112,12 +113,27 @@ APP 当前主链是手机号验证码:
 - `event`
 - `release`
 - `resolvedRelease`
+- `play.assignmentMode`
+- `play.courseVariants[]`
 - `play.canLaunch`
 - `play.primaryAction`
 - `play.launchSource`
 - `play.ongoingSession`
 - `play.recentSession`
 
+当前多赛道第一阶段约束:
+
+- `play.assignmentMode` 只先支持最小口径:
+  - `manual`
+  - `random`
+  - `server-assigned`
+- `play.courseVariants[]` 只先返回准备页必需字段:
+  - `id`
+  - `name`
+  - `description`
+  - `routeCode`
+  - `selectable`
+
 ## 6. Launch 流程
 
 ### 6.1 当前原则
@@ -135,6 +151,7 @@ APP 当前主链是手机号验证码:
 当前请求体支持:
 
 - `releaseId`
+- `variantId`
 - `clientType`
 - `deviceKey`
 
@@ -142,6 +159,7 @@ APP 当前主链是手机号验证码:
 
 - `launch.source`
 - `launch.resolvedRelease`
+- `launch.variant`
 - `launch.config`
 - `launch.business.sessionId`
 - `launch.business.sessionToken`
@@ -158,6 +176,14 @@ APP 当前主链是手机号验证码:
 - `launch.resolvedRelease.releaseId`
 - `launch.resolvedRelease.manifestUrl`
 - `launch.resolvedRelease.manifestChecksumSha256`
+- `launch.variant.id`
+- `launch.variant.assignmentMode`
+
+补充说明:
+
+- 如果活动声明了多赛道 variant,`launch` 会返回本局最终绑定的 `variant`
+- 前端可以发起选择,但最终绑定以后端 `launch` 返回为准
+- 故障恢复不重新分配 variant
 
 而不是再拿 `event` 自己去猜。
 
@@ -195,6 +221,11 @@ APP 当前主链是手机号验证码:
 - `cancelled` 和 `failed` 都不再作为 ongoing session 返回
 - “放弃恢复”当前正式收口为 `finish(cancelled)`
 - 同一局旧 `sessionToken` 在 `finish(cancelled)` 场景允许继续使用
+- 第一阶段若活动声明了多赛道,session 会固化:
+  - `assignmentMode`
+  - `variantId`
+  - `variantName`
+  - `routeCode`
 
 ### 7.4 幂等要求
 
@@ -232,6 +263,7 @@ APP 当前主链是手机号验证码:
 
 - 一个 event 未来可能发布新版本
 - 历史结果必须追溯到当时真实跑过的那份 release
+- 如果一场活动存在多个 variant,结果与历史摘要也必须能追溯本局 `variantId`
 
 ## 9. 当前最应该坚持的流程约束
 

File diff suppressed because it is too large
+ 732 - 43
backend/internal/httpapi/handlers/dev_handler.go


+ 8 - 4
backend/internal/service/entry_home_service.go

@@ -55,6 +55,8 @@ type EntrySessionSummary struct {
 	EventName   string  `json:"eventName"`
 	ReleaseID   *string `json:"releaseId,omitempty"`
 	ConfigLabel *string `json:"configLabel,omitempty"`
+	VariantID   *string `json:"variantId,omitempty"`
+	VariantName *string `json:"variantName,omitempty"`
 	RouteCode   *string `json:"routeCode,omitempty"`
 	LaunchedAt  string  `json:"launchedAt"`
 	StartedAt   *string `json:"startedAt,omitempty"`
@@ -139,10 +141,12 @@ func (s *EntryHomeService) GetEntryHome(ctx context.Context, input EntryHomeInpu
 
 func buildEntrySessionSummary(session *postgres.Session) EntrySessionSummary {
 	summary := EntrySessionSummary{
-		ID:         session.SessionPublicID,
-		Status:     session.Status,
-		RouteCode:  session.RouteCode,
-		LaunchedAt: session.LaunchedAt.Format(timeRFC3339),
+		ID:          session.SessionPublicID,
+		Status:      session.Status,
+		VariantID:   session.VariantID,
+		VariantName: session.VariantName,
+		RouteCode:   session.RouteCode,
+		LaunchedAt:  session.LaunchedAt.Format(timeRFC3339),
 	}
 	if session.EventPublicID != nil {
 		summary.EventID = *session.EventPublicID

+ 7 - 0
backend/internal/service/event_play_service.go

@@ -35,6 +35,8 @@ type EventPlayResult struct {
 	} `json:"release,omitempty"`
 	ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
 	Play            struct {
+		AssignmentMode *string              `json:"assignmentMode,omitempty"`
+		CourseVariants []CourseVariantView  `json:"courseVariants,omitempty"`
 		CanLaunch      bool                 `json:"canLaunch"`
 		PrimaryAction  string               `json:"primaryAction"`
 		Reason         string               `json:"reason"`
@@ -77,6 +79,11 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
 	result.Event.DisplayName = event.DisplayName
 	result.Event.Summary = event.Summary
 	result.Event.Status = event.Status
+	variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
+	result.Play.AssignmentMode = variantPlan.AssignmentMode
+	if len(variantPlan.CourseVariants) > 0 {
+		result.Play.CourseVariants = variantPlan.CourseVariants
+	}
 	if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
 		result.Release = &struct {
 			ID                     string  `json:"id"`

+ 28 - 3
backend/internal/service/event_service.go

@@ -37,6 +37,7 @@ type LaunchEventInput struct {
 	EventPublicID string
 	UserID        string
 	ReleaseID     string `json:"releaseId,omitempty"`
+	VariantID     string `json:"variantId,omitempty"`
 	ClientType    string `json:"clientType"`
 	DeviceKey     string `json:"deviceKey"`
 }
@@ -49,6 +50,7 @@ type LaunchEventResult struct {
 	Launch struct {
 		Source          string               `json:"source"`
 		ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
+		Variant         *VariantBindingView  `json:"variant,omitempty"`
 		Config          struct {
 			ConfigURL            string  `json:"configUrl"`
 			ConfigLabel          string  `json:"configLabel"`
@@ -115,6 +117,7 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string)
 func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) (*LaunchEventResult, error) {
 	input.EventPublicID = strings.TrimSpace(input.EventPublicID)
 	input.ReleaseID = strings.TrimSpace(input.ReleaseID)
+	input.VariantID = strings.TrimSpace(input.VariantID)
 	input.DeviceKey = strings.TrimSpace(input.DeviceKey)
 	if err := validateClientType(input.ClientType); err != nil {
 		return nil, err
@@ -139,6 +142,24 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
 	if input.ReleaseID != "" && input.ReleaseID != *event.CurrentReleasePubID {
 		return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
 	}
+	variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
+	variant, err := resolveLaunchVariant(variantPlan, input.VariantID)
+	if err != nil {
+		return nil, err
+	}
+	routeCode := event.RouteCode
+	var assignmentMode *string
+	var variantID *string
+	var variantName *string
+	if variant != nil {
+		resultMode := variant.AssignmentMode
+		assignmentMode = &resultMode
+		variantID = &variant.ID
+		variantName = &variant.Name
+		if variant.RouteCode != nil {
+			routeCode = variant.RouteCode
+		}
+	}
 
 	tx, err := s.store.Begin(ctx)
 	if err != nil {
@@ -163,7 +184,10 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
 		EventReleaseID:        *event.CurrentReleaseID,
 		DeviceKey:             input.DeviceKey,
 		ClientType:            input.ClientType,
-		RouteCode:             event.RouteCode,
+		AssignmentMode:        assignmentMode,
+		VariantID:             variantID,
+		VariantName:           variantName,
+		RouteCode:             routeCode,
 		SessionTokenHash:      security.HashText(sessionToken),
 		SessionTokenExpiresAt: sessionTokenExpiresAt,
 	})
@@ -180,16 +204,17 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
 	result.Event.DisplayName = event.DisplayName
 	result.Launch.Source = LaunchSourceEventCurrentRelease
 	result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
+	result.Launch.Variant = variant
 	result.Launch.Config.ConfigURL = *event.ManifestURL
 	result.Launch.Config.ConfigLabel = *event.ConfigLabel
 	result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum
 	result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
-	result.Launch.Config.RouteCode = event.RouteCode
+	result.Launch.Config.RouteCode = routeCode
 	result.Launch.Business.Source = "direct-event"
 	result.Launch.Business.EventID = event.PublicID
 	result.Launch.Business.SessionID = session.SessionPublicID
 	result.Launch.Business.SessionToken = sessionToken
 	result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
-	result.Launch.Business.RouteCode = event.RouteCode
+	result.Launch.Business.RouteCode = routeCode
 	return result, nil
 }

+ 6 - 0
backend/internal/service/session_service.go

@@ -26,6 +26,9 @@ type SessionResult struct {
 		Status                string  `json:"status"`
 		ClientType            string  `json:"clientType"`
 		DeviceKey             string  `json:"deviceKey"`
+		AssignmentMode        *string `json:"assignmentMode,omitempty"`
+		VariantID             *string `json:"variantId,omitempty"`
+		VariantName           *string `json:"variantName,omitempty"`
 		RouteCode             *string `json:"routeCode,omitempty"`
 		SessionTokenExpiresAt string  `json:"sessionTokenExpiresAt"`
 		LaunchedAt            string  `json:"launchedAt"`
@@ -264,6 +267,9 @@ func buildSessionResult(session *postgres.Session) *SessionResult {
 	result.Session.Status = session.Status
 	result.Session.ClientType = session.ClientType
 	result.Session.DeviceKey = session.DeviceKey
+	result.Session.AssignmentMode = session.AssignmentMode
+	result.Session.VariantID = session.VariantID
+	result.Session.VariantName = session.VariantName
 	result.Session.RouteCode = session.RouteCode
 	result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
 	result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339)

+ 189 - 0
backend/internal/service/variant_contract.go

@@ -0,0 +1,189 @@
+package service
+
+import (
+	"crypto/rand"
+	"encoding/json"
+	"fmt"
+	"math/big"
+	"net/http"
+	"strings"
+
+	"cmr-backend/internal/apperr"
+)
+
+const (
+	AssignmentModeManual         = "manual"
+	AssignmentModeRandom         = "random"
+	AssignmentModeServerAssigned = "server-assigned"
+)
+
+type CourseVariantView struct {
+	ID          string  `json:"id"`
+	Name        string  `json:"name"`
+	Description *string `json:"description,omitempty"`
+	RouteCode   *string `json:"routeCode,omitempty"`
+	Selectable  bool    `json:"selectable"`
+}
+
+type VariantBindingView struct {
+	ID             string  `json:"id"`
+	Name           string  `json:"name"`
+	RouteCode      *string `json:"routeCode,omitempty"`
+	AssignmentMode string  `json:"assignmentMode"`
+}
+
+type VariantPlan struct {
+	AssignmentMode *string
+	CourseVariants []CourseVariantView
+}
+
+func resolveVariantPlan(payloadJSON *string) VariantPlan {
+	if payloadJSON == nil || strings.TrimSpace(*payloadJSON) == "" {
+		return VariantPlan{}
+	}
+
+	var payload map[string]any
+	if err := json.Unmarshal([]byte(*payloadJSON), &payload); err != nil {
+		return VariantPlan{}
+	}
+
+	play, _ := payload["play"].(map[string]any)
+	if len(play) == 0 {
+		return VariantPlan{}
+	}
+
+	result := VariantPlan{}
+	if rawMode, ok := play["assignmentMode"].(string); ok {
+		if normalized := normalizeAssignmentMode(rawMode); normalized != nil {
+			result.AssignmentMode = normalized
+		}
+	}
+
+	rawVariants, _ := play["courseVariants"].([]any)
+	if len(rawVariants) == 0 {
+		return result
+	}
+
+	for _, raw := range rawVariants {
+		item, ok := raw.(map[string]any)
+		if !ok {
+			continue
+		}
+		id, _ := item["id"].(string)
+		name, _ := item["name"].(string)
+		id = strings.TrimSpace(id)
+		name = strings.TrimSpace(name)
+		if id == "" || name == "" {
+			continue
+		}
+		var description *string
+		if value, ok := item["description"].(string); ok && strings.TrimSpace(value) != "" {
+			trimmed := strings.TrimSpace(value)
+			description = &trimmed
+		}
+		var routeCode *string
+		if value, ok := item["routeCode"].(string); ok && strings.TrimSpace(value) != "" {
+			trimmed := strings.TrimSpace(value)
+			routeCode = &trimmed
+		}
+		selectable := true
+		if value, ok := item["selectable"].(bool); ok {
+			selectable = value
+		}
+		result.CourseVariants = append(result.CourseVariants, CourseVariantView{
+			ID:          id,
+			Name:        name,
+			Description: description,
+			RouteCode:   routeCode,
+			Selectable:  selectable,
+		})
+	}
+
+	return result
+}
+
+func resolveLaunchVariant(plan VariantPlan, requestedVariantID string) (*VariantBindingView, error) {
+	requestedVariantID = strings.TrimSpace(requestedVariantID)
+	if len(plan.CourseVariants) == 0 {
+		return nil, nil
+	}
+
+	mode := AssignmentModeManual
+	if plan.AssignmentMode != nil {
+		mode = *plan.AssignmentMode
+	}
+
+	if requestedVariantID != "" {
+		for _, item := range plan.CourseVariants {
+			if item.ID == requestedVariantID {
+				if !item.Selectable && mode == AssignmentModeManual {
+					return nil, apperr.New(http.StatusBadRequest, "variant_not_selectable", "requested variant is not selectable")
+				}
+				return &VariantBindingView{
+					ID:             item.ID,
+					Name:           item.Name,
+					RouteCode:      item.RouteCode,
+					AssignmentMode: mode,
+				}, nil
+			}
+		}
+		return nil, apperr.New(http.StatusBadRequest, "variant_not_found", "requested variant does not exist")
+	}
+
+	selected, err := selectDefaultVariant(plan.CourseVariants, mode)
+	if err != nil {
+		return nil, err
+	}
+	return &VariantBindingView{
+		ID:             selected.ID,
+		Name:           selected.Name,
+		RouteCode:      selected.RouteCode,
+		AssignmentMode: mode,
+	}, nil
+}
+
+func normalizeAssignmentMode(value string) *string {
+	switch strings.TrimSpace(value) {
+	case AssignmentModeManual:
+		mode := AssignmentModeManual
+		return &mode
+	case AssignmentModeRandom:
+		mode := AssignmentModeRandom
+		return &mode
+	case AssignmentModeServerAssigned:
+		mode := AssignmentModeServerAssigned
+		return &mode
+	default:
+		return nil
+	}
+}
+
+func selectDefaultVariant(items []CourseVariantView, mode string) (*CourseVariantView, error) {
+	candidates := make([]CourseVariantView, 0, len(items))
+	for _, item := range items {
+		if item.Selectable {
+			candidates = append(candidates, item)
+		}
+	}
+	if len(candidates) == 0 {
+		candidates = append(candidates, items...)
+	}
+	if len(candidates) == 0 {
+		return nil, apperr.New(http.StatusBadRequest, "variant_not_found", "course variants are empty")
+	}
+
+	switch mode {
+	case AssignmentModeRandom:
+		index, err := rand.Int(rand.Reader, big.NewInt(int64(len(candidates))))
+		if err != nil {
+			return nil, apperr.New(http.StatusInternalServerError, "variant_select_failed", fmt.Sprintf("failed to select random variant: %v", err))
+		}
+		selected := candidates[int(index.Int64())]
+		return &selected, nil
+	case AssignmentModeServerAssigned, AssignmentModeManual:
+		fallthrough
+	default:
+		selected := candidates[0]
+		return &selected, nil
+	}
+}

+ 151 - 16
backend/internal/store/postgres/dev_store.go

@@ -6,13 +6,16 @@ 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"`
+	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"`
+	VariantManualEventID string `json:"variantManualEventId"`
+	VariantManualRelease string `json:"variantManualReleaseId"`
+	VariantManualCardID  string `json:"variantManualCardId"`
 }
 
 func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) {
@@ -88,7 +91,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			$1,
 			1,
 			'Demo Config v1',
-			'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json',
+			'https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json',
 			'demo-checksum-001',
 			'route-demo-001',
 			'published'
@@ -224,7 +227,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 			EventReleaseID: releaseRow.ID,
 			AssetType:      "manifest",
 			AssetKey:       "manifest",
-			AssetURL:       "https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json",
+			AssetURL:       "https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json",
 			Checksum:       &manifestChecksum,
 			Meta:           map[string]any{"source": "release-manifest"},
 		},
@@ -308,17 +311,149 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
 		return nil, fmt.Errorf("ensure demo card: %w", err)
 	}
 
+	var manualEventID string
+	if err := tx.QueryRow(ctx, `
+		INSERT INTO events (
+			tenant_id, event_public_id, slug, display_name, summary, status
+		)
+		VALUES ($1, 'evt_demo_variant_manual_001', 'demo-variant-manual-run', 'Demo Variant Manual Run', 'Manual 多赛道联调活动', '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(&manualEventID); err != nil {
+		return nil, fmt.Errorf("ensure variant manual demo event: %w", err)
+	}
+
+	var manualReleaseRow 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,
+			payload_jsonb
+		)
+		VALUES (
+			'rel_demo_variant_manual_001',
+			$1,
+			1,
+			'Demo Variant Manual Config v1',
+			'https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json',
+			'demo-variant-checksum-001',
+			'route-variant-a',
+			'published',
+			$2::jsonb
+		)
+		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,
+			payload_jsonb = EXCLUDED.payload_jsonb
+		RETURNING id, release_public_id
+	`, manualEventID, `{
+  "play": {
+    "assignmentMode": "manual",
+    "courseVariants": [
+      {
+        "id": "variant_a",
+        "name": "A 线",
+        "description": "短线体验版",
+        "routeCode": "route-variant-a",
+        "selectable": true
+      },
+      {
+        "id": "variant_b",
+        "name": "B 线",
+        "description": "长线挑战版",
+        "routeCode": "route-variant-b",
+        "selectable": true
+      }
+    ]
+  }
+}`).Scan(&manualReleaseRow.ID, &manualReleaseRow.PublicID); err != nil {
+		return nil, fmt.Errorf("ensure variant manual demo release: %w", err)
+	}
+
+	if _, err := tx.Exec(ctx, `
+		UPDATE events
+		SET current_release_id = $2
+		WHERE id = $1
+	`, manualEventID, manualReleaseRow.ID); err != nil {
+		return nil, fmt.Errorf("attach variant manual demo release: %w", err)
+	}
+
+	var manualCardPublicID 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_variant_manual_001',
+			$1,
+			$2,
+			'event',
+			'Demo Variant Manual Run',
+			'多赛道手动选择联调',
+			'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
+			$3,
+			'home_primary',
+			95,
+			'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, manualEventID).Scan(&manualCardPublicID); err != nil {
+		return nil, fmt.Errorf("ensure variant manual demo card: %w", err)
+	}
+
 	if err := tx.Commit(ctx); err != nil {
 		return nil, err
 	}
 
 	return &DemoBootstrapSummary{
-		TenantCode:  "tenant_demo",
-		ChannelCode: "mini-demo",
-		EventID:     "evt_demo_001",
-		ReleaseID:   releaseRow.PublicID,
-		SourceID:    source.ID,
-		BuildID:     build.ID,
-		CardID:      cardPublicID,
+		TenantCode:           "tenant_demo",
+		ChannelCode:          "mini-demo",
+		EventID:              "evt_demo_001",
+		ReleaseID:            releaseRow.PublicID,
+		SourceID:             source.ID,
+		BuildID:              build.ID,
+		CardID:               cardPublicID,
+		VariantManualEventID: "evt_demo_variant_manual_001",
+		VariantManualRelease: manualReleaseRow.PublicID,
+		VariantManualCardID:  manualCardPublicID,
 	}, nil
 }

+ 22 - 5
backend/internal/store/postgres/event_store.go

@@ -22,6 +22,7 @@ type Event struct {
 	ManifestURL         *string
 	ManifestChecksum    *string
 	RouteCode           *string
+	ReleasePayloadJSON  *string
 }
 
 type EventRelease struct {
@@ -45,6 +46,9 @@ type CreateGameSessionParams struct {
 	EventReleaseID        string
 	DeviceKey             string
 	ClientType            string
+	AssignmentMode        *string
+	VariantID             *string
+	VariantName           *string
 	RouteCode             *string
 	SessionTokenHash      string
 	SessionTokenExpiresAt time.Time
@@ -58,6 +62,9 @@ type GameSession struct {
 	EventReleaseID        string
 	DeviceKey             string
 	ClientType            string
+	AssignmentMode        *string
+	VariantID             *string
+	VariantName           *string
 	RouteCode             *string
 	Status                string
 	SessionTokenExpiresAt time.Time
@@ -77,7 +84,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
 			er.config_label,
 			er.manifest_url,
 			er.manifest_checksum_sha256,
-			er.route_code
+			er.route_code,
+			er.payload_jsonb::text
 		FROM events e
 		LEFT JOIN event_releases er ON er.id = e.current_release_id
 		WHERE e.event_public_id = $1
@@ -98,6 +106,7 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
 		&event.ManifestURL,
 		&event.ManifestChecksum,
 		&event.RouteCode,
+		&event.ReleasePayloadJSON,
 	)
 	if errors.Is(err, pgx.ErrNoRows) {
 		return nil, nil
@@ -122,7 +131,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
 			er.config_label,
 			er.manifest_url,
 			er.manifest_checksum_sha256,
-			er.route_code
+			er.route_code,
+			er.payload_jsonb::text
 		FROM events e
 		LEFT JOIN event_releases er ON er.id = e.current_release_id
 		WHERE e.id = $1
@@ -143,6 +153,7 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
 		&event.ManifestURL,
 		&event.ManifestChecksum,
 		&event.RouteCode,
+		&event.ReleasePayloadJSON,
 	)
 	if errors.Is(err, pgx.ErrNoRows) {
 		return nil, nil
@@ -235,13 +246,16 @@ func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameS
 			event_release_id,
 			device_key,
 			client_type,
+			assignment_mode,
+			variant_id,
+			variant_name,
 			route_code,
 			session_token_hash,
 			session_token_expires_at
 		)
-		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
-		RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, route_code, status, session_token_expires_at
-	`, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt)
+		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+		RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, assignment_mode, variant_id, variant_name, route_code, status, session_token_expires_at
+	`, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, params.AssignmentMode, params.VariantID, params.VariantName, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt)
 
 	var session GameSession
 	err := row.Scan(
@@ -252,6 +266,9 @@ func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameS
 		&session.EventReleaseID,
 		&session.DeviceKey,
 		&session.ClientType,
+		&session.AssignmentMode,
+		&session.VariantID,
+		&session.VariantName,
 		&session.RouteCode,
 		&session.Status,
 		&session.SessionTokenExpiresAt,

+ 12 - 0
backend/internal/store/postgres/result_store.go

@@ -101,6 +101,9 @@ func (s *Store) GetSessionResultByPublicID(ctx context.Context, sessionPublicID
 			er.manifest_checksum_sha256,
 			gs.device_key,
 			gs.client_type,
+			gs.assignment_mode,
+			gs.variant_id,
+			gs.variant_name,
 			gs.route_code,
 			gs.status,
 			gs.session_token_hash,
@@ -149,6 +152,9 @@ func (s *Store) ListSessionResultsByUserID(ctx context.Context, userID string, l
 			er.manifest_checksum_sha256,
 			gs.device_key,
 			gs.client_type,
+			gs.assignment_mode,
+			gs.variant_id,
+			gs.variant_name,
 			gs.route_code,
 			gs.status,
 			gs.session_token_hash,
@@ -244,6 +250,9 @@ func scanSessionResultRecord(row pgx.Row) (*SessionResultRecord, error) {
 		&record.ManifestChecksum,
 		&record.DeviceKey,
 		&record.ClientType,
+		&record.AssignmentMode,
+		&record.VariantID,
+		&record.VariantName,
 		&record.RouteCode,
 		&record.Status,
 		&record.SessionTokenHash,
@@ -317,6 +326,9 @@ func scanSessionResultRecordFromRows(rows pgx.Rows) (*SessionResultRecord, error
 		&record.ManifestChecksum,
 		&record.DeviceKey,
 		&record.ClientType,
+		&record.AssignmentMode,
+		&record.VariantID,
+		&record.VariantName,
 		&record.RouteCode,
 		&record.Status,
 		&record.SessionTokenHash,

+ 21 - 0
backend/internal/store/postgres/session_store.go

@@ -21,6 +21,9 @@ type Session struct {
 	ManifestChecksum      *string
 	DeviceKey             string
 	ClientType            string
+	AssignmentMode        *string
+	VariantID             *string
+	VariantName           *string
 	RouteCode             *string
 	Status                string
 	SessionTokenHash      string
@@ -51,6 +54,9 @@ func (s *Store) GetSessionByPublicID(ctx context.Context, sessionPublicID string
 			er.manifest_checksum_sha256,
 			gs.device_key,
 			gs.client_type,
+			gs.assignment_mode,
+			gs.variant_id,
+			gs.variant_name,
 			gs.route_code,
 			gs.status,
 			gs.session_token_hash,
@@ -83,6 +89,9 @@ func (s *Store) GetSessionByPublicIDForUpdate(ctx context.Context, tx Tx, sessio
 			er.manifest_checksum_sha256,
 			gs.device_key,
 			gs.client_type,
+			gs.assignment_mode,
+			gs.variant_id,
+			gs.variant_name,
 			gs.route_code,
 			gs.status,
 			gs.session_token_hash,
@@ -119,6 +128,9 @@ func (s *Store) ListSessionsByUserID(ctx context.Context, userID string, limit i
 			er.manifest_checksum_sha256,
 			gs.device_key,
 			gs.client_type,
+			gs.assignment_mode,
+			gs.variant_id,
+			gs.variant_name,
 			gs.route_code,
 			gs.status,
 			gs.session_token_hash,
@@ -172,6 +184,9 @@ func (s *Store) ListSessionsByUserAndEvent(ctx context.Context, userID, eventID
 			er.manifest_checksum_sha256,
 			gs.device_key,
 			gs.client_type,
+			gs.assignment_mode,
+			gs.variant_id,
+			gs.variant_name,
 			gs.route_code,
 			gs.status,
 			gs.session_token_hash,
@@ -249,6 +264,9 @@ func scanSession(row pgx.Row) (*Session, error) {
 		&session.ManifestChecksum,
 		&session.DeviceKey,
 		&session.ClientType,
+		&session.AssignmentMode,
+		&session.VariantID,
+		&session.VariantName,
 		&session.RouteCode,
 		&session.Status,
 		&session.SessionTokenHash,
@@ -282,6 +300,9 @@ func scanSessionFromRows(rows pgx.Rows) (*Session, error) {
 		&session.ManifestChecksum,
 		&session.DeviceKey,
 		&session.ClientType,
+		&session.AssignmentMode,
+		&session.VariantID,
+		&session.VariantName,
 		&session.RouteCode,
 		&session.Status,
 		&session.SessionTokenHash,

+ 11 - 0
backend/migrations/0007_variant_minimal.sql

@@ -0,0 +1,11 @@
+BEGIN;
+
+ALTER TABLE game_sessions
+  ADD COLUMN assignment_mode TEXT CHECK (assignment_mode IN ('manual', 'random', 'server-assigned')),
+  ADD COLUMN variant_id TEXT,
+  ADD COLUMN variant_name TEXT;
+
+CREATE INDEX game_sessions_variant_id_idx ON game_sessions(variant_id);
+CREATE INDEX game_sessions_assignment_mode_idx ON game_sessions(assignment_mode);
+
+COMMIT;

+ 12 - 1
backend/scripts/start-dev.ps1

@@ -46,4 +46,15 @@ if ($workbenchAddr.StartsWith(":")) {
 Write-Host ("http://" + $workbenchAddr + "/dev/workbench")
 Write-Host ""
 
-go run .\cmd\api
+$exePath = Join-Path $backendDir "cmr-backend.exe"
+
+Write-Host "Build:" -ForegroundColor Yellow
+Write-Host $exePath
+Write-Host ""
+
+go build -o $exePath .\cmd\api
+if ($LASTEXITCODE -ne 0) {
+  throw "go build failed"
+}
+
+& $exePath

+ 1 - 1
backend/start-backend.ps1

@@ -10,4 +10,4 @@ if (-not (Test-Path $scriptPath)) {
 
 Set-Location $backendDir
 
-powershell -ExecutionPolicy Bypass -File $scriptPath
+& $scriptPath

+ 324 - 0
doc/gameplay/APP全局产品架构草案.md

@@ -0,0 +1,324 @@
+# APP全局产品架构草案
+> 文档版本:v1.0
+> 最后更新:2026-04-02 18:10:04
+
+本文档用于整理当前 APP 级产品逻辑的整体设想,作为后续页面架构、后端对象模型、联调边界和默认流程设计的上层基线。
+
+---
+
+## 1. 总体判断
+
+当前产品不应再简单理解为“地图游戏程序”,更准确的定位应当是:
+
+**以地图为资源底座、以活动为对外核心、以对局为过程、以用户资产为沉淀的运动游戏系统。**
+
+这意味着:
+
+- 地图是场地资产和体验载体
+- 活动是运营层、展示层、商业层和用户入口
+- Session 是一次真实对局过程
+- 历史、成就、奖励、资料等属于用户长期资产
+
+---
+
+## 2. 一级模块建议
+
+建议 APP 长期按 5 个一级模块组织。
+
+### 2.1 首页 / 发现
+
+负责:
+
+- 地区浏览
+- 地图列表
+- 推荐活动
+- 默认体验入口
+- 宣传入口
+
+适用对象:
+
+- 游客
+- 初次进入的新用户
+- 以地图体验为目标的训练型用户
+
+### 2.2 活动 / 赛事
+
+这是系统核心模块。
+
+负责:
+
+- 活动卡片列表
+- 活动详情
+- 报名
+- 签到
+- 开局
+- 公告
+- 排行榜
+- 社交或分享入口
+- 多地图活动聚合
+
+说明:
+
+- 活动页本质上是系统的对外展示壳和运营壳
+- 不同客户的活动页、排行榜、宣传页都可能是定制化的
+- 这里适合长期采用“原生模板 + 原生 DSL + H5 增强”的分层方案
+
+### 2.3 地图体验
+
+负责:
+
+- 地图默认体验活动
+- 地图自由体验
+- 训练入口
+- 正式进入地图游戏过程
+- 游戏过程中的内容、规则、结算
+
+说明:
+
+- 地图体验层是运行层,不是对外运营主入口
+- 游客主要通过这一层进入体验
+
+### 2.4 我的
+
+负责:
+
+- 历史记录
+- 成绩详情
+- 报名信息
+- 全局头像与昵称
+- 成就
+- 奖励展示
+- 收藏与长期资产
+
+说明:
+
+- 历史、成就、奖励不应分散在不同页面里,建议统一收口在用户资产中心
+
+### 2.5 系统
+
+负责:
+
+- 设置
+- 帮助
+- 使用说明
+- 反馈
+- 关于
+- 调试入口
+
+---
+
+## 3. 核心对象模型
+
+建议长期围绕 4 个核心对象组织前后端模型。
+
+### 3.1 Map
+
+含义:
+
+- 地图资源
+- 场地资产
+- 瓦片与底图
+- 点位、图层、默认体验挂载位置
+
+### 3.2 Event
+
+含义:
+
+- 活动
+- 赛事
+- 运营包装壳
+- 报名、签到、排行榜、宣传、社交等活动级能力
+
+说明:
+
+- 一个 Event 可以挂一个地图,也可以挂多个地图
+- 一个地图也可以承载多个 Event
+
+### 3.3 Session
+
+含义:
+
+- 一次具体开局记录
+- 一次真实游戏过程
+- 与规则、成绩、恢复、结果页直接相关
+
+### 3.4 User Asset
+
+含义:
+
+- 历史记录
+- 成就
+- 奖励
+- 头像昵称
+- 报名资料快照
+- 收藏与长期沉淀
+
+---
+
+## 4. 用户主流程建议
+
+### 4.1 游客主流程
+
+1. 打开程序
+2. 浏览地区和地图
+3. 进入地图默认体验活动
+4. 进行单局游戏
+5. 结果和历史先本地保存
+6. 登录后再触发同步或迁移
+
+设计结论:
+
+- 游客以地图体验链为主
+- 游客不应直接进入正式活动能力
+
+### 4.2 注册用户主流程
+
+1. 打开程序
+2. 浏览活动 / 赛事
+3. 进入活动详情
+4. 报名 / 签到 / 开局
+5. 进入正式对局
+6. 成绩回写历史、排行榜、奖励、成就
+
+设计结论:
+
+- 注册用户以活动运营链为主
+- 活动是主要用户入口
+
+---
+
+## 5. 当前设想的逐项落点
+
+### 5.1 地图列表
+
+保留,但定位建议明确为:
+
+- 自由体验入口
+- 游客入口
+- 训练入口
+
+### 5.2 活动卡片列表
+
+必须升格为核心模块。
+
+活动系统长期需要支持:
+
+- 标准模板活动
+- 默认体验活动
+- 客户定制活动
+
+### 5.3 历史记录
+
+建议归入“我的”模块统一管理,至少分成:
+
+- 最近活动
+- 全部记录
+- 成绩详情
+
+### 5.4 游客模式
+
+建议正式规则为:
+
+- 游客只可进入地图默认体验活动
+- 游客数据先本地存储
+- 登录后触发数据迁移或合并
+
+### 5.5 全局资料与活动内资料
+
+建议分两层模型:
+
+- 全局用户资料
+- 活动报名资料快照
+
+因为活动内昵称、头像、队伍信息可能与全局资料不同。
+
+### 5.6 帮助页面
+
+建议未来拆成:
+
+- 新手引导
+- 使用帮助
+- 常见问题
+
+### 5.7 反馈功能
+
+建议反馈最少自动带上:
+
+- 当前活动
+- 当前地图
+- 当前 session
+- 设备信息
+- 程序版本
+
+### 5.8 成就展示
+
+当前可以先不做奖励系统,但架构上建议预留:
+
+- 成就
+- 奖章
+- 奖励
+- 收藏品
+
+---
+
+## 6. 产品主线建议
+
+当前最适合正式确定的一条主线是:
+
+### 游客态
+
+- 以地图和默认体验为主
+
+### 登录态
+
+- 以活动和赛事为主
+
+这个分流非常重要,因为它会影响:
+
+- 首页结构
+- 登录前后导航
+- 活动和地图的权重关系
+- 默认入口设计
+
+---
+
+## 7. 与当前工程结构的关系
+
+对照当前代码与文档体系,可以理解为:
+
+- `MapEngine`、渲染层、传感器层:地图资源与运行骨架
+- 规则层、Telemetry、Feedback:Session 过程层
+- 活动页、准备页、结果页、首页聚合:Event 与 User Asset 的上层壳
+- 配置系统与文档体系:把 Map / Event / Session 的差异转为可配置运行态
+
+因此,后续开发不应再只围绕“地图页”扩功能,而应正式开始:
+
+- 活动系统设计
+- 结果与历史沉淀设计
+- 游客链与登录链分流
+
+---
+
+## 8. 当前阶段建议
+
+当前建议优先做这三件事:
+
+1. 正式梳理活动系统页面骨架  
+2. 打磨顺序赛 / 积分赛的默认规则、默认样式、默认流程  
+3. 明确游客模式与登录模式的数据迁移规则  
+
+不建议当前阶段做的事:
+
+- 过早铺开复杂社交体系
+- 在奖励系统未定前做大而全的成就结构
+- 让地图页承担过多对外展示职责
+
+---
+
+## 9. 一句话结论
+
+当前 APP 最合理的全局方案是:
+
+**地图是资源底座,活动是对外核心,Session 是游戏过程,历史与成就是用户资产。**
+
+后续页面、后端模型、联调策略和配置体系,都应围绕这四个对象继续收口。

+ 383 - 0
doc/gameplay/多赛道Variant五层设计草案.md

@@ -0,0 +1,383 @@
+# 多赛道 Variant 五层设计草案
+> 文档版本:v0.1
+> 最后更新:2026-04-02 18:24:00
+
+本文档用于定义“一个活动对应多版 KML / 多条赛道”时的推荐架构。
+
+目标:
+
+- 不从页面交互倒推系统结构
+- 先把多赛道能力按平台层次拆清
+- 让前端、后端、后台、恢复、结果页都围绕同一套事实工作
+- 为后续“手动选择 / 随机指定 / 后端指定”留出统一伸缩空间
+
+说明:
+
+- 本文档是设计草案,不是最终接口契约
+- 本文档优先定义分层、边界、事实和约束
+- 具体页面表现、后台表单细节、字段命名可在后续实现阶段微调
+
+---
+
+## 1. 背景与核心判断
+
+当前项目里,一场活动后续可能不止一版 KML。
+
+常见需求包括:
+
+- 同一活动下有 A / B / C 多条赛道
+- 准备阶段允许玩家手动选择
+- 准备阶段由系统随机分配
+- 后续可能由后台或裁判端直接指定
+
+这里最关键的判断是:
+
+**赛道版本不是页面临时状态,而是 session 级事实。**
+
+也就是说,一旦某局比赛绑定了某个赛道版本,这个事实必须贯穿:
+
+- 准备阶段
+- launch
+- session start / finish
+- 故障恢复
+- result
+- ongoing session
+- 历史结果
+
+如果这一点不先定住,后面多端、多页面、多恢复链会很快乱掉。
+
+---
+
+## 2. 总体原则
+
+多赛道能力建议固定遵守下面 6 条原则:
+
+1. 一局比赛只绑定一个赛道版本
+2. 前端可以参与选择,但最终绑定以后端 session 为准
+3. 客户端不应各自实现不同的随机分配规则
+4. 恢复链必须记住赛道版本
+5. 结果页、历史结果和 ongoing 摘要必须可追溯赛道版本
+6. 扩展新分配模式时,不破坏现有分层
+
+一句话总结:
+
+**前端负责交互,后端负责最终绑定,session 负责真实落账。**
+
+---
+
+## 3. 五层模型
+
+多赛道能力建议拆成 5 层。
+
+### 3.1 资源层
+
+职责:
+
+- 只定义赛道素材本身
+- 不讨论谁来选,不讨论 session
+
+典型内容:
+
+- KML 文件
+- 赛道元数据
+- 可选地图资源
+- 可选赛道封面、说明图
+
+这一层回答的是:
+
+- 这个赛道版本本身是什么
+- 它的原始素材和元信息是什么
+
+建议约束:
+
+- 每个赛道版本都应有稳定的 `variantId`
+- 每个赛道版本都应能独立定位到自己的 KML / manifest 入口
+- 资源层不应混入“本局随机到谁”这种运行时逻辑
+
+### 3.2 活动编排层
+
+职责:
+
+- 定义一个活动下有哪些赛道版本可用
+- 定义这些版本如何被分配
+
+典型内容:
+
+- `assignmentMode`
+- `courseVariants[]`
+- 权重
+- 可选分组规则
+- 可选活动级覆盖
+
+这一层回答的是:
+
+- 当前活动允许哪些赛道版本
+- 当前活动按什么模式分配赛道
+
+推荐至少支持 3 种模式:
+
+1. `manual`
+   - 用户手选
+2. `random`
+   - 系统随机指定
+3. `server-assigned`
+   - 后端预先指定,前端只展示
+
+后续可扩展模式:
+
+- 按分组分配
+- 按批次轮换
+- 团队共用赛道
+- 避免重复赛道
+
+### 3.3 会话绑定层
+
+职责:
+
+- 真正决定“这局比赛到底绑定哪条赛道”
+- 作为跨端、跨页面、跨恢复的一致事实
+
+这一层回答的是:
+
+- 当前 session 最终绑定的是哪个 `variantId`
+- 这个绑定是手选、随机还是后端直接指定
+
+这一层必须落账的最小事实建议包括:
+
+- `sessionId`
+- `eventId`
+- `variantId`
+- `assignmentMode`
+- `assignedAt`
+- 可选的 `assignmentSource`
+
+核心约束:
+
+- `variantId` 一旦绑定,不应在本局内漂移
+- launch、恢复、结果、排行榜都要引用同一 `variantId`
+- 客户端本地恢复快照必须保存 `variantId`
+
+### 3.4 客户端呈现层
+
+职责:
+
+- 负责向玩家展示可选项或绑定结果
+- 负责发起用户选择
+- 负责消费最终绑定后的赛道配置
+
+这一层回答的是:
+
+- 准备页要不要展示赛道列表
+- 是否允许点击手选
+- 是否显示“本局随机分配结果”
+- 地图页加载哪一个 manifest
+
+推荐规则:
+
+- `manual`:准备页展示赛道列表,允许选择
+- `random`:准备页展示“随机分配”结果,不允许随意更改
+- `server-assigned`:准备页只展示最终结果,不提供选择
+
+强约束:
+
+- 客户端不能把“页面选择结果”当成最终事实
+- 客户端必须以后端返回的 `variantId` 为准
+- 地图页只消费最终绑定后的 manifest / runtime profile
+
+### 3.5 后台运营层
+
+职责:
+
+- 管理赛道版本
+- 管理活动编排
+- 管理发布与审计
+
+这一层回答的是:
+
+- 活动有哪些赛道版本
+- 每个版本的素材、说明和发布状态是什么
+- 当前活动采用哪种分配策略
+- 某局最终是怎么绑定出来的
+
+后台层建议逐步承担:
+
+- 赛道版本管理
+- 活动绑定多个 variant
+- 权重配置
+- 发布检查
+- 历史审计
+
+---
+
+## 4. 多端协作边界
+
+多赛道能力一旦牵涉多端,最容易出问题的地方是边界不清。
+
+建议固定边界如下:
+
+### 4.1 前端负责
+
+- 展示赛道选择或展示赛道结果
+- 发起选择请求或发起随机请求
+- 消费 launch 返回的赛道绑定结果
+- 在地图、恢复、结果页中显示当前 `variantId` 或赛道名
+
+### 4.2 后端负责
+
+- 最终确认本局绑定哪个 `variantId`
+- 将 `variantId` 写入 session
+- 确保 launch 返回的 release / manifest 与 `variantId` 对应
+- 确保 result / ongoing / recovery 均能反查 `variantId`
+
+### 4.3 后台负责
+
+- 管理活动可用的 variants
+- 管理 assignment mode
+- 管理发布、上下线和审计
+
+约束:
+
+- 不允许前端自己定义“随机分配算法”后直接当成最终结果
+- 不允许某端私自改 `variantId` 但不落 session
+- 不允许恢复链丢失 `variantId`
+
+---
+
+## 5. 配置与发布建议
+
+多赛道不是只改一个 `kmlUrl`。
+
+它建议进入活动编排配置,而不是页面临时字段。
+
+推荐抽象:
+
+- 活动级:
+  - `assignmentMode`
+  - `courseVariants[]`
+- variant 级:
+  - `id`
+  - `name`
+  - `kmlUrl`
+  - `weight`
+  - `overrides`
+
+如果某些版本不仅 KML 不同,连分值、样式、规则也不同,推荐允许 variant 带局部覆盖。
+
+推荐覆盖顺序:
+
+`系统默认值 -> 玩法默认值 -> 活动默认值 -> variant 覆盖 -> 单点覆盖`
+
+这样未来不会因为多赛道把既有继承体系打乱。
+
+---
+
+## 6. 恢复、结果与摘要约束
+
+### 6.1 故障恢复
+
+恢复快照中必须加入:
+
+- `variantId`
+- 可选 `variantName`
+
+恢复原则:
+
+- 恢复的是这局绑定的赛道事实
+- 不重新随机
+- 不重新询问选择
+
+### 6.2 结果页
+
+结果页建议至少展示:
+
+- 活动名
+- 赛道版本名或 `variantId`
+- 本局结果摘要
+
+### 6.3 ongoing / recent / 历史成绩
+
+这些摘要建议都能反映:
+
+- 本局属于哪个赛道版本
+
+否则后续:
+
+- 用户无法判断自己玩的哪版
+- 运营无法解释同活动下不同赛道差异
+- 排名、复盘、申诉都困难
+
+---
+
+## 7. 推荐实施顺序
+
+这块不建议一上来就做全套复杂功能。
+
+推荐分三期:
+
+### 第一期:架构定型
+
+目标:
+
+- 定义 `courseVariants`
+- 定义 `assignmentMode`
+- 定义 session 绑定 `variantId`
+- 明确恢复、结果、launch 必须带上 `variantId`
+
+### 第二期:前后端最小闭环
+
+目标:
+
+- 支持 `manual`
+- 支持 `random`
+- 准备页可展示赛道选择或随机结果
+- launch 能消费最终绑定结果
+
+### 第三期:运营扩展
+
+目标:
+
+- 接后台编排
+- 增加 `server-assigned`
+- 扩展更多分配模式
+- 做更完整的审计、排行榜和统计
+
+---
+
+## 8. 六层检查在多赛道能力中的应用
+
+后续只要多赛道相关配置或契约有变更,建议继续执行当前约定的六层检查:
+
+1. 文档
+2. 配置源
+3. 解析层
+4. 编译层
+5. 消费层
+6. 发布与联调层
+
+具体到多赛道能力,检查点通常包括:
+
+- 文档是否同步 `assignmentMode / variants / variantId`
+- 配置源是否新增或调整 variant 结构
+- 解析层是否能读取多赛道结构
+- 编译层是否能生成最终绑定后的 runtime profile
+- 地图、结果页、恢复链是否都消费了 `variantId`
+- launch / release / manifest 是否和最终 variant 绑定一致
+
+---
+
+## 9. 当前建议结论
+
+当前阶段,建议把“多 KML / 多赛道”先当成**平台能力设计**,而不是页面功能。
+
+当前最重要的不是先做某个选择 UI,而是先定住以下 4 个事实:
+
+1. 一个活动可以有多个 variants
+2. 一个 session 只能绑定一个 `variantId`
+3. 最终绑定以后端 session 为准
+4. 恢复、结果、ongoing、历史结果都必须能追溯该 `variantId`
+
+---
+
+## 10. 一句话总结
+
+**多赛道能力建议固定采用“资源层、活动编排层、会话绑定层、客户端呈现层、后台运营层”五层模型,先把 `variantId` 做成 session 级事实,再去实现准备页手选、随机分配和后台指定等具体交互。**

+ 294 - 0
doc/gameplay/多赛道Variant前后端最小契约.md

@@ -0,0 +1,294 @@
+# 多赛道 Variant 前后端最小契约
+> 文档版本:v0.1
+> 最后更新:2026-04-02 18:33:00
+
+本文档用于定义“多 KML / 多赛道 variant”第一阶段联调所需的最小前后端契约。
+
+目标:
+
+- 先定最小可联调字段,不一开始追求大而全
+- 保证准备页、launch、地图、恢复、结果页能围绕同一 `variantId` 工作
+- 避免前端从页面交互反推后端字段
+
+说明:
+
+- 本文档只定义最小契约建议
+- 不等同于最终后台配置模型
+- 不等同于最终数据库模型
+- 本文档优先服务前后端第一阶段联调
+
+---
+
+## 1. 最小联调目标
+
+第一阶段只解决下面 4 件事:
+
+1. 一个活动可声明多个 `variant`
+2. 准备页能知道当前活动是否允许手选 / 随机 / 后端指定
+3. `launch` 能明确返回本局最终绑定的 `variantId`
+4. `result / ongoing / recovery` 能持续追溯同一个 `variantId`
+
+一句话目标:
+
+**让 `variantId` 成为贯穿一局的稳定事实。**
+
+---
+
+## 2. 契约原则
+
+### 2.1 session 绑定优先
+
+- 前端可参与选择
+- 最终绑定以后端 session 为准
+
+### 2.2 launch 是最终真相
+
+- 前端准备页即便做了手选或随机请求
+- 地图页真正消费的仍应是 `launch` 返回的最终绑定结果
+
+### 2.3 恢复不重新分配
+
+- 恢复链只恢复既有 `variantId`
+- 不重新随机
+- 不重新提示选择
+
+### 2.4 结果必须可追溯
+
+- 结果页
+- ongoing session
+- 历史成绩
+
+都建议能反查:
+
+- `variantId`
+- 可选 `variantName`
+
+---
+
+## 3. 活动级最小字段建议
+
+建议活动可玩信息中增加一个最小赛道编排块,例如在 `play` 返回里体现:
+
+### 3.1 assignmentMode
+
+含义:
+
+- 当前活动的赛道分配模式
+
+建议最小取值:
+
+- `manual`
+- `random`
+- `server-assigned`
+
+### 3.2 courseVariants
+
+含义:
+
+- 当前活动可用赛道版本列表
+
+建议最小字段:
+
+- `id`
+- `name`
+- `description`
+- `routeCode`
+- `selectable`
+
+备注:
+
+- 第一阶段不一定要在 `play` 里返回所有复杂资源
+- 只要足够准备页展示选择即可
+
+推荐最小形态示意:
+
+```json
+{
+  "play": {
+    "assignmentMode": "manual",
+    "courseVariants": [
+      {
+        "id": "variant_a",
+        "name": "A 线",
+        "description": "适合首次体验",
+        "routeCode": "A",
+        "selectable": true
+      },
+      {
+        "id": "variant_b",
+        "name": "B 线",
+        "description": "稍长路线",
+        "routeCode": "B",
+        "selectable": true
+      }
+    ]
+  }
+}
+```
+
+---
+
+## 4. launch 最小字段建议
+
+launch 必须承担“最终绑定本局赛道”的责任。
+
+建议在现有 `launch` 返回中增加一个明确的 variant 绑定块。
+
+### 4.1 建议字段
+
+- `launch.variant.id`
+- `launch.variant.name`
+- `launch.variant.routeCode`
+- `launch.variant.assignmentMode`
+
+如需保守,也可挂到 `business` 下,但建议语义上单独成块,避免和 release/session 混淆。
+
+### 4.2 前端输入建议
+
+如果是手选模式,前端建议向 `launch` 传:
+
+- `variantId`
+
+如果是随机模式,前端可以:
+
+- 不传,由后端分配
+- 或显式传一个 `assign=random` 请求意图
+
+### 4.3 输出约束
+
+无论前端是否传入 `variantId`,launch 返回都必须给出最终绑定结果。
+
+因为地图页只应消费:
+
+- 最终 `variantId`
+- 对应 manifest / config
+
+不应再依赖准备页上的临时选择状态。
+
+推荐最小形态示意:
+
+```json
+{
+  "launch": {
+    "variant": {
+      "id": "variant_b",
+      "name": "B 线",
+      "routeCode": "B",
+      "assignmentMode": "manual"
+    },
+    "resolvedRelease": {
+      "releaseId": "rel_xxx",
+      "manifestUrl": "https://..."
+    },
+    "business": {
+      "sessionId": "ses_xxx",
+      "sessionToken": "..."
+    }
+  }
+}
+```
+
+---
+
+## 5. session / result 最小字段建议
+
+### 5.1 session 摘要
+
+建议在以下位置都可见:
+
+- `ongoingSession`
+- `recentSession`
+- `session detail`
+
+最小补充:
+
+- `variantId`
+- `variantName`
+- `routeCode`
+
+### 5.2 result
+
+建议 `GET /sessions/{sessionPublicID}/result` 至少返回:
+
+- `result.session.variantId`
+- `result.session.variantName`
+- `result.session.routeCode`
+
+这样前端单局结果页和历史结果页都能统一展示。
+
+---
+
+## 6. 前端第一阶段落点
+
+前端第一阶段建议只做下面几件事:
+
+### 6.1 准备页
+
+- 读取 `assignmentMode`
+- 读取 `courseVariants[]`
+- 在 `manual` 下展示可选赛道列表
+- 在 `random` 下展示“随机分配”
+- 在 `server-assigned` 下只展示结果
+
+### 6.2 launch 适配层
+
+- 将 `launch.variant.*` 写入 `GameLaunchEnvelope`
+- 将 `variantId` 一起进入地图页和恢复快照
+
+### 6.3 结果与历史页
+
+- 显示本局 `variantName / routeCode`
+
+### 6.4 故障恢复
+
+- 快照中补 `variantId`
+- 恢复时继续使用既有 `variantId`
+
+---
+
+## 7. 第一阶段后端落点
+
+后端第一阶段建议只做下面几件事:
+
+### 7.1 play
+
+- 返回 `assignmentMode`
+- 返回 `courseVariants[]`
+
+### 7.2 launch
+
+- 接收可选 `variantId`
+- 返回最终绑定后的 `variant` 信息
+
+### 7.3 session / result
+
+- 在 session 摘要和结果里带出 `variantId`
+
+这样就足够完成第一阶段联调。
+
+---
+
+## 8. 和现有体系的关系
+
+这份最小契约不替代现有六层检查,后续一旦开始实现,仍建议按六层检查推进:
+
+1. 文档
+2. 配置源
+3. 解析层
+4. 编译层
+5. 消费层
+6. 发布与联调层
+
+特别是:
+
+- 配置层后续如果引入 `courseVariants`
+- 解析层如果开始读取多 variant 结构
+- 编译层如果开始按 `variantId` 产出 runtime profile
+
+这三层都不能跳。
+
+---
+
+## 9. 一句话结论
+
+**多赛道第一阶段联调只需要先定住 `assignmentMode`、`courseVariants[]`、`launch.variant.*`、`session/result.variant*` 这四组最小字段,让 `variantId` 成为贯穿一局的稳定事实。**

+ 3 - 1
doc/gameplay/游戏规则架构.md

@@ -1,6 +1,6 @@
 # 游戏规则架构
 > 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 最后更新:2026-04-02 18:33:00
 
 
 本文档用于说明当前项目中“游戏规则”在文档、配置文件、样例 JSON、解析代码和运行时规则引擎之间的实际组织方式。
@@ -68,6 +68,8 @@
 
 - [程序默认规则基线](D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
 - [运行时编译层总表](D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
+- [多赛道 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)
 - `doc/games/<游戏名称>/规则说明文档.md`

+ 4 - 1
doc/文档索引.md

@@ -1,6 +1,6 @@
 # 文档索引
 > 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 最后更新:2026-04-02 18:10:04
 
 维护约定:
 
@@ -41,7 +41,10 @@
 - [玩法构想方案](/D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md)
 - [程序默认规则基线](/D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
 - [游戏规则架构](/D:/dev/cmr-mini/doc/gameplay/游戏规则架构.md)
+- [多赛道 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)
+- [APP全局产品架构草案](/D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.md)
 - [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md)
 - [运行时编译层总表](/D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
 - [玩法设计文档模板](/D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)

+ 107 - 18
f2b.md

@@ -1,6 +1,6 @@
 # F2B 协作清单
-> 文档版本:v1.0
-> 最后更新:2026-04-02 08:28:05
+> 文档版本:v1.3
+> 最后更新:2026-04-02 15:19:37
 
 
 说明:
@@ -14,34 +14,33 @@
 
 ## 待确认
 
-### F2B-004
+### F2B-007
 
-- 时间:2026-04-01
+- 时间:2026-04-02
 - 提出方:前端
 - 当前事实:
-  - 前端当前依赖以下 launch 字段:
-    - `resolvedRelease.manifestUrl`
-    - `resolvedRelease.releaseId`
-    - `business.sessionId`
-    - `business.sessionToken`
-    - `business.sessionTokenExpiresAt`
+  - 前端已完成多赛道第一阶段接入:
+    - 活动页、准备页可展示 `assignmentMode / courseVariants`
+    - 当 `assignmentMode=manual` 时,准备页会让用户选择赛道
+    - 前端会把选中的 `variantId` 带入 `POST /events/{eventPublicID}/launch`
 - 需要对方确认什么:
-  - backend 后续如需调整这些字段名或层级,需先在 `b2f.md` 明确通知
+  - 请 backend 提供一个可联调的 `manual` 多赛道活动或 demo 数据
+  - 该活动需确保 `play.courseVariants[]`、`launch.variant.*` 可稳定返回
 - 状态:待确认
 
-### F2B-005
+### F2B-008
 
-- 时间:2026-04-01
+- 时间:2026-04-02
 - 提出方:前端
 - 当前事实:
-  - ongoing session 目前会影响:
+  - 前端已开始在首页 ongoing/recent、单局结果页、历史结果页展示 `variantName / routeCode`
+  - 当前需要确认从 `launch` 选定的 `variantId` 是否会稳定回流到:
     - `/me/entry-home`
-    - `/events/{eventPublicID}/play`
     - `/sessions/{sessionPublicID}/result`
+    - `/me/results`
 - 需要对方确认什么:
-  - `cancelled` 后不再作为 ongoing 返回
-  - `failed` 后不再作为 ongoing 返回
-  - `finished` 后结果摘要与首页摘要口径一致
+  - 请 backend 确认以上摘要链是否已完成 variant 回写
+  - 如还未全部完成,请给出可联调时间点或先可用的接口范围
 - 状态:待确认
 
 ---
@@ -108,6 +107,58 @@
   - 无
 - 状态:已确认
 
+### F2B-C006
+
+- 时间:2026-04-02
+- 提出方:前端
+- 当前事实:
+  - backend 已确认多赛道第一阶段最小契约,且相关字段已可从以下接口返回:
+    - `/events/{eventPublicID}/play`
+    - `/events/{eventPublicID}/launch`
+    - `/me/entry-home`
+    - `/sessions/{sessionPublicID}`
+    - `/sessions/{sessionPublicID}/result`
+    - `/me/results`
+    - `/me/sessions`
+  - 正式口径为:
+    - `play.assignmentMode`
+    - `play.courseVariants[]`
+    - `launch.variant.id/name/routeCode/assignmentMode`
+    - `session / ongoing / recent / result` 摘要中带 `variantId/variantName/routeCode`
+- 需要对方确认什么:
+  - 无
+- 状态:已确认
+
+### F2B-C007
+
+- 时间:2026-04-02
+- 提出方:前端
+- 当前事实:
+  - backend 已确认 launch 关键字段为正式契约:
+    - `resolvedRelease.manifestUrl`
+    - `resolvedRelease.releaseId`
+    - `business.sessionId`
+    - `business.sessionToken`
+    - `business.sessionTokenExpiresAt`
+  - 如后续字段名或层级需调整,backend 将先在 `b2f.md` 通知
+- 需要对方确认什么:
+  - 无
+- 状态:已确认
+
+### F2B-C008
+
+- 时间:2026-04-02
+- 提出方:前端
+- 当前事实:
+  - backend 已确认 ongoing / recent / result 摘要口径:
+    - `launched`、`running` 作为 ongoing
+    - `finished`、`failed`、`cancelled` 不再作为 ongoing
+    - `/me/results` 只返回终态对局
+  - 前端后续按这套摘要口径做显示与回归
+- 需要对方确认什么:
+  - 无
+- 状态:已确认
+
 ---
 
 ## 阻塞
@@ -169,6 +220,20 @@
   - 无
 - 状态:已完成
 
+### F2B-D004
+
+- 时间:2026-04-02
+- 提出方:前端
+- 当前事实:
+  - 前端已完成多赛道第一阶段接入:
+    - `backendApi / launchAdapter / GameLaunchEnvelope` 已接入 `variant` 字段
+    - 故障恢复会随 `launchEnvelope` 保留 `variant` 信息
+    - 活动页、准备页、首页、单局结果页、历史结果页开始展示赛道版本信息
+    - `manual` 模式下准备页已支持选择赛道并把 `variantId` 带入 launch
+- 需要对方确认什么:
+  - 无
+- 状态:已完成
+
 ---
 
 ## 下一步
@@ -194,4 +259,28 @@
   - 后续是否提供用户身体数据接口
 - 状态:后续事项
 
+### F2B-N003
+
+- 时间:2026-04-02
+- 提出方:前端
+- 当前事实:
+  - backend 已确认多赛道第一阶段最小契约
+  - 前端已完成第一阶段基础接入,下一步将转入多赛道专项联调与展示补强
+- 需要对方确认什么:
+  - 无
+- 状态:前端执行中
+
+### F2B-N004
+
+- 时间:2026-04-02
+- 提出方:前端
+- 当前事实:
+  - 多赛道下一步最值钱的是专项联调,而不是继续扩页面
+  - 当前优先链路为:
+    - `manual` 赛道选择 -> `launch.variant`
+    - `launch.variant` -> `ongoing / result / results`
+- 需要对方确认什么:
+  - 无
+- 状态:等待 backend 提供联调数据
+
 

+ 2 - 0
miniprogram/app.json

@@ -4,7 +4,9 @@
     "pages/login/login",
     "pages/home/home",
     "pages/event/event",
+    "pages/event-prepare/event-prepare",
     "pages/result/result",
+    "pages/results/results",
     "pages/map/map",
     "pages/experience-webview/experience-webview",
     "pages/webview-test/webview-test",

+ 2 - 0
miniprogram/app.ts

@@ -6,6 +6,8 @@ App<IAppOption>({
     telemetryPlayerProfile: null,
     backendBaseUrl: null,
     backendAuthTokens: null,
+    pendingResultSnapshot: null,
+    pendingHeartRateAutoConnect: null,
   },
   onLaunch() {
     this.globalData.backendBaseUrl = loadBackendBaseUrl()

+ 575 - 0
miniprogram/pages/event-prepare/event-prepare.ts

@@ -0,0 +1,575 @@
+import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
+import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi'
+import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
+import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
+import { HeartRateController } from '../../engine/sensor/heartRateController'
+
+const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
+const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
+const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
+
+type EventPreparePageData = {
+  eventId: string
+  loading: boolean
+  titleText: string
+  summaryText: string
+  releaseText: string
+  actionText: string
+  statusText: string
+  assignmentMode: string
+  variantModeText: string
+  variantSummaryText: string
+  selectedVariantId: string
+  selectedVariantText: string
+  selectableVariants: Array<{
+    id: string
+    name: string
+    routeCodeText: string
+    descriptionText: string
+    selected: boolean
+  }>
+  locationStatusText: string
+  heartRateStatusText: string
+  heartRateDeviceText: string
+  heartRateScanText: string
+  heartRateConnected: boolean
+  showHeartRateDevicePicker: boolean
+  locationPermissionGranted: boolean
+  locationBackgroundPermissionGranted: boolean
+  heartRateDiscoveredDevices: Array<{
+    deviceId: string
+    name: string
+    rssiText: string
+    preferred: boolean
+    connected: boolean
+  }>
+  mockSourceStatusText: string
+}
+
+function formatAssignmentMode(mode?: string | null): string {
+  if (mode === 'manual') {
+    return '手动选择'
+  }
+  if (mode === 'random') {
+    return '随机分配'
+  }
+  if (mode === 'server-assigned') {
+    return '后台指定'
+  }
+  return '默认单赛道'
+}
+
+function formatVariantSummary(result: BackendEventPlayResult): string {
+  const variants = result.play.courseVariants || []
+  if (!variants.length) {
+    return '当前未声明额外赛道版本,启动时按默认赛道进入。'
+  }
+
+  const preview = variants.map((item) => {
+    const title = item.routeCode || item.name
+    return item.selectable === false ? `${title}(固定)` : title
+  }).join(' / ')
+
+  if (result.play.assignmentMode === 'manual') {
+    return `当前活动支持 ${variants.length} 条赛道。本阶段前端先展示赛道信息,最终绑定以后端 launch 返回为准:${preview}`
+  }
+
+  if (result.play.assignmentMode === 'random') {
+    return `当前活动支持 ${variants.length} 条赛道,进入地图前由后端随机绑定:${preview}`
+  }
+
+  if (result.play.assignmentMode === 'server-assigned') {
+    return `当前活动赛道由后台预先指定:${preview}`
+  }
+
+  return preview
+}
+
+function resolveSelectedVariantId(
+  currentVariantId: string,
+  assignmentMode?: string | null,
+  variants?: BackendCourseVariantSummary[] | null,
+): string {
+  if (assignmentMode !== 'manual' || !variants || !variants.length) {
+    return ''
+  }
+
+  const selectable = variants.filter((item) => item.selectable !== false)
+  if (!selectable.length) {
+    return ''
+  }
+
+  const currentStillExists = selectable.some((item) => item.id === currentVariantId)
+  if (currentVariantId && currentStillExists) {
+    return currentVariantId
+  }
+
+  return selectable[0].id
+}
+
+function buildSelectableVariants(
+  selectedVariantId: string,
+  assignmentMode?: string | null,
+  variants?: BackendCourseVariantSummary[] | null,
+) {
+  if (assignmentMode !== 'manual' || !variants || !variants.length) {
+    return []
+  }
+
+  return variants
+    .filter((item) => item.selectable !== false)
+    .map((item) => ({
+      id: item.id,
+      name: item.name,
+      routeCodeText: item.routeCode || '默认编码',
+      descriptionText: item.description || '暂无赛道说明',
+      selected: item.id === selectedVariantId,
+    }))
+}
+
+let prepareHeartRateController: HeartRateController | null = null
+
+function getAccessToken(): string | null {
+  const app = getApp<IAppOption>()
+  const tokens = app.globalData && app.globalData.backendAuthTokens
+    ? app.globalData.backendAuthTokens
+    : loadBackendAuthTokens()
+  return tokens && tokens.accessToken ? tokens.accessToken : null
+}
+
+function loadPreferredHeartRateDeviceName(): string | null {
+  try {
+    const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY)
+    if (!stored || typeof stored !== 'object') {
+      return null
+    }
+    const normalized = stored as { name?: unknown }
+    return typeof normalized.name === 'string' && normalized.name.trim().length > 0
+      ? normalized.name.trim()
+      : '心率带'
+  } catch (_error) {
+    return null
+  }
+}
+
+function loadStoredMockChannelId(): string {
+  try {
+    const stored = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY)
+    if (typeof stored === 'string' && stored.trim().length > 0) {
+      return stored.trim()
+    }
+  } catch (_error) {
+    return 'default'
+  }
+  return 'default'
+}
+
+function loadMockAutoConnectEnabled(): boolean {
+  try {
+    return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true
+  } catch (_error) {
+    return false
+  }
+}
+
+Page({
+  data: {
+    eventId: '',
+    loading: false,
+    titleText: '开始前准备',
+    summaryText: '未加载',
+    releaseText: '--',
+    actionText: '--',
+    statusText: '待加载',
+    assignmentMode: '',
+    variantModeText: '--',
+    variantSummaryText: '--',
+    selectedVariantId: '',
+    selectedVariantText: '当前无需手动指定赛道',
+    selectableVariants: [],
+    locationStatusText: '待进入地图后校验定位权限与实时精度',
+    heartRateStatusText: '局前心率带连接入口待接入,本轮先保留骨架',
+    heartRateDeviceText: '--',
+    heartRateScanText: '未扫描',
+    heartRateConnected: false,
+    showHeartRateDevicePicker: false,
+    locationPermissionGranted: false,
+    locationBackgroundPermissionGranted: false,
+    heartRateDiscoveredDevices: [],
+    mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用',
+  } as EventPreparePageData,
+
+  onLoad(query: { eventId?: string }) {
+    const eventId = query && query.eventId ? decodeURIComponent(query.eventId) : ''
+    if (!eventId) {
+      this.setData({
+        statusText: '缺少 eventId',
+      })
+      return
+    }
+    this.setData({ eventId })
+    this.ensurePrepareHeartRateController()
+    this.refreshPreparationDeviceState()
+    this.loadEventPlay(eventId)
+  },
+
+  onShow() {
+    this.refreshPreparationDeviceState()
+  },
+
+  onUnload() {
+    if (prepareHeartRateController) {
+      prepareHeartRateController.destroy()
+      prepareHeartRateController = null
+    }
+  },
+
+  async loadEventPlay(eventId?: string) {
+    const targetEventId = eventId || this.data.eventId
+    const accessToken = getAccessToken()
+    if (!accessToken) {
+      wx.redirectTo({ url: '/pages/login/login' })
+      return
+    }
+
+    this.setData({
+      loading: true,
+      statusText: '正在加载局前准备信息',
+    })
+
+    try {
+      const result = await getEventPlay({
+        baseUrl: loadBackendBaseUrl(),
+        eventId: targetEventId,
+        accessToken,
+      })
+      this.applyEventPlay(result)
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        loading: false,
+        statusText: `局前准备加载失败:${message}`,
+      })
+    }
+  },
+
+  applyEventPlay(result: BackendEventPlayResult) {
+    const selectedVariantId = resolveSelectedVariantId(
+      this.data.selectedVariantId,
+      result.play.assignmentMode,
+      result.play.courseVariants,
+    )
+    const selectableVariants = buildSelectableVariants(
+      selectedVariantId,
+      result.play.assignmentMode,
+      result.play.courseVariants,
+    )
+    const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null
+    this.setData({
+      loading: false,
+      titleText: `${result.event.displayName} / 开始前准备`,
+      summaryText: result.event.summary || '暂无活动简介',
+      releaseText: result.resolvedRelease
+        ? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}`
+        : '当前无可用 release',
+      actionText: `${result.play.primaryAction} / ${result.play.reason}`,
+      statusText: result.play.canLaunch ? '准备完成,可进入地图' : '当前不可启动',
+      assignmentMode: result.play.assignmentMode || '',
+      variantModeText: formatAssignmentMode(result.play.assignmentMode),
+      variantSummaryText: formatVariantSummary(result),
+      selectedVariantId,
+      selectedVariantText: selectedVariant
+        ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
+        : '当前无需手动指定赛道',
+      selectableVariants,
+    })
+  },
+
+  refreshPreparationDeviceState() {
+    this.refreshLocationPermissionStatus()
+    this.refreshHeartRatePreparationStatus()
+    this.refreshMockSourcePreparationStatus()
+  },
+
+  ensurePrepareHeartRateController() {
+    if (prepareHeartRateController) {
+      return prepareHeartRateController
+    }
+
+    prepareHeartRateController = new HeartRateController({
+      onHeartRate: () => {},
+      onStatus: (message) => {
+        this.setData({
+          heartRateStatusText: message,
+        })
+      },
+      onError: (message) => {
+        this.setData({
+          heartRateStatusText: message,
+        })
+      },
+      onConnectionChange: (connected, deviceName) => {
+        this.setData({
+          heartRateConnected: connected,
+          heartRateDeviceText: connected ? (deviceName || '心率带') : (deviceName || '--'),
+        })
+        this.refreshHeartRatePreparationStatus()
+      },
+      onDeviceListChange: (devices) => {
+        this.setData({
+          heartRateScanText: devices.length ? `已发现 ${devices.length} 个设备` : '未扫描',
+          heartRateDiscoveredDevices: devices.map((device) => ({
+            deviceId: device.deviceId,
+            name: device.name,
+            rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
+            preferred: !!device.isPreferred,
+            connected: !!prepareHeartRateController
+              && !!prepareHeartRateController.currentDeviceId
+              && prepareHeartRateController.currentDeviceId === device.deviceId
+              && prepareHeartRateController.connected,
+          })),
+        })
+      },
+    })
+
+    return prepareHeartRateController
+  },
+
+  refreshLocationPermissionStatus() {
+    wx.getSetting({
+      success: (result) => {
+        const authSetting = result && result.authSetting
+          ? result.authSetting as Record<string, boolean | undefined>
+          : {}
+        const hasForeground = authSetting['scope.userLocation'] === true
+        const hasBackground = authSetting['scope.userLocationBackground'] === true
+        let locationStatusText = '未请求定位权限'
+        if (hasForeground && hasBackground) {
+          locationStatusText = '已授权前后台定位'
+        } else if (hasForeground) {
+          locationStatusText = '已授权前台定位'
+        } else if (authSetting['scope.userLocation'] === false) {
+          locationStatusText = '定位权限被拒绝'
+        }
+        this.setData({
+          locationStatusText,
+          locationPermissionGranted: hasForeground,
+          locationBackgroundPermissionGranted: hasBackground,
+        })
+      },
+      fail: () => {
+        this.setData({
+          locationStatusText: '无法读取定位权限状态',
+          locationPermissionGranted: false,
+          locationBackgroundPermissionGranted: false,
+        })
+      },
+    })
+  },
+
+  handleRequestLocationPermission() {
+    wx.authorize({
+      scope: 'scope.userLocation',
+      success: () => {
+        this.refreshLocationPermissionStatus()
+        wx.showToast({
+          title: '前台定位已授权',
+          icon: 'none',
+        })
+      },
+      fail: () => {
+        this.refreshLocationPermissionStatus()
+        wx.showToast({
+          title: '请在设置中开启定位权限',
+          icon: 'none',
+        })
+      },
+    })
+  },
+
+  handleOpenLocationSettings() {
+    wx.openSetting({
+      success: () => {
+        this.refreshLocationPermissionStatus()
+      },
+      fail: () => {
+        wx.showToast({
+          title: '无法打开设置面板',
+          icon: 'none',
+        })
+      },
+    })
+  },
+
+  refreshHeartRatePreparationStatus() {
+    const controller = this.ensurePrepareHeartRateController()
+    const preferredDeviceName = loadPreferredHeartRateDeviceName()
+    this.setData({
+      heartRateStatusText: controller.connected
+        ? '局前心率带已连接'
+        : preferredDeviceName
+          ? `已记住首选设备:${preferredDeviceName}`
+          : '未设置首选设备,可在此连接或进入地图后连接',
+      heartRateDeviceText: controller.currentDeviceName || preferredDeviceName || '--',
+      heartRateScanText: controller.scanning
+        ? '扫描中'
+        : (controller.discoveredDevices.length ? `已发现 ${controller.discoveredDevices.length} 个设备` : '未扫描'),
+      heartRateConnected: controller.connected,
+      heartRateDiscoveredDevices: controller.discoveredDevices.map((device) => ({
+        deviceId: device.deviceId,
+        name: device.name,
+        rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
+        preferred: !!device.isPreferred,
+        connected: !!controller.currentDeviceId && controller.currentDeviceId === device.deviceId && controller.connected,
+      })),
+    })
+  },
+
+  refreshMockSourcePreparationStatus() {
+    const channelId = loadStoredMockChannelId()
+    const autoConnect = loadMockAutoConnectEnabled()
+    this.setData({
+      mockSourceStatusText: autoConnect
+        ? `自动连接已开启 / 通道 ${channelId}`
+        : `自动连接未开启 / 通道 ${channelId}`,
+    })
+  },
+
+  handleRefresh() {
+    this.loadEventPlay()
+  },
+
+  handleBack() {
+    wx.navigateBack()
+  },
+
+  handlePrepareHeartRateConnect() {
+    const controller = this.ensurePrepareHeartRateController()
+    controller.startScanAndConnect()
+    this.refreshHeartRatePreparationStatus()
+  },
+
+  handleOpenHeartRateDevicePicker() {
+    const controller = this.ensurePrepareHeartRateController()
+    this.setData({
+      showHeartRateDevicePicker: true,
+    })
+    if (!controller.scanning) {
+      controller.startScanAndConnect()
+    }
+    this.refreshHeartRatePreparationStatus()
+  },
+
+  handleCloseHeartRateDevicePicker() {
+    this.setData({
+      showHeartRateDevicePicker: false,
+    })
+  },
+
+  handlePrepareHeartRateDeviceConnect(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
+    const deviceId = event.currentTarget.dataset.deviceId
+    if (!deviceId) {
+      return
+    }
+    const controller = this.ensurePrepareHeartRateController()
+    controller.connectToDiscoveredDevice(deviceId)
+    this.setData({
+      showHeartRateDevicePicker: false,
+    })
+    this.refreshHeartRatePreparationStatus()
+  },
+
+  handlePrepareHeartRateDisconnect() {
+    if (!prepareHeartRateController) {
+      return
+    }
+    prepareHeartRateController.disconnect()
+    this.setData({
+      heartRateConnected: false,
+    })
+    this.refreshHeartRatePreparationStatus()
+  },
+
+  handlePrepareHeartRateClearPreferred() {
+    const controller = this.ensurePrepareHeartRateController()
+    controller.clearPreferredDevice()
+    this.refreshHeartRatePreparationStatus()
+  },
+
+  handleSelectVariant(event: WechatMiniprogram.BaseEvent<{ variantId?: string }>) {
+    const variantId = event.currentTarget.dataset.variantId
+    if (!variantId) {
+      return
+    }
+
+    const selectableVariants = this.data.selectableVariants.map((item) => ({
+      ...item,
+      selected: item.id === variantId,
+    }))
+    const selectedVariant = selectableVariants.find((item) => item.id === variantId) || null
+    this.setData({
+      selectedVariantId: variantId,
+      selectedVariantText: selectedVariant
+        ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
+        : '当前无需手动指定赛道',
+      selectableVariants,
+    })
+  },
+
+  async handleLaunch() {
+    const accessToken = getAccessToken()
+    if (!accessToken) {
+      wx.redirectTo({ url: '/pages/login/login' })
+      return
+    }
+
+    if (!this.data.locationPermissionGranted) {
+      this.setData({
+        statusText: '进入地图前请先完成定位授权',
+      })
+      wx.showToast({
+        title: '请先授权定位',
+        icon: 'none',
+      })
+      return
+    }
+
+    this.setData({
+      statusText: '正在创建 session 并进入地图',
+    })
+
+    try {
+      const app = getApp<IAppOption>()
+      if (app.globalData) {
+        const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName
+          ? prepareHeartRateController.currentDeviceName
+          : loadPreferredHeartRateDeviceName()
+        app.globalData.pendingHeartRateAutoConnect = {
+          enabled: !!pendingDeviceName,
+          deviceName: pendingDeviceName || null,
+        }
+      }
+      if (prepareHeartRateController) {
+        prepareHeartRateController.destroy()
+        prepareHeartRateController = null
+      }
+      const result = await launchEvent({
+        baseUrl: loadBackendBaseUrl(),
+        eventId: this.data.eventId,
+        accessToken,
+        variantId: this.data.assignmentMode === 'manual' ? this.data.selectedVariantId : undefined,
+        clientType: 'wechat',
+        deviceKey: 'mini-dev-device-001',
+      })
+      const envelope = adaptBackendLaunchResultToEnvelope(result)
+      wx.navigateTo({
+        url: prepareMapPageUrlForLaunch(envelope),
+      })
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        statusText: `launch 失败:${message}`,
+      })
+    }
+  },
+})

+ 105 - 0
miniprogram/pages/event-prepare/event-prepare.wxml

@@ -0,0 +1,105 @@
+<scroll-view class="page" scroll-y>
+  <view class="shell">
+    <view class="hero">
+      <view class="hero__eyebrow">Prepare</view>
+      <view class="hero__title">{{titleText}}</view>
+      <view class="hero__desc">{{summaryText}}</view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">活动与发布</view>
+      <view class="summary">Release:{{releaseText}}</view>
+      <view class="summary">主动作:{{actionText}}</view>
+      <view class="summary">状态:{{statusText}}</view>
+      <view class="summary">赛道模式:{{variantModeText}}</view>
+      <view class="summary">赛道摘要:{{variantSummaryText}}</view>
+      <view class="summary">当前选择:{{selectedVariantText}}</view>
+    </view>
+
+    <view class="panel" wx:if="{{assignmentMode === 'manual' && selectableVariants.length}}">
+      <view class="panel__title">赛道选择</view>
+      <view class="summary">当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。</view>
+      <view class="variant-list">
+        <view wx:for="{{selectableVariants}}" wx:key="id" class="variant-card {{item.selected ? 'variant-card--active' : ''}}" data-variant-id="{{item.id}}" bindtap="handleSelectVariant">
+          <view class="variant-card__main">
+            <view class="variant-card__title-row">
+              <text class="variant-card__name">{{item.name}}</text>
+              <text class="variant-card__badge" wx:if="{{item.selected}}">已选中</text>
+            </view>
+            <text class="variant-card__meta">{{item.routeCodeText}}</text>
+            <text class="variant-card__meta">{{item.descriptionText}}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">设备准备</view>
+      <view class="summary">这一页现在负责局前设备准备。定位权限先在这里确认,心率带支持先连后进图,地图内仍保留局中快速重连入口。</view>
+      <view class="row">
+        <view class="row__label">定位状态</view>
+        <view class="row__value">{{locationStatusText}}</view>
+      </view>
+      <view class="summary" wx:if="{{locationPermissionGranted && !locationBackgroundPermissionGranted}}">已完成前台定位授权;如果后续需要后台持续定位,请在系统设置中补齐后台权限。</view>
+      <view class="actions">
+        <button class="btn btn--secondary" bindtap="handleRequestLocationPermission">申请定位权限</button>
+        <button class="btn btn--ghost" bindtap="handleOpenLocationSettings">打开系统设置</button>
+      </view>
+      <view class="row">
+        <view class="row__label">心率带</view>
+        <view class="row__value">{{heartRateStatusText}}</view>
+      </view>
+      <view class="row">
+        <view class="row__label">当前设备</view>
+        <view class="row__value">{{heartRateDeviceText}}</view>
+      </view>
+      <view class="row">
+        <view class="row__label">扫描状态</view>
+        <view class="row__value">{{heartRateScanText}}</view>
+      </view>
+      <view class="row">
+        <view class="row__label">模拟源</view>
+        <view class="row__value">{{mockSourceStatusText}}</view>
+      </view>
+      <view class="actions">
+        <button class="btn btn--secondary" bindtap="handleOpenHeartRateDevicePicker">选择设备</button>
+        <button class="btn btn--ghost" bindtap="handlePrepareHeartRateConnect">重新扫描</button>
+        <button class="btn btn--ghost" bindtap="handlePrepareHeartRateDisconnect">断开连接</button>
+        <button class="btn btn--ghost" bindtap="handlePrepareHeartRateClearPreferred">清除首选</button>
+      </view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">开始比赛</view>
+      <view class="summary">这一页先承担局前准备壳子,后面会继续接定位权限、心率带局前连接和设备检查。</view>
+      <view class="actions">
+        <button class="btn btn--secondary" bindtap="handleBack">返回活动页</button>
+        <button class="btn btn--ghost" bindtap="handleRefresh">刷新</button>
+        <button class="btn btn--primary" bindtap="handleLaunch">进入地图</button>
+      </view>
+    </view>
+  </view>
+
+  <view wx:if="{{showHeartRateDevicePicker}}" class="picker-mask" bindtap="handleCloseHeartRateDevicePicker"></view>
+  <view wx:if="{{showHeartRateDevicePicker}}" class="picker-sheet">
+    <view class="picker-sheet__header">
+      <view class="picker-sheet__title">选择心率带设备</view>
+      <button class="picker-sheet__close" bindtap="handleCloseHeartRateDevicePicker">关闭</button>
+    </view>
+    <view class="summary">扫描状态:{{heartRateScanText}}</view>
+    <view wx:if="{{!heartRateDiscoveredDevices.length}}" class="summary">当前还没有发现设备,可先点“重新扫描”。</view>
+    <view wx:if="{{heartRateDiscoveredDevices.length}}" class="device-list">
+      <view wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId" class="device-card">
+        <view class="device-card__main">
+          <view class="device-card__title-row">
+            <text class="device-card__name">{{item.name}}</text>
+            <text class="device-card__badge" wx:if="{{item.preferred}}">首选</text>
+            <text class="device-card__badge device-card__badge--active" wx:if="{{item.connected}}">已连接</text>
+          </view>
+          <text class="device-card__meta">{{item.rssiText}}</text>
+        </view>
+        <button class="btn {{item.connected ? 'btn--ghost' : 'btn--secondary'}} device-card__action" data-device-id="{{item.deviceId}}" bindtap="handlePrepareHeartRateDeviceConnect">{{item.connected ? '已连接' : '连接'}}</button>
+      </view>
+    </view>
+  </view>
+</scroll-view>

+ 276 - 0
miniprogram/pages/event-prepare/event-prepare.wxss

@@ -0,0 +1,276 @@
+page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
+}
+
+.page {
+  min-height: 100vh;
+}
+
+.shell {
+  display: grid;
+  gap: 24rpx;
+  padding: 28rpx 24rpx 40rpx;
+}
+
+.hero,
+.panel {
+  display: grid;
+  gap: 16rpx;
+  padding: 24rpx;
+  border-radius: 24rpx;
+}
+
+.hero {
+  background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
+  color: #ffffff;
+}
+
+.hero__eyebrow {
+  font-size: 22rpx;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: rgba(255, 255, 255, 0.72);
+}
+
+.hero__title {
+  font-size: 40rpx;
+  font-weight: 700;
+}
+
+.hero__desc {
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.84);
+  line-height: 1.6;
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.94);
+  box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
+}
+
+.panel__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.summary,
+.row__label,
+.row__value {
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: #30465f;
+}
+
+.row {
+  display: flex;
+  justify-content: space-between;
+  gap: 16rpx;
+  padding: 10rpx 0;
+  border-bottom: 2rpx solid #edf2f7;
+}
+
+.row:last-child {
+  border-bottom: 0;
+}
+
+.row__value {
+  max-width: 70%;
+  text-align: right;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.actions {
+  display: flex;
+  gap: 16rpx;
+  flex-wrap: wrap;
+}
+
+.device-list {
+  display: grid;
+  gap: 14rpx;
+}
+
+.variant-list {
+  display: grid;
+  gap: 14rpx;
+}
+
+.variant-card {
+  display: grid;
+  gap: 8rpx;
+  padding: 18rpx;
+  border-radius: 18rpx;
+  background: #f6f9fc;
+  border: 2rpx solid transparent;
+}
+
+.variant-card--active {
+  background: #edf5ff;
+  border-color: #4f86c9;
+}
+
+.variant-card__main {
+  display: grid;
+  gap: 8rpx;
+}
+
+.variant-card__title-row {
+  display: flex;
+  gap: 10rpx;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.variant-card__name {
+  font-size: 26rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.variant-card__badge {
+  padding: 4rpx 10rpx;
+  border-radius: 999rpx;
+  background: #dff3e8;
+  color: #1f6a45;
+  font-size: 20rpx;
+}
+
+.variant-card__meta {
+  font-size: 22rpx;
+  color: #5c7288;
+  line-height: 1.5;
+}
+
+.picker-mask {
+  position: fixed;
+  inset: 0;
+  background: rgba(10, 22, 38, 0.42);
+  z-index: 30;
+}
+
+.picker-sheet {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 31;
+  display: grid;
+  gap: 16rpx;
+  padding: 24rpx 24rpx 36rpx;
+  border-top-left-radius: 28rpx;
+  border-top-right-radius: 28rpx;
+  background: rgba(255, 255, 255, 0.98);
+  box-shadow: 0 -14rpx 36rpx rgba(22, 43, 71, 0.18);
+}
+
+.picker-sheet__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+}
+
+.picker-sheet__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.picker-sheet__close {
+  margin: 0;
+  min-height: 60rpx;
+  padding: 0 18rpx;
+  line-height: 60rpx;
+  border-radius: 999rpx;
+  font-size: 22rpx;
+  background: #eef3f8;
+  color: #455a72;
+}
+
+.picker-sheet__close::after {
+  border: 0;
+}
+
+.device-card {
+  display: flex;
+  justify-content: space-between;
+  gap: 16rpx;
+  align-items: center;
+  padding: 18rpx;
+  border-radius: 18rpx;
+  background: #f6f9fc;
+}
+
+.device-card__main {
+  display: grid;
+  gap: 8rpx;
+  min-width: 0;
+  flex: 1;
+}
+
+.device-card__title-row {
+  display: flex;
+  gap: 10rpx;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.device-card__name {
+  font-size: 26rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.device-card__badge {
+  padding: 4rpx 10rpx;
+  border-radius: 999rpx;
+  background: #e1ecfa;
+  color: #35567d;
+  font-size: 20rpx;
+}
+
+.device-card__badge--active {
+  background: #dff3e8;
+  color: #1f6a45;
+}
+
+.device-card__meta {
+  font-size: 22rpx;
+  color: #5c7288;
+}
+
+.device-card__action {
+  flex: none;
+}
+
+.btn {
+  margin: 0;
+  min-height: 76rpx;
+  padding: 0 24rpx;
+  line-height: 76rpx;
+  border-radius: 18rpx;
+  font-size: 26rpx;
+}
+
+.btn::after {
+  border: 0;
+}
+
+.btn--primary {
+  background: #173d73;
+  color: #ffffff;
+}
+
+.btn--secondary {
+  background: #dfeaf8;
+  color: #173d73;
+}
+
+.btn--ghost {
+  background: #ffffff;
+  color: #52657d;
+  border: 2rpx solid #d8e2ec;
+}

+ 34 - 30
miniprogram/pages/event/event.ts

@@ -1,7 +1,5 @@
 import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
-import { getEventPlay, launchEvent, type BackendEventPlayResult } from '../../utils/backendApi'
-import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
-import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
+import { getEventPlay, type BackendEventPlayResult } from '../../utils/backendApi'
 
 type EventPageData = {
   eventId: string
@@ -11,6 +9,33 @@ type EventPageData = {
   releaseText: string
   actionText: string
   statusText: string
+  variantModeText: string
+  variantSummaryText: string
+}
+
+function formatAssignmentMode(mode?: string | null): string {
+  if (mode === 'manual') {
+    return '手动选择'
+  }
+  if (mode === 'random') {
+    return '随机分配'
+  }
+  if (mode === 'server-assigned') {
+    return '后台指定'
+  }
+  return '默认单赛道'
+}
+
+function formatVariantSummary(result: BackendEventPlayResult): string {
+  const variants = result.play.courseVariants || []
+  if (!variants.length) {
+    return '当前未声明额外赛道版本'
+  }
+
+  const selectable = variants.filter((item) => item.selectable !== false)
+  const preview = variants.slice(0, 3).map((item) => item.routeCode || item.name).join(' / ')
+  const suffix = variants.length > 3 ? ' / ...' : ''
+  return `${variants.length} 条赛道,可选 ${selectable.length} 条:${preview}${suffix}`
 }
 
 function getAccessToken(): string | null {
@@ -30,6 +55,8 @@ Page({
     releaseText: '--',
     actionText: '--',
     statusText: '待加载',
+    variantModeText: '--',
+    variantSummaryText: '--',
   } as EventPageData,
 
   onLoad(query: { eventId?: string }) {
@@ -83,6 +110,8 @@ Page({
         : '当前无可用 release',
       actionText: `${result.play.primaryAction} / ${result.play.reason}`,
       statusText: result.play.canLaunch ? '可启动' : '当前不可启动',
+      variantModeText: formatAssignmentMode(result.play.assignmentMode),
+      variantSummaryText: formatVariantSummary(result),
     })
   },
 
@@ -91,33 +120,8 @@ Page({
   },
 
   async handleLaunch() {
-    const accessToken = getAccessToken()
-    if (!accessToken) {
-      wx.redirectTo({ url: '/pages/login/login' })
-      return
-    }
-
-    this.setData({
-      statusText: '正在创建 session 并进入地图',
+    wx.navigateTo({
+      url: `/pages/event-prepare/event-prepare?eventId=${encodeURIComponent(this.data.eventId)}`,
     })
-
-    try {
-      const result = await launchEvent({
-        baseUrl: loadBackendBaseUrl(),
-        eventId: this.data.eventId,
-        accessToken,
-        clientType: 'wechat',
-        deviceKey: 'mini-dev-device-001',
-      })
-      const envelope = adaptBackendLaunchResultToEnvelope(result)
-      wx.navigateTo({
-        url: prepareMapPageUrlForLaunch(envelope),
-      })
-    } catch (error) {
-      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
-      this.setData({
-        statusText: `launch 失败:${message}`,
-      })
-    }
   },
 })

+ 3 - 1
miniprogram/pages/event/event.wxml

@@ -11,9 +11,11 @@
       <view class="summary">Release:{{releaseText}}</view>
       <view class="summary">主动作:{{actionText}}</view>
       <view class="summary">状态:{{statusText}}</view>
+      <view class="summary">赛道模式:{{variantModeText}}</view>
+      <view class="summary">赛道摘要:{{variantSummaryText}}</view>
       <view class="actions">
         <button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
-        <button class="btn btn--primary" bindtap="handleLaunch">开始比赛</button>
+        <button class="btn btn--primary" bindtap="handleLaunch">前往准备页</button>
       </view>
     </view>
   </view>

+ 14 - 7
miniprogram/pages/home/home.ts

@@ -15,6 +15,17 @@ type HomePageData = {
   cards: BackendCardResult[]
 }
 
+function formatSessionSummary(session?: BackendEntryHomeResult['ongoingSession'] | null): string {
+  if (!session) {
+    return '无'
+  }
+
+  const title = session.eventName || session.eventDisplayName || session.eventId || session.id || session.sessionId
+  const status = session.status || session.sessionStatus || '--'
+  const route = session.routeCode || session.variantName || '默认赛道'
+  return `${title} / ${status} / ${route}`
+}
+
 function requireAuthToken(): string | null {
   const app = getApp<IAppOption>()
   const tokens = app.globalData && app.globalData.backendAuthTokens
@@ -79,12 +90,8 @@ Page({
       userNameText: result.user.nickname || result.user.publicId || result.user.id,
       tenantText: `${result.tenant.name} (${result.tenant.code})`,
       channelText: `${result.channel.displayName} / ${result.channel.code}`,
-      ongoingSessionText: result.ongoingSession
-        ? `${result.ongoingSession.eventName || result.ongoingSession.eventDisplayName || result.ongoingSession.eventId || result.ongoingSession.id || result.ongoingSession.sessionId} / ${result.ongoingSession.status || result.ongoingSession.sessionStatus}`
-        : '无',
-      recentSessionText: result.recentSession
-        ? `${result.recentSession.eventName || result.recentSession.eventDisplayName || result.recentSession.eventId || result.recentSession.id || result.recentSession.sessionId} / ${result.recentSession.status || result.recentSession.sessionStatus}`
-        : '无',
+      ongoingSessionText: formatSessionSummary(result.ongoingSession),
+      recentSessionText: formatSessionSummary(result.recentSession),
       cards: result.cards || [],
     })
   },
@@ -110,7 +117,7 @@ Page({
 
   handleOpenRecentResult() {
     wx.navigateTo({
-      url: '/pages/result/result',
+      url: '/pages/results/results',
     })
   },
 

+ 222 - 31
miniprogram/pages/map/map.ts

@@ -71,6 +71,7 @@ type MapPageData = MapEngineViewState & {
   showGameInfoPanel: boolean
   showResultScene: boolean
   showSystemSettingsPanel: boolean
+  showHeartRateDevicePicker: boolean
   showCenterScaleRuler: boolean
   showPunchHintBanner: boolean
   punchHintFxClass: string
@@ -92,6 +93,7 @@ type MapPageData = MapEngineViewState & {
   resultSceneHeroLabel: string
   resultSceneHeroValue: string
   resultSceneRows: MapEngineGameInfoRow[]
+  resultSceneCountdownText: string
   panelTimerText: string
   panelTimerMode: 'elapsed' | 'countdown'
   panelMileageText: string
@@ -157,6 +159,7 @@ const PUNCH_HINT_AUTO_HIDE_MS = 30000
 const PUNCH_HINT_FX_DURATION_MS = 420
 const PUNCH_HINT_HAPTIC_GAP_MS = 2400
 const SESSION_RECOVERY_PERSIST_INTERVAL_MS = 5000
+const RESULT_EXIT_REDIRECT_DELAY_MS = 3000
 let currentGameLaunchEnvelope: GameLaunchEnvelope = getDemoGameLaunchEnvelope()
 let mapEngine: MapEngine | null = null
 let stageCanvasAttached = false
@@ -172,6 +175,8 @@ let panelMileageFxTimer = 0
 let panelSpeedFxTimer = 0
 let panelHeartRateFxTimer = 0
 let sessionRecoveryPersistTimer = 0
+let resultExitRedirectTimer = 0
+let resultExitCountdownTimer = 0
 let lastPunchHintHapticAt = 0
 let currentSystemSettingsConfig: SystemSettingsConfig | undefined
 let currentRemoteMapConfig: RemoteMapConfig | undefined
@@ -179,6 +184,8 @@ let systemSettingsLockLifetimeActive = false
 let syncedBackendSessionStartId = ''
 let syncedBackendSessionFinishId = ''
 let shouldAutoRestoreRecoverySnapshot = false
+let redirectedToResultPage = false
+let pendingHeartRateSwitchDeviceName: string | null = null
 const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
 const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
 let lastCenterScaleRulerStablePatch: Pick<
@@ -469,6 +476,34 @@ function clearSessionRecoveryPersistTimer() {
   }
 }
 
+function clearResultExitRedirectTimer() {
+  if (resultExitRedirectTimer) {
+    clearTimeout(resultExitRedirectTimer)
+    resultExitRedirectTimer = 0
+  }
+}
+
+function clearResultExitCountdownTimer() {
+  if (resultExitCountdownTimer) {
+    clearInterval(resultExitCountdownTimer)
+    resultExitCountdownTimer = 0
+  }
+}
+
+function navigateAwayFromMapAfterCancel() {
+  const pages = getCurrentPages()
+  if (pages.length > 1) {
+    wx.navigateBack({
+      delta: 1,
+    })
+    return
+  }
+
+  wx.redirectTo({
+    url: '/pages/home/home',
+  })
+}
+
 function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolean {
   if (!options) {
     return false
@@ -776,11 +811,12 @@ function buildEmptyResultSceneSnapshot(): MapEngineResultSnapshot {
 
 Page({
   data: {
-    showDebugPanel: false,
-    showGameInfoPanel: false,
-    showResultScene: false,
-    showSystemSettingsPanel: false,
-    showCenterScaleRuler: false,
+      showDebugPanel: false,
+      showGameInfoPanel: false,
+      showResultScene: false,
+      showSystemSettingsPanel: false,
+      showHeartRateDevicePicker: false,
+      showCenterScaleRuler: false,
     statusBarHeight: 0,
     topInsetHeight: 12,
     hudPanelIndex: 0,
@@ -798,6 +834,7 @@ Page({
     resultSceneHeroLabel: '本局用时',
     resultSceneHeroValue: '--',
     resultSceneRows: buildEmptyResultSceneSnapshot().rows,
+    resultSceneCountdownText: '',
     panelTimerText: '00:00:00',
     panelTimerMode: 'elapsed',
     panelMileageText: '0m',
@@ -927,8 +964,11 @@ Page({
 
   onLoad(options: MapPageLaunchOptions) {
     clearSessionRecoveryPersistTimer()
+    clearResultExitRedirectTimer()
+    clearResultExitCountdownTimer()
     syncedBackendSessionStartId = ''
     syncedBackendSessionFinishId = ''
+    redirectedToResultPage = false
     shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1'
     currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
     if (!hasExplicitLaunchOptions(options)) {
@@ -959,6 +999,7 @@ Page({
         const includeRulerFields = this.data.showCenterScaleRuler
         let shouldSyncRuntimeSystemSettings = false
         let nextLockLifetimeActive = isSystemSettingsLockLifetimeActive()
+        let heartRateSwitchToastText = ''
         const nextData: Partial<MapPageData> = filterDebugOnlyPatch({
           ...nextPatch,
         }, includeDebugFields, includeRulerFields)
@@ -1054,6 +1095,8 @@ Page({
           : this.data.animationLevel
         let shouldSyncBackendSessionStart = false
         let backendSessionFinishStatus: 'finished' | 'failed' | null = null
+        let shouldOpenResultExitPrompt = false
+        let resultPageSnapshot: MapEngineResultSnapshot | null = null
 
         if (nextAnimationLevel === 'lite') {
           clearHudFxTimer('timer')
@@ -1112,13 +1155,24 @@ Page({
             shouldSyncRuntimeSystemSettings = true
             clearSessionRecoverySnapshot()
             clearSessionRecoveryPersistTimer()
-            this.syncResultSceneSnapshot()
+            clearResultExitRedirectTimer()
+            clearResultExitCountdownTimer()
+            resultPageSnapshot = mapEngine ? mapEngine.getResultSceneSnapshot() : null
             nextData.showResultScene = true
             nextData.showDebugPanel = false
             nextData.showGameInfoPanel = false
             nextData.showSystemSettingsPanel = false
             clearGameInfoPanelSyncTimer()
             backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed'
+            shouldOpenResultExitPrompt = true
+            if (resultPageSnapshot) {
+              nextData.resultSceneTitle = resultPageSnapshot.title
+              nextData.resultSceneSubtitle = resultPageSnapshot.subtitle
+              nextData.resultSceneHeroLabel = resultPageSnapshot.heroLabel
+              nextData.resultSceneHeroValue = resultPageSnapshot.heroValue
+              nextData.resultSceneRows = resultPageSnapshot.rows
+            }
+            nextData.resultSceneCountdownText = '3 秒后自动进入成绩页'
           } else if (
             nextPatch.gameSessionStatus !== this.data.gameSessionStatus
             && nextPatch.gameSessionStatus === 'idle'
@@ -1128,6 +1182,8 @@ Page({
             shouldSyncRuntimeSystemSettings = true
             clearSessionRecoverySnapshot()
             clearSessionRecoveryPersistTimer()
+            clearResultExitRedirectTimer()
+            clearResultExitCountdownTimer()
           } else if (
             nextPatch.gameSessionStatus !== this.data.gameSessionStatus
             && nextPatch.gameSessionStatus === 'running'
@@ -1138,6 +1194,19 @@ Page({
           }
         }
 
+        if (
+          pendingHeartRateSwitchDeviceName
+          && nextPatch.heartRateConnected === true
+          && typeof nextPatch.heartRateDeviceText === 'string'
+        ) {
+          const connectedDeviceName = nextPatch.heartRateDeviceText.trim()
+          if (connectedDeviceName && connectedDeviceName === pendingHeartRateSwitchDeviceName) {
+            heartRateSwitchToastText = `已切换到 ${connectedDeviceName}`
+            nextData.statusText = `已切换心率带:${connectedDeviceName}`
+            pendingHeartRateSwitchDeviceName = null
+          }
+        }
+
         if (Object.keys(nextData).length || Object.keys(derivedPatch).length) {
           this.setData({
             ...nextData,
@@ -1152,9 +1221,20 @@ Page({
             if (backendSessionFinishStatus) {
               this.syncBackendSessionFinish(backendSessionFinishStatus)
             }
-            if (shouldSyncRuntimeSystemSettings) {
-              this.applyRuntimeSystemSettings(nextLockLifetimeActive)
-            }
+              if (shouldOpenResultExitPrompt && resultPageSnapshot) {
+                this.stashPendingResultSnapshot(resultPageSnapshot)
+                this.presentResultExitPrompt()
+              }
+              if (heartRateSwitchToastText) {
+                wx.showToast({
+                  title: `${heartRateSwitchToastText},并设为首选设备`,
+                  icon: 'none',
+                  duration: 1800,
+                })
+              }
+              if (shouldSyncRuntimeSystemSettings) {
+                this.applyRuntimeSystemSettings(nextLockLifetimeActive)
+              }
             if (this.data.showGameInfoPanel) {
               this.scheduleGameInfoPanelSnapshotSync()
             }
@@ -1169,6 +1249,10 @@ Page({
           if (backendSessionFinishStatus) {
             this.syncBackendSessionFinish(backendSessionFinishStatus)
           }
+          if (shouldOpenResultExitPrompt && resultPageSnapshot) {
+            this.stashPendingResultSnapshot(resultPageSnapshot)
+            this.presentResultExitPrompt()
+          }
           if (shouldSyncRuntimeSystemSettings) {
             this.applyRuntimeSystemSettings(nextLockLifetimeActive)
           }
@@ -1209,6 +1293,7 @@ Page({
       ...buildResolvedSystemSettingsPatch(systemSettingsState),
       showDebugPanel: false,
       showGameInfoPanel: false,
+      showResultScene: false,
       showSystemSettingsPanel: false,
       statusBarHeight,
       topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
@@ -1218,6 +1303,12 @@ Page({
       gameInfoSubtitle: '未开始',
       gameInfoLocalRows: [],
       gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
+      resultSceneTitle: '本局结果',
+      resultSceneSubtitle: '未开始',
+      resultSceneHeroLabel: '本局用时',
+      resultSceneHeroValue: '--',
+      resultSceneRows: buildEmptyResultSceneSnapshot().rows,
+      resultSceneCountdownText: '',
       panelTimerText: '00:00:00',
       panelTimerMode: 'elapsed',
       panelTimerFxClass: '',
@@ -1349,6 +1440,18 @@ Page({
     stageCanvasAttached = false
     this.measureStageAndCanvas()
     this.loadGameLaunchEnvelope(currentGameLaunchEnvelope)
+    const app = getApp<IAppOption>()
+    const pendingHeartRateAutoConnect = app.globalData ? app.globalData.pendingHeartRateAutoConnect : null
+    if (pendingHeartRateAutoConnect && pendingHeartRateAutoConnect.enabled && mapEngine) {
+      const pendingDeviceName = pendingHeartRateAutoConnect.deviceName || '心率带'
+      app.globalData.pendingHeartRateAutoConnect = null
+      mapEngine.handleConnectHeartRate()
+      this.setData({
+        statusText: `正在自动连接局前设备:${pendingDeviceName}`,
+        heartRateStatusText: `正在自动连接 ${pendingDeviceName}`,
+        heartRateDeviceText: pendingDeviceName,
+      })
+    }
   },
 
   onShow() {
@@ -1360,6 +1463,8 @@ Page({
 
   onHide() {
     this.persistSessionRecoverySnapshot()
+    clearResultExitRedirectTimer()
+    clearResultExitCountdownTimer()
     if (mapEngine) {
       mapEngine.handleAppHide()
     }
@@ -1368,6 +1473,8 @@ Page({
   onUnload() {
     this.persistSessionRecoverySnapshot()
     clearSessionRecoveryPersistTimer()
+    clearResultExitRedirectTimer()
+    clearResultExitCountdownTimer()
     syncedBackendSessionStartId = ''
     syncedBackendSessionFinishId = ''
     clearGameInfoPanelSyncTimer()
@@ -1388,6 +1495,7 @@ Page({
     systemSettingsLockLifetimeActive = false
     currentGameLaunchEnvelope = getDemoGameLaunchEnvelope()
     shouldAutoRestoreRecoverySnapshot = false
+    redirectedToResultPage = false
     stageCanvasAttached = false
   },
 
@@ -1528,6 +1636,57 @@ Page({
       })
   },
 
+  stashPendingResultSnapshot(snapshot: MapEngineResultSnapshot) {
+    const app = getApp<IAppOption>()
+    if (app.globalData) {
+      app.globalData.pendingResultSnapshot = snapshot
+    }
+  },
+
+  redirectToResultPage() {
+    if (redirectedToResultPage) {
+      return
+    }
+    clearResultExitRedirectTimer()
+    clearResultExitCountdownTimer()
+    redirectedToResultPage = true
+    const sessionContext = getCurrentBackendSessionContext()
+    const resultUrl = sessionContext
+      ? `/pages/result/result?sessionId=${encodeURIComponent(sessionContext.sessionId)}`
+      : '/pages/result/result'
+    wx.redirectTo({
+      url: resultUrl,
+    })
+  },
+
+  presentResultExitPrompt() {
+    clearResultExitRedirectTimer()
+    clearResultExitCountdownTimer()
+
+    let remainingSeconds = Math.ceil(RESULT_EXIT_REDIRECT_DELAY_MS / 1000)
+    this.setData({
+      showResultScene: true,
+      resultSceneCountdownText: `${remainingSeconds} 秒后自动进入成绩页`,
+    })
+
+    resultExitCountdownTimer = setInterval(() => {
+      remainingSeconds -= 1
+      if (remainingSeconds <= 0) {
+        clearResultExitCountdownTimer()
+        return
+      }
+
+      this.setData({
+        resultSceneCountdownText: `${remainingSeconds} 秒后自动进入成绩页`,
+      })
+    }, 1000) as unknown as number
+
+    resultExitRedirectTimer = setTimeout(() => {
+      resultExitRedirectTimer = 0
+      this.redirectToResultPage()
+    }, RESULT_EXIT_REDIRECT_DELAY_MS) as unknown as number
+  },
+
   restoreRecoverySnapshot(snapshot: SessionRecoverySnapshot) {
     systemSettingsLockLifetimeActive = true
     this.applyRuntimeSystemSettings(true)
@@ -2052,20 +2211,53 @@ Page({
   },
 
   handleConnectHeartRate() {
-    if (mapEngine) {
-      mapEngine.handleConnectHeartRate()
-    }
-  },
+      if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') {
+        return
+      }
+      if (mapEngine) {
+        mapEngine.handleConnectHeartRate()
+      }
+    },
 
-    handleDisconnectHeartRate() {
+    handleOpenHeartRateDevicePicker() {
+      if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') {
+        return
+      }
+      this.setData({
+        showHeartRateDevicePicker: true,
+      })
       if (mapEngine) {
-        mapEngine.handleDisconnectHeartRate()
+        mapEngine.handleConnectHeartRate()
       }
     },
 
+    handleCloseHeartRateDevicePicker() {
+      this.setData({
+        showHeartRateDevicePicker: false,
+      })
+    },
+
+      handleDisconnectHeartRate() {
+        if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') {
+          return
+        }
+        if (mapEngine) {
+          mapEngine.handleDisconnectHeartRate()
+        }
+    },
+
     handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
       if (mapEngine && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.deviceId) {
-        mapEngine.handleConnectHeartRateDevice(event.currentTarget.dataset.deviceId)
+        const targetDeviceId = event.currentTarget.dataset.deviceId
+        const targetDevice = this.data.heartRateDiscoveredDevices.find((item) => item.deviceId === targetDeviceId)
+        pendingHeartRateSwitchDeviceName = targetDevice ? targetDevice.name : null
+        mapEngine.handleConnectHeartRateDevice(targetDeviceId)
+        this.setData({
+          showHeartRateDevicePicker: false,
+          statusText: targetDevice
+            ? `正在切换到 ${targetDevice.name}`
+            : '正在切换心率带设备',
+        })
       }
     },
 
@@ -2174,9 +2366,21 @@ Page({
         cancelText: '取消',
         success: (result) => {
           if (result.confirm && mapEngine) {
+            clearResultExitRedirectTimer()
+            clearResultExitCountdownTimer()
             this.syncBackendSessionFinish('cancelled')
+            clearSessionRecoverySnapshot()
+            clearSessionRecoveryPersistTimer()
             systemSettingsLockLifetimeActive = false
             mapEngine.handleForceExitGame()
+            wx.showToast({
+              title: '已退出当前对局',
+              icon: 'none',
+              duration: 1000,
+            })
+            setTimeout(() => {
+              navigateAwayFromMapAfterCancel()
+            }, 180)
           }
         },
       })
@@ -2312,24 +2516,11 @@ Page({
   handleResultSceneTap() {},
 
   handleCloseResultScene() {
-    this.setData({
-      showResultScene: false,
-    })
+    this.redirectToResultPage()
   },
 
   handleRestartFromResult() {
-    if (!mapEngine) {
-      return
-    }
-    this.setData({
-      showResultScene: false,
-    }, () => {
-      if (mapEngine) {
-        systemSettingsLockLifetimeActive = true
-        this.applyRuntimeSystemSettings(true)
-        mapEngine.handleStartGame()
-      }
-    })
+    this.redirectToResultPage()
   },
 
   handleOpenSystemSettingsPanel() {

+ 50 - 23
miniprogram/pages/map/map.wxml

@@ -324,7 +324,7 @@
 
   <view class="result-scene-modal" wx:if="{{showResultScene}}" bindtap="handleCloseResultScene">
     <view class="result-scene-modal__dialog" catchtap="handleResultSceneTap">
-      <view class="result-scene-modal__eyebrow">RESULT</view>
+      <view class="result-scene-modal__eyebrow">FINISH</view>
       <view class="result-scene-modal__title">{{resultSceneTitle}}</view>
       <view class="result-scene-modal__subtitle">{{resultSceneSubtitle}}</view>
 
@@ -340,9 +340,10 @@
         </view>
       </view>
 
+      <view class="result-scene-modal__countdown">{{resultSceneCountdownText}}</view>
+
       <view class="result-scene-modal__actions">
-        <view class="result-scene-modal__action result-scene-modal__action--secondary" bindtap="handleCloseResultScene">返回地图</view>
-        <view class="result-scene-modal__action result-scene-modal__action--primary" bindtap="handleRestartFromResult">再来一局</view>
+        <view class="result-scene-modal__action result-scene-modal__action--primary" bindtap="handleRestartFromResult">查看成绩</view>
       </view>
     </view>
   </view>
@@ -726,13 +727,31 @@
                 <view class="debug-section__header-row">
                   <view class="debug-section__header-main">
                     <view class="debug-section__title">16. 心率设备</view>
-                  <view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
+                  <view class="debug-section__desc">局内正式入口,可快速更换、重连或断开当前心率带</view>
                 </view>
                 <view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}">
                   <text class="debug-section__lock-text">{{lockHeartRateDevice ? '配置锁定' : '允许调整'}}</text>
                 </view>
               </view>
             </view>
+            <view class="info-panel__row">
+              <text class="info-panel__label">当前状态</text>
+              <text class="info-panel__value">{{heartRateStatusText}}{{heartRateSourceMode !== 'real' ? ' · 当前为模拟模式' : ''}}</text>
+            </view>
+            <view class="info-panel__row info-panel__row--stack">
+              <text class="info-panel__label">当前设备</text>
+              <text class="info-panel__value">{{heartRateDeviceText}}</text>
+            </view>
+            <view class="info-panel__row" wx:if="{{heartRateSourceMode === 'real'}}">
+              <text class="info-panel__label">扫描状态</text>
+              <text class="info-panel__value">{{heartRateScanText}}</text>
+            </view>
+            <view class="summary" wx:if="{{heartRateSourceMode !== 'real'}}">当前为模拟心率模式,如需连接真实心率带,请先在调试面板切回“真实心率”。</view>
+            <view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
+              <view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleOpenHeartRateDevicePicker">更换心率带</view>
+              <view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}} {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '重新扫描' : '连接心率带'}}</view>
+              <view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleDisconnectHeartRate">断开心率带</view>
+            </view>
             <view class="control-row">
               <view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleClearPreferredHeartRateDevice">清除首选设备</view>
             </view>
@@ -897,25 +916,10 @@
             <text class="info-panel__label">HR Scan</text>
             <text class="info-panel__value">{{heartRateScanText}}</text>
           </view>
-          <view class="debug-device-list" wx:if="{{heartRateSourceMode === 'real' && heartRateDiscoveredDevices.length}}">
-            <view class="debug-device-card" wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId">
-              <view class="debug-device-card__main">
-                <view class="debug-device-card__title-row">
-                  <text class="debug-device-card__name">{{item.name}}</text>
-                  <text class="debug-device-card__badge" wx:if="{{item.preferred}}">首选</text>
-                </view>
-                <text class="debug-device-card__meta">{{item.rssiText}}</text>
-              </view>
-              <view class="debug-device-card__action {{item.connected ? 'debug-device-card__action--active' : ''}}" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</view>
-            </view>
-          </view>
           <view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
-            <view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
-            <view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
-          </view>
-          <view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
-            <view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
+            <view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '重新扫描' : '连接心率带'}}</view>
           </view>
+          <view class="summary" wx:if="{{heartRateSourceMode === 'real'}}">正式用户入口已放到系统设置;这里仅保留心率源切换与开发调试能力。</view>
           <view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
             <text class="info-panel__label">心率模拟状态</text>
             <text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
@@ -1169,9 +1173,32 @@
             <text class="info-panel__value">{{networkFetchCount}}</text>
           </view>
         </view>
-      </scroll-view>
+        </scroll-view>
+      </view>
+    </view>
+
+    <view wx:if="{{showHeartRateDevicePicker}}" class="picker-mask" bindtap="handleCloseHeartRateDevicePicker"></view>
+    <view wx:if="{{showHeartRateDevicePicker}}" class="picker-sheet">
+      <view class="picker-sheet__header">
+        <view class="picker-sheet__title">选择心率带设备</view>
+        <button class="picker-sheet__close" bindtap="handleCloseHeartRateDevicePicker">关闭</button>
+      </view>
+      <view class="summary">扫描状态:{{heartRateScanText}}</view>
+      <view wx:if="{{!heartRateDiscoveredDevices.length}}" class="summary">当前还没有发现设备,可先点“重新扫描”。</view>
+      <view wx:if="{{heartRateDiscoveredDevices.length}}" class="device-list">
+        <view wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId" class="device-card">
+          <view class="device-card__main">
+            <view class="device-card__title-row">
+              <text class="device-card__name">{{item.name}}</text>
+              <text class="device-card__badge" wx:if="{{item.preferred}}">首选</text>
+              <text class="device-card__badge device-card__badge--active" wx:if="{{item.connected}}">已连接</text>
+            </view>
+            <text class="device-card__meta">{{item.rssiText}}</text>
+          </view>
+          <button class="btn {{item.connected ? 'btn--ghost' : 'btn--secondary'}} device-card__action" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</button>
+        </view>
+      </view>
     </view>
-  </view>
 </view>
 
 

+ 145 - 0
miniprogram/pages/map/map.wxss

@@ -1458,6 +1458,14 @@
   text-align: right;
 }
 
+.result-scene-modal__countdown {
+  margin-top: 18rpx;
+  text-align: center;
+  font-size: 22rpx;
+  line-height: 1.4;
+  color: #6a826f;
+}
+
 .result-scene-modal__actions {
   margin-top: 28rpx;
   display: flex;
@@ -1781,6 +1789,143 @@
   color: #f7fbf2;
 }
 
+.picker-mask {
+  position: absolute;
+  inset: 0;
+  background: rgba(10, 22, 38, 0.42);
+  z-index: 90;
+}
+
+.picker-sheet {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 91;
+  display: grid;
+  gap: 16rpx;
+  padding: 24rpx 24rpx 36rpx;
+  border-top-left-radius: 28rpx;
+  border-top-right-radius: 28rpx;
+  background: rgba(255, 255, 255, 0.98);
+  box-shadow: 0 -14rpx 36rpx rgba(22, 43, 71, 0.18);
+}
+
+.picker-sheet__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16rpx;
+}
+
+.picker-sheet__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.picker-sheet__close {
+  margin: 0;
+  min-height: 60rpx;
+  padding: 0 18rpx;
+  line-height: 60rpx;
+  border-radius: 999rpx;
+  font-size: 22rpx;
+  background: #eef3f8;
+  color: #455a72;
+}
+
+.picker-sheet__close::after {
+  border: 0;
+}
+
+.summary {
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: #30465f;
+}
+
+.device-list {
+  display: grid;
+  gap: 14rpx;
+}
+
+.device-card {
+  display: flex;
+  justify-content: space-between;
+  gap: 16rpx;
+  align-items: center;
+  padding: 18rpx;
+  border-radius: 18rpx;
+  background: #f6f9fc;
+}
+
+.device-card__main {
+  display: grid;
+  gap: 8rpx;
+  min-width: 0;
+  flex: 1;
+}
+
+.device-card__title-row {
+  display: flex;
+  gap: 10rpx;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.device-card__name {
+  font-size: 26rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.device-card__badge {
+  padding: 4rpx 10rpx;
+  border-radius: 999rpx;
+  background: #e1ecfa;
+  color: #35567d;
+  font-size: 20rpx;
+}
+
+.device-card__badge--active {
+  background: #dff3e8;
+  color: #1f6a45;
+}
+
+.device-card__meta {
+  font-size: 22rpx;
+  color: #5c7288;
+}
+
+.device-card__action {
+  flex: none;
+}
+
+.btn {
+  margin: 0;
+  min-height: 76rpx;
+  padding: 0 24rpx;
+  line-height: 76rpx;
+  border-radius: 18rpx;
+  font-size: 26rpx;
+}
+
+.btn::after {
+  border: 0;
+}
+
+.btn--secondary {
+  background: #dfeaf8;
+  color: #173d73;
+}
+
+.btn--ghost {
+  background: #ffffff;
+  color: #52657d;
+  border: 2rpx solid #d8e2ec;
+}
+
 .control-row {
   display: flex;
   gap: 14rpx;

+ 56 - 49
miniprogram/pages/result/result.ts

@@ -1,5 +1,6 @@
 import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
-import { getMyResults, getSessionResult, type BackendSessionResultView } from '../../utils/backendApi'
+import { getSessionResult } from '../../utils/backendApi'
+import type { MapEngineResultSnapshot } from '../../engine/map/mapEngine'
 
 type ResultPageData = {
   sessionId: string
@@ -7,7 +8,6 @@ type ResultPageData = {
   sessionTitleText: string
   sessionSubtitleText: string
   rows: Array<{ label: string; value: string }>
-  recentResults: BackendSessionResultView[]
 }
 
 function getAccessToken(): string | null {
@@ -25,6 +25,22 @@ function formatValue(value: unknown): string {
   return String(value)
 }
 
+function formatRouteSummary(input: {
+  variantName?: string | null
+  routeCode?: string | null
+}): string {
+  if (input.variantName && input.routeCode) {
+    return `${input.variantName} / ${input.routeCode}`
+  }
+  if (input.variantName) {
+    return input.variantName
+  }
+  if (input.routeCode) {
+    return input.routeCode
+  }
+  return '默认赛道'
+}
+
 Page({
   data: {
     sessionId: '',
@@ -32,17 +48,49 @@ Page({
     sessionTitleText: '结果页',
     sessionSubtitleText: '未加载',
     rows: [],
-    recentResults: [],
   } as ResultPageData,
 
   onLoad(query: { sessionId?: string }) {
     const sessionId = query && query.sessionId ? decodeURIComponent(query.sessionId) : ''
     this.setData({ sessionId })
+    this.applyPendingResultSnapshot()
     if (sessionId) {
       this.loadSingleResult(sessionId)
       return
     }
-    this.loadRecentResults()
+    this.setData({
+      statusText: '未提供单局会话,已跳转历史结果',
+    })
+    wx.redirectTo({
+      url: '/pages/results/results',
+    })
+  },
+
+  applyPendingResultSnapshot() {
+    const app = getApp<IAppOption>()
+    const snapshot = app.globalData && app.globalData.pendingResultSnapshot
+      ? app.globalData.pendingResultSnapshot as MapEngineResultSnapshot
+      : null
+    if (!snapshot) {
+      return
+    }
+
+    this.setData({
+      statusText: '正在加载结果',
+      sessionTitleText: snapshot.title,
+      sessionSubtitleText: snapshot.subtitle,
+      rows: [
+        { label: snapshot.heroLabel, value: snapshot.heroValue },
+        ...snapshot.rows.map((row) => ({
+          label: row.label,
+          value: row.value,
+        })),
+      ],
+    })
+
+    if (app.globalData) {
+      app.globalData.pendingResultSnapshot = null
+    }
   },
 
   async loadSingleResult(sessionId: string) {
@@ -65,8 +113,9 @@ Page({
       this.setData({
         statusText: '单局结果加载完成',
         sessionTitleText: result.session.eventName || result.session.eventDisplayName || result.session.eventId || result.session.id || result.session.sessionId,
-        sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status}`,
+        sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status} / ${formatRouteSummary(result.session)}`,
         rows: [
+          { label: '赛道版本', value: formatRouteSummary(result.session) },
           { label: '最终得分', value: formatValue(result.result.finalScore) },
           { label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) },
           { label: '完成点数', value: formatValue(result.result.completedControls) },
@@ -84,51 +133,9 @@ Page({
     }
   },
 
-  async loadRecentResults() {
-    const accessToken = getAccessToken()
-    if (!accessToken) {
-      wx.redirectTo({ url: '/pages/login/login' })
-      return
-    }
-
-    this.setData({
-      statusText: '正在加载最近结果',
-    })
-
-    try {
-      const results = await getMyResults({
-        baseUrl: loadBackendBaseUrl(),
-        accessToken,
-        limit: 20,
-      })
-      this.setData({
-        statusText: '最近结果加载完成',
-        sessionSubtitleText: '最近结果列表',
-        recentResults: results,
-      })
-    } catch (error) {
-      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
-      this.setData({
-        statusText: `结果加载失败:${message}`,
-      })
-    }
-  },
-
-  handleOpenResult(event: WechatMiniprogram.TouchEvent) {
-    const sessionId = event.currentTarget.dataset.sessionId as string | undefined
-    if (!sessionId) {
-      return
-    }
-    wx.redirectTo({
-      url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`,
-    })
-  },
-
   handleBackToList() {
-    this.setData({
-      sessionId: '',
-      rows: [],
+    wx.redirectTo({
+      url: '/pages/results/results',
     })
-    this.loadRecentResults()
   },
 })

+ 1 - 11
miniprogram/pages/result/result.wxml

@@ -9,7 +9,7 @@
     <view class="panel">
       <view class="panel__title">当前状态</view>
       <view class="summary">{{statusText}}</view>
-      <button wx:if="{{sessionId}}" class="btn btn--ghost" bindtap="handleBackToList">返回最近结果</button>
+      <button class="btn btn--ghost" bindtap="handleBackToList">查看历史结果</button>
     </view>
 
     <view wx:if="{{rows.length}}" class="panel">
@@ -19,15 +19,5 @@
         <view class="row__value">{{item.value}}</view>
       </view>
     </view>
-
-    <view wx:if="{{!sessionId}}" class="panel">
-      <view class="panel__title">最近结果</view>
-      <view wx:if="{{!recentResults.length}}" class="summary">当前没有结果记录</view>
-      <view wx:for="{{recentResults}}" wx:key="session.id" class="result-card" bindtap="handleOpenResult" data-session-id="{{item.session.id}}">
-        <view class="result-card__title">{{item.session.eventName || item.session.id}}</view>
-        <view class="result-card__meta">{{item.result.status}} / {{item.session.status}}</view>
-        <view class="result-card__meta">得分 {{item.result.finalScore || '--'}} / 用时 {{item.result.finalDurationSec || '--'}}s</view>
-      </view>
-    </view>
   </view>
 </scroll-view>

+ 104 - 0
miniprogram/pages/results/results.ts

@@ -0,0 +1,104 @@
+import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
+import { getMyResults, type BackendSessionResultView } from '../../utils/backendApi'
+
+type ResultsPageData = {
+  loading: boolean
+  statusText: string
+  results: Array<{
+    sessionId: string
+    titleText: string
+    statusText: string
+    scoreText: string
+    routeText: string
+  }>
+}
+
+function getAccessToken(): string | null {
+  const app = getApp<IAppOption>()
+  const tokens = app.globalData && app.globalData.backendAuthTokens
+    ? app.globalData.backendAuthTokens
+    : loadBackendAuthTokens()
+  return tokens && tokens.accessToken ? tokens.accessToken : null
+}
+
+function formatRouteSummary(result: BackendSessionResultView): string {
+  const session = result.session
+  if (session.variantName && session.routeCode) {
+    return `${session.variantName} / ${session.routeCode}`
+  }
+  if (session.variantName) {
+    return session.variantName
+  }
+  if (session.routeCode) {
+    return session.routeCode
+  }
+  return '默认赛道'
+}
+
+function buildResultCardView(result: BackendSessionResultView) {
+  return {
+    sessionId: result.session.id,
+    titleText: result.session.eventName || result.session.id,
+    statusText: `${result.result.status} / ${result.session.status}`,
+    scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`,
+    routeText: `赛道 ${formatRouteSummary(result)}`,
+  }
+}
+
+Page({
+  data: {
+    loading: false,
+    statusText: '准备加载历史结果',
+    results: [],
+  } as ResultsPageData,
+
+  onLoad() {
+    this.loadResults()
+  },
+
+  onShow() {
+    this.loadResults()
+  },
+
+  async loadResults() {
+    const accessToken = getAccessToken()
+    if (!accessToken) {
+      wx.redirectTo({ url: '/pages/login/login' })
+      return
+    }
+
+    this.setData({
+      loading: true,
+      statusText: '正在加载历史结果',
+    })
+
+    try {
+      const results = await getMyResults({
+        baseUrl: loadBackendBaseUrl(),
+        accessToken,
+        limit: 20,
+      })
+      this.setData({
+        loading: false,
+        statusText: `历史结果加载完成,共 ${results.length} 条`,
+        results: results.map(buildResultCardView),
+      })
+    } catch (error) {
+      const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
+      this.setData({
+        loading: false,
+        statusText: `历史结果加载失败:${message}`,
+      })
+    }
+  },
+
+  handleOpenResult(event: WechatMiniprogram.TouchEvent) {
+    const sessionId = event.currentTarget.dataset.sessionId as string | undefined
+    if (!sessionId) {
+      return
+    }
+    wx.navigateTo({
+      url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`,
+    })
+  },
+})

+ 25 - 0
miniprogram/pages/results/results.wxml

@@ -0,0 +1,25 @@
+<scroll-view class="page" scroll-y>
+  <view class="shell">
+    <view class="hero">
+      <view class="hero__eyebrow">Results</view>
+      <view class="hero__title">历史结果</view>
+      <view class="hero__desc">查看最近联调与正式对局结果</view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">当前状态</view>
+      <view class="summary">{{statusText}}</view>
+    </view>
+
+    <view class="panel">
+      <view class="panel__title">结果列表</view>
+      <view wx:if="{{!results.length}}" class="summary">当前没有结果记录</view>
+      <view wx:for="{{results}}" wx:key="sessionId" class="result-card" bindtap="handleOpenResult" data-session-id="{{item.sessionId}}">
+        <view class="result-card__title">{{item.titleText}}</view>
+        <view class="result-card__meta">{{item.statusText}}</view>
+        <view class="result-card__meta">{{item.scoreText}}</view>
+        <view class="result-card__meta">{{item.routeText}}</view>
+      </view>
+    </view>
+  </view>
+</scroll-view>

+ 76 - 0
miniprogram/pages/results/results.wxss

@@ -0,0 +1,76 @@
+page {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
+}
+
+.page {
+  min-height: 100vh;
+}
+
+.shell {
+  display: grid;
+  gap: 24rpx;
+  padding: 28rpx 24rpx 40rpx;
+}
+
+.hero,
+.panel {
+  display: grid;
+  gap: 16rpx;
+  padding: 24rpx;
+  border-radius: 24rpx;
+}
+
+.hero {
+  background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
+  color: #ffffff;
+}
+
+.hero__eyebrow {
+  font-size: 22rpx;
+  letter-spacing: 0.16em;
+  text-transform: uppercase;
+  color: rgba(255, 255, 255, 0.72);
+}
+
+.hero__title {
+  font-size: 40rpx;
+  font-weight: 700;
+}
+
+.hero__desc {
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.84);
+}
+
+.panel {
+  background: rgba(255, 255, 255, 0.94);
+  box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
+}
+
+.panel__title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #17345a;
+}
+
+.summary,
+.result-card__meta {
+  font-size: 24rpx;
+  line-height: 1.6;
+  color: #30465f;
+}
+
+.result-card {
+  display: grid;
+  gap: 8rpx;
+  padding: 18rpx;
+  border-radius: 18rpx;
+  background: #f6f9fc;
+}
+
+.result-card__title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #17345a;
+}

+ 24 - 0
miniprogram/utils/backendApi.ts

@@ -30,6 +30,21 @@ export interface BackendResolvedRelease {
   routeCode?: string | null
 }
 
+export interface BackendCourseVariantSummary {
+  id: string
+  name: string
+  description?: string | null
+  routeCode?: string | null
+  selectable?: boolean
+}
+
+export interface BackendLaunchVariantSummary {
+  id: string
+  name: string
+  routeCode?: string | null
+  assignmentMode?: string | null
+}
+
 export interface BackendEntrySessionSummary {
   id: string
   status: string
@@ -38,6 +53,8 @@ export interface BackendEntrySessionSummary {
   releaseId?: string | null
   configLabel?: string | null
   routeCode?: string | null
+  variantId?: string | null
+  variantName?: string | null
   launchedAt?: string | null
   startedAt?: string | null
   endedAt?: string | null
@@ -111,6 +128,8 @@ export interface BackendEventPlayResult {
     primaryAction: string
     reason: string
     launchSource?: string
+    assignmentMode?: string | null
+    courseVariants?: BackendCourseVariantSummary[] | null
     ongoingSession?: BackendEntrySessionSummary | null
     recentSession?: BackendEntrySessionSummary | null
   }
@@ -139,6 +158,7 @@ export interface BackendLaunchResult {
       sessionTokenExpiresAt: string
       routeCode?: string | null
     }
+    variant?: BackendLaunchVariantSummary | null
   }
 }
 
@@ -294,6 +314,7 @@ export function launchEvent(input: {
   eventId: string
   accessToken: string
   releaseId?: string
+  variantId?: string
   clientType: string
   deviceKey: string
 }): Promise<BackendLaunchResult> {
@@ -304,6 +325,9 @@ export function launchEvent(input: {
   if (input.releaseId) {
     body.releaseId = input.releaseId
   }
+  if (input.variantId) {
+    body.variantId = input.variantId
+  }
   return requestBackend<BackendLaunchResult>({
     method: 'POST',
     baseUrl: input.baseUrl,

+ 12 - 0
miniprogram/utils/backendLaunchAdapter.ts

@@ -17,5 +17,17 @@ export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult):
       sessionToken: result.launch.business.sessionToken,
       sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt,
     },
+    variant: result.launch.variant
+      ? {
+        variantId: result.launch.variant.id,
+        variantName: result.launch.variant.name,
+        routeCode: result.launch.variant.routeCode || result.launch.config.routeCode || result.launch.business.routeCode || null,
+        assignmentMode: result.launch.variant.assignmentMode || null,
+      }
+      : (result.launch.config.routeCode || result.launch.business.routeCode)
+        ? {
+          routeCode: result.launch.config.routeCode || result.launch.business.routeCode || null,
+        }
+        : null,
   }
 }

+ 35 - 0
miniprogram/utils/gameLaunch.ts

@@ -22,9 +22,17 @@ export interface BusinessLaunchContext {
   realtimeToken?: string | null
 }
 
+export interface GameVariantLaunchContext {
+  variantId?: string | null
+  variantName?: string | null
+  routeCode?: string | null
+  assignmentMode?: string | null
+}
+
 export interface GameLaunchEnvelope {
   config: GameConfigLaunchRequest
   business: BusinessLaunchContext | null
+  variant?: GameVariantLaunchContext | null
 }
 
 export interface MapPageLaunchOptions {
@@ -46,6 +54,9 @@ export interface MapPageLaunchOptions {
   sessionTokenExpiresAt?: string
   realtimeEndpoint?: string
   realtimeToken?: string
+  variantId?: string
+  variantName?: string
+  assignmentMode?: string
 }
 
 type PendingGameLaunchStore = Record<string, GameLaunchEnvelope>
@@ -121,6 +132,28 @@ function buildBusinessLaunchContext(options?: MapPageLaunchOptions | null): Busi
   }
 }
 
+function buildVariantLaunchContext(options?: MapPageLaunchOptions | null): GameVariantLaunchContext | null {
+  if (!options) {
+    return null
+  }
+
+  const variantId = normalizeOptionalString(options.variantId)
+  const variantName = normalizeOptionalString(options.variantName)
+  const routeCode = normalizeOptionalString(options.routeCode)
+  const assignmentMode = normalizeOptionalString(options.assignmentMode)
+
+  if (!variantId && !variantName && !routeCode && !assignmentMode) {
+    return null
+  }
+
+  return {
+    variantId,
+    variantName,
+    routeCode,
+    assignmentMode,
+  }
+}
+
 function loadPendingGameLaunchStore(): PendingGameLaunchStore {
   try {
     const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY)
@@ -146,6 +179,7 @@ export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): G
     business: {
       source: 'demo',
     },
+    variant: null,
   }
 }
 
@@ -217,6 +251,7 @@ export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null)
         routeCode: normalizeOptionalString(options ? options.routeCode : undefined),
       },
       business: buildBusinessLaunchContext(options),
+      variant: buildVariantLaunchContext(options),
     }
   }
 

+ 5 - 0
typings/index.d.ts

@@ -6,6 +6,11 @@ interface IAppOption {
     telemetryPlayerProfile?: import('../miniprogram/game/telemetry/playerTelemetryProfile').PlayerTelemetryProfile | null,
     backendBaseUrl?: string | null,
     backendAuthTokens?: import('../miniprogram/utils/backendAuth').BackendAuthTokens | null,
+    pendingResultSnapshot?: import('../miniprogram/engine/map/mapEngine').MapEngineResultSnapshot | null,
+    pendingHeartRateAutoConnect?: {
+      enabled: boolean,
+      deviceName?: string | null,
+    } | null,
   }
   userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
 }

Some files were not shown because too many files changed in this diff