runtime-smoke-test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. declare const console: {
  2. log: (...args: unknown[]) => void
  3. }
  4. import { buildGameDefinitionFromCourse } from '../miniprogram/game/content/courseToGameDefinition'
  5. import { getGameModeDefaults } from '../miniprogram/game/core/gameModeDefaults'
  6. import { GameRuntime } from '../miniprogram/game/core/gameRuntime'
  7. import { ScoreORule } from '../miniprogram/game/rules/scoreORule'
  8. import { resolveSystemSettingsState } from '../miniprogram/game/core/systemSettingsState'
  9. import { adaptBackendLaunchResultToEnvelope } from '../miniprogram/utils/backendLaunchAdapter'
  10. import { type GameDefinition } from '../miniprogram/game/core/gameDefinition'
  11. import { type BackendLaunchResult } from '../miniprogram/utils/backendApi'
  12. import { type OrienteeringCourseData } from '../miniprogram/utils/orienteeringCourse'
  13. type StorageMap = Record<string, unknown>
  14. function assert(condition: boolean, message: string): void {
  15. if (!condition) {
  16. throw new Error(message)
  17. }
  18. }
  19. function createWxStorage(storage: StorageMap): void {
  20. ;(globalThis as { wx?: unknown }).wx = {
  21. getStorageSync(key: string): unknown {
  22. return storage[key]
  23. },
  24. setStorageSync(key: string, value: unknown): void {
  25. storage[key] = value
  26. },
  27. }
  28. }
  29. function buildCourse(): OrienteeringCourseData {
  30. return {
  31. title: 'Smoke Test Course',
  32. layers: {
  33. starts: [
  34. { label: 'Start', point: { lon: 120.0, lat: 30.0 }, headingDeg: 90 },
  35. ],
  36. controls: [
  37. { label: '1', sequence: 1, point: { lon: 120.0001, lat: 30.0 } },
  38. { label: '2', sequence: 2, point: { lon: 120.0002, lat: 30.0 } },
  39. ],
  40. finishes: [
  41. { label: 'Finish', point: { lon: 120.0003, lat: 30.0 } },
  42. ],
  43. legs: [],
  44. },
  45. }
  46. }
  47. function getControl(definition: GameDefinition, id: string) {
  48. return definition.controls.find((control) => control.id === id) || null
  49. }
  50. function testControlInheritance(): void {
  51. const definition = buildGameDefinitionFromCourse(
  52. buildCourse(),
  53. 5,
  54. 'score-o',
  55. undefined,
  56. undefined,
  57. undefined,
  58. undefined,
  59. 'enter-confirm',
  60. 5,
  61. false,
  62. false,
  63. undefined,
  64. undefined,
  65. { 'control-2': 80 },
  66. {
  67. title: '默认说明',
  68. body: '所有点默认正文',
  69. },
  70. {
  71. 'control-2': {
  72. body: '2号点单点覆盖正文',
  73. },
  74. },
  75. 30,
  76. )
  77. const control1 = getControl(definition, 'control-1')
  78. const control2 = getControl(definition, 'control-2')
  79. assert(!!control1 && !!control2, '应生成普通检查点')
  80. assert(control1!.score === 30, 'controlDefaults 默认分值应继承到普通点')
  81. assert(control2!.score === 80, '单点 score override 应覆盖默认分值')
  82. assert(!!control1!.displayContent && control1!.displayContent.title === '默认说明', '默认内容标题应继承到普通点')
  83. assert(!!control2!.displayContent && control2!.displayContent.body === '2号点单点覆盖正文', '单点内容 override 应覆盖默认正文')
  84. }
  85. function testScoreOFreePunchAndFinishGate(): void {
  86. const definition = buildGameDefinitionFromCourse(
  87. buildCourse(),
  88. 5,
  89. 'score-o',
  90. 2 * 60 * 60 * 1000,
  91. 10 * 60 * 1000,
  92. 1,
  93. false,
  94. 'enter-confirm',
  95. 5,
  96. false,
  97. )
  98. const rule = new ScoreORule()
  99. let state = rule.initialize(definition)
  100. let result = rule.reduce(definition, state, { type: 'session_started', at: 1 })
  101. state = result.nextState
  102. result = rule.reduce(definition, state, {
  103. type: 'gps_updated',
  104. at: 2,
  105. lon: 120.0,
  106. lat: 30.0,
  107. accuracyMeters: null,
  108. })
  109. state = result.nextState
  110. result = rule.reduce(definition, state, {
  111. type: 'punch_requested',
  112. at: 3,
  113. lon: 120.0,
  114. lat: 30.0,
  115. })
  116. state = result.nextState
  117. assert(state.completedControlIds.includes('start-1'), '积分赛应能完成开始点')
  118. result = rule.reduce(definition, state, {
  119. type: 'gps_updated',
  120. at: 4,
  121. lon: 120.0001,
  122. lat: 30.0,
  123. accuracyMeters: null,
  124. })
  125. state = result.nextState
  126. assert(result.presentation.hud.punchButtonEnabled, '自由打点时进入普通点范围应可直接打点')
  127. result = rule.reduce(definition, state, {
  128. type: 'punch_requested',
  129. at: 5,
  130. lon: 120.0001,
  131. lat: 30.0,
  132. })
  133. state = result.nextState
  134. assert(state.completedControlIds.includes('control-1'), '积分赛默认无需先选中也应可打普通点')
  135. const preFinishState = rule.initialize(definition)
  136. let preFinishResult = rule.reduce(definition, preFinishState, { type: 'session_started', at: 10 })
  137. let runningState = preFinishResult.nextState
  138. preFinishResult = rule.reduce(definition, runningState, {
  139. type: 'gps_updated',
  140. at: 11,
  141. lon: 120.0,
  142. lat: 30.0,
  143. accuracyMeters: null,
  144. })
  145. runningState = preFinishResult.nextState
  146. preFinishResult = rule.reduce(definition, runningState, {
  147. type: 'punch_requested',
  148. at: 12,
  149. lon: 120.0,
  150. lat: 30.0,
  151. })
  152. runningState = preFinishResult.nextState
  153. preFinishResult = rule.reduce(definition, runningState, {
  154. type: 'punch_requested',
  155. at: 13,
  156. lon: 120.0003,
  157. lat: 30.0,
  158. })
  159. assert(preFinishResult.effects.some((effect) => effect.type === 'punch_feedback'), '未完成最低点数前打终点应被拦截')
  160. result = rule.reduce(definition, state, {
  161. type: 'gps_updated',
  162. at: 6,
  163. lon: 120.0003,
  164. lat: 30.0,
  165. accuracyMeters: null,
  166. })
  167. state = result.nextState
  168. assert(result.presentation.hud.punchButtonText === '结束打卡', '终点进入范围后按钮文案应切为结束打卡')
  169. result = rule.reduce(definition, state, {
  170. type: 'punch_requested',
  171. at: 7,
  172. lon: 120.0003,
  173. lat: 30.0,
  174. })
  175. state = result.nextState
  176. assert(state.status === 'finished' && state.endReason === 'completed', '达到终点解锁条件后应可正常结束')
  177. }
  178. function testSettingsLockLifecycle(): void {
  179. const storage: StorageMap = {
  180. cmr_user_settings_v1: {
  181. gpsMarkerStyle: 'dot',
  182. trackDisplayMode: 'full',
  183. },
  184. }
  185. createWxStorage(storage)
  186. const runtimeLocked = resolveSystemSettingsState(
  187. {
  188. values: {
  189. gpsMarkerStyle: 'beacon',
  190. },
  191. locks: {
  192. lockGpsMarkerStyle: true,
  193. },
  194. },
  195. 'cmr_user_settings_v1',
  196. true,
  197. )
  198. assert(runtimeLocked.values.gpsMarkerStyle === 'beacon', '本局锁定时应以配置值为准')
  199. assert(runtimeLocked.locks.lockGpsMarkerStyle, '本局内锁态应生效')
  200. const runtimeReleased = resolveSystemSettingsState(
  201. {
  202. values: {
  203. gpsMarkerStyle: 'beacon',
  204. },
  205. locks: {
  206. lockGpsMarkerStyle: true,
  207. },
  208. },
  209. 'cmr_user_settings_v1',
  210. false,
  211. )
  212. assert(runtimeReleased.values.gpsMarkerStyle === 'dot', '脱离本局后应回落到玩家持久化设置')
  213. assert(!runtimeReleased.locks.lockGpsMarkerStyle, '脱离本局后锁态应自动解除')
  214. }
  215. function testTimeoutEndReason(): void {
  216. const definition = buildGameDefinitionFromCourse(buildCourse(), 5, 'classic-sequential')
  217. const rule = new ScoreORule()
  218. const state = rule.initialize(definition)
  219. const result = rule.reduce(definition, state, { type: 'session_timed_out', at: 99 })
  220. assert(result.nextState.status === 'failed', '超时应进入 failed 状态')
  221. assert(result.nextState.endReason === 'timed_out', '超时结束原因应为 timed_out')
  222. }
  223. function testClassicSequentialSkipConfirmDefault(): void {
  224. const defaults = getGameModeDefaults('classic-sequential')
  225. assert(defaults.skipEnabled, '顺序打点默认应开启跳点')
  226. assert(defaults.skipRequiresConfirm, '顺序打点默认跳点应弹出确认')
  227. }
  228. function testRuntimeRestoreDefinition(): void {
  229. const definition = buildGameDefinitionFromCourse(
  230. buildCourse(),
  231. 5,
  232. 'score-o',
  233. 2 * 60 * 60 * 1000,
  234. 10 * 60 * 1000,
  235. 1,
  236. false,
  237. 'enter-confirm',
  238. 5,
  239. false,
  240. )
  241. const runtime = new GameRuntime()
  242. runtime.loadDefinition(definition)
  243. runtime.startSession(1)
  244. runtime.dispatch({
  245. type: 'gps_updated',
  246. at: 2,
  247. lon: 120.0,
  248. lat: 30.0,
  249. accuracyMeters: null,
  250. })
  251. runtime.dispatch({
  252. type: 'punch_requested',
  253. at: 3,
  254. lon: 120.0,
  255. lat: 30.0,
  256. })
  257. runtime.dispatch({
  258. type: 'gps_updated',
  259. at: 4,
  260. lon: 120.0001,
  261. lat: 30.0,
  262. accuracyMeters: null,
  263. })
  264. runtime.dispatch({
  265. type: 'punch_requested',
  266. at: 5,
  267. lon: 120.0001,
  268. lat: 30.0,
  269. })
  270. const savedState = runtime.state
  271. assert(!!savedState, '恢复测试前应存在对局状态')
  272. const restoredRuntime = new GameRuntime()
  273. const restoreResult = restoredRuntime.restoreDefinition(definition, savedState!)
  274. assert(restoredRuntime.state !== null, '恢复后应保留对局状态')
  275. assert(restoredRuntime.state!.completedControlIds.includes('control-1'), '恢复后应保留已完成检查点')
  276. assert(restoredRuntime.state!.status === 'running', '恢复后对局应继续保持 running')
  277. assert(restoreResult.presentation.hud.punchButtonText === runtime.presentation.hud.punchButtonText, '恢复后 HUD 关键按钮文案应可重建')
  278. }
  279. function testLaunchRuntimeAdapter(): void {
  280. const launchResult: BackendLaunchResult = {
  281. event: {
  282. id: 'evt_demo_variant_manual_001',
  283. displayName: 'Manual Variant Demo',
  284. },
  285. launch: {
  286. source: 'event',
  287. resolvedRelease: {
  288. launchMode: 'formal-release',
  289. source: 'current-release',
  290. eventId: 'evt_demo_variant_manual_001',
  291. releaseId: 'rel_runtime_001',
  292. configLabel: 'runtime demo',
  293. manifestUrl: 'https://example.com/releases/rel_runtime_001/manifest.json',
  294. manifestChecksumSha256: 'manifest-sha-001',
  295. routeCode: 'route-variant-b',
  296. },
  297. config: {
  298. configUrl: 'https://example.com/runtime.json',
  299. configLabel: 'runtime demo',
  300. releaseId: 'rel_runtime_001',
  301. routeCode: 'route-variant-b',
  302. },
  303. business: {
  304. source: 'direct-event',
  305. eventId: 'evt_demo_variant_manual_001',
  306. sessionId: 'sess_001',
  307. sessionToken: 'token_001',
  308. sessionTokenExpiresAt: '2026-04-03T16:00:00+08:00',
  309. routeCode: 'route-variant-b',
  310. },
  311. variant: {
  312. id: 'variant_b',
  313. name: 'B 线',
  314. routeCode: 'route-variant-b',
  315. assignmentMode: 'manual',
  316. },
  317. runtime: {
  318. runtimeBindingId: 'rtb_001',
  319. placeId: 'place_campus',
  320. placeName: '示范校园',
  321. mapId: 'map_main',
  322. mapName: '主图',
  323. tileReleaseId: 'tile_rel_001',
  324. courseSetId: 'course_set_001',
  325. courseVariantId: 'variant_b',
  326. routeCode: 'route-variant-b',
  327. },
  328. presentation: {
  329. presentationId: 'pres_001',
  330. templateKey: 'campus-v1',
  331. version: 'v3',
  332. },
  333. contentBundle: {
  334. bundleId: 'bundle_001',
  335. bundleType: 'quiz-pack',
  336. version: 'v7',
  337. },
  338. },
  339. }
  340. const envelope = adaptBackendLaunchResultToEnvelope(launchResult)
  341. assert(!!envelope.resolvedRelease, 'resolvedRelease 应映射到 GameLaunchEnvelope.resolvedRelease')
  342. assert(envelope.resolvedRelease!.manifestUrl === 'https://example.com/releases/rel_runtime_001/manifest.json', 'resolvedRelease.manifestUrl 应正确适配')
  343. assert(envelope.resolvedRelease!.releaseId === 'rel_runtime_001', 'resolvedRelease.releaseId 应正确适配')
  344. assert(!!envelope.runtime, 'launch.runtime 应映射到 GameLaunchEnvelope.runtime')
  345. assert(envelope.runtime!.runtimeBindingId === 'rtb_001', 'runtimeBindingId 应正确适配')
  346. assert(envelope.runtime!.placeName === '示范校园', 'placeName 应正确适配')
  347. assert(envelope.runtime!.mapName === '主图', 'mapName 应正确适配')
  348. assert(envelope.runtime!.courseVariantId === 'variant_b', 'courseVariantId 应正确适配')
  349. assert(envelope.runtime!.routeCode === 'route-variant-b', 'runtime routeCode 应优先保留后端透出值')
  350. assert(!!envelope.variant && envelope.variant.variantName === 'B 线', 'variant 摘要应继续保持兼容')
  351. assert(!!envelope.presentation && envelope.presentation.presentationId === 'pres_001', 'launch.presentation 应映射到 GameLaunchEnvelope.presentation')
  352. assert(!!envelope.contentBundle && envelope.contentBundle.bundleId === 'bundle_001', 'launch.contentBundle 应映射到 GameLaunchEnvelope.contentBundle')
  353. }
  354. function run(): void {
  355. createWxStorage({})
  356. testControlInheritance()
  357. testScoreOFreePunchAndFinishGate()
  358. testSettingsLockLifecycle()
  359. testTimeoutEndReason()
  360. testClassicSequentialSkipConfirmDefault()
  361. testRuntimeRestoreDefinition()
  362. testLaunchRuntimeAdapter()
  363. console.log('runtime smoke tests passed')
  364. }
  365. run()