declare const console: { log: (...args: unknown[]) => void } import { buildGameDefinitionFromCourse } from '../miniprogram/game/content/courseToGameDefinition' import { getGameModeDefaults } from '../miniprogram/game/core/gameModeDefaults' import { GameRuntime } from '../miniprogram/game/core/gameRuntime' import { ScoreORule } from '../miniprogram/game/rules/scoreORule' import { resolveSystemSettingsState } from '../miniprogram/game/core/systemSettingsState' import { adaptBackendLaunchResultToEnvelope } from '../miniprogram/utils/backendLaunchAdapter' import { type GameDefinition } from '../miniprogram/game/core/gameDefinition' import { type BackendLaunchResult } from '../miniprogram/utils/backendApi' import { type OrienteeringCourseData } from '../miniprogram/utils/orienteeringCourse' type StorageMap = Record function assert(condition: boolean, message: string): void { if (!condition) { throw new Error(message) } } function createWxStorage(storage: StorageMap): void { ;(globalThis as { wx?: unknown }).wx = { getStorageSync(key: string): unknown { return storage[key] }, setStorageSync(key: string, value: unknown): void { storage[key] = value }, } } function buildCourse(): OrienteeringCourseData { return { title: 'Smoke Test Course', layers: { starts: [ { label: 'Start', point: { lon: 120.0, lat: 30.0 }, headingDeg: 90 }, ], controls: [ { label: '1', sequence: 1, point: { lon: 120.0001, lat: 30.0 } }, { label: '2', sequence: 2, point: { lon: 120.0002, lat: 30.0 } }, ], finishes: [ { label: 'Finish', point: { lon: 120.0003, lat: 30.0 } }, ], legs: [], }, } } function getControl(definition: GameDefinition, id: string) { return definition.controls.find((control) => control.id === id) || null } function testControlInheritance(): void { const definition = buildGameDefinitionFromCourse( buildCourse(), 5, 'score-o', undefined, undefined, undefined, undefined, 'enter-confirm', 5, false, false, undefined, undefined, { 'control-2': 80 }, { title: '默认说明', body: '所有点默认正文', }, { 'control-2': { body: '2号点单点覆盖正文', }, }, 30, ) const control1 = getControl(definition, 'control-1') const control2 = getControl(definition, 'control-2') assert(!!control1 && !!control2, '应生成普通检查点') assert(control1!.score === 30, 'controlDefaults 默认分值应继承到普通点') assert(control2!.score === 80, '单点 score override 应覆盖默认分值') assert(!!control1!.displayContent && control1!.displayContent.title === '默认说明', '默认内容标题应继承到普通点') assert(!!control2!.displayContent && control2!.displayContent.body === '2号点单点覆盖正文', '单点内容 override 应覆盖默认正文') } function testScoreOFreePunchAndFinishGate(): void { const definition = buildGameDefinitionFromCourse( buildCourse(), 5, 'score-o', 2 * 60 * 60 * 1000, 10 * 60 * 1000, 1, false, 'enter-confirm', 5, false, ) const rule = new ScoreORule() let state = rule.initialize(definition) let result = rule.reduce(definition, state, { type: 'session_started', at: 1 }) state = result.nextState result = rule.reduce(definition, state, { type: 'gps_updated', at: 2, lon: 120.0, lat: 30.0, accuracyMeters: null, }) state = result.nextState result = rule.reduce(definition, state, { type: 'punch_requested', at: 3, lon: 120.0, lat: 30.0, }) state = result.nextState assert(state.completedControlIds.includes('start-1'), '积分赛应能完成开始点') result = rule.reduce(definition, state, { type: 'gps_updated', at: 4, lon: 120.0001, lat: 30.0, accuracyMeters: null, }) state = result.nextState assert(result.presentation.hud.punchButtonEnabled, '自由打点时进入普通点范围应可直接打点') result = rule.reduce(definition, state, { type: 'punch_requested', at: 5, lon: 120.0001, lat: 30.0, }) state = result.nextState assert(state.completedControlIds.includes('control-1'), '积分赛默认无需先选中也应可打普通点') const preFinishState = rule.initialize(definition) let preFinishResult = rule.reduce(definition, preFinishState, { type: 'session_started', at: 10 }) let runningState = preFinishResult.nextState preFinishResult = rule.reduce(definition, runningState, { type: 'gps_updated', at: 11, lon: 120.0, lat: 30.0, accuracyMeters: null, }) runningState = preFinishResult.nextState preFinishResult = rule.reduce(definition, runningState, { type: 'punch_requested', at: 12, lon: 120.0, lat: 30.0, }) runningState = preFinishResult.nextState preFinishResult = rule.reduce(definition, runningState, { type: 'punch_requested', at: 13, lon: 120.0003, lat: 30.0, }) assert(preFinishResult.effects.some((effect) => effect.type === 'punch_feedback'), '未完成最低点数前打终点应被拦截') result = rule.reduce(definition, state, { type: 'gps_updated', at: 6, lon: 120.0003, lat: 30.0, accuracyMeters: null, }) state = result.nextState assert(result.presentation.hud.punchButtonText === '结束打卡', '终点进入范围后按钮文案应切为结束打卡') result = rule.reduce(definition, state, { type: 'punch_requested', at: 7, lon: 120.0003, lat: 30.0, }) state = result.nextState assert(state.status === 'finished' && state.endReason === 'completed', '达到终点解锁条件后应可正常结束') } function testSettingsLockLifecycle(): void { const storage: StorageMap = { cmr_user_settings_v1: { gpsMarkerStyle: 'dot', trackDisplayMode: 'full', }, } createWxStorage(storage) const runtimeLocked = resolveSystemSettingsState( { values: { gpsMarkerStyle: 'beacon', }, locks: { lockGpsMarkerStyle: true, }, }, 'cmr_user_settings_v1', true, ) assert(runtimeLocked.values.gpsMarkerStyle === 'beacon', '本局锁定时应以配置值为准') assert(runtimeLocked.locks.lockGpsMarkerStyle, '本局内锁态应生效') const runtimeReleased = resolveSystemSettingsState( { values: { gpsMarkerStyle: 'beacon', }, locks: { lockGpsMarkerStyle: true, }, }, 'cmr_user_settings_v1', false, ) assert(runtimeReleased.values.gpsMarkerStyle === 'dot', '脱离本局后应回落到玩家持久化设置') assert(!runtimeReleased.locks.lockGpsMarkerStyle, '脱离本局后锁态应自动解除') } function testTimeoutEndReason(): void { const definition = buildGameDefinitionFromCourse(buildCourse(), 5, 'classic-sequential') const rule = new ScoreORule() const state = rule.initialize(definition) const result = rule.reduce(definition, state, { type: 'session_timed_out', at: 99 }) assert(result.nextState.status === 'failed', '超时应进入 failed 状态') assert(result.nextState.endReason === 'timed_out', '超时结束原因应为 timed_out') } function testClassicSequentialSkipConfirmDefault(): void { const defaults = getGameModeDefaults('classic-sequential') assert(defaults.skipEnabled, '顺序打点默认应开启跳点') assert(defaults.skipRequiresConfirm, '顺序打点默认跳点应弹出确认') } function testRuntimeRestoreDefinition(): void { const definition = buildGameDefinitionFromCourse( buildCourse(), 5, 'score-o', 2 * 60 * 60 * 1000, 10 * 60 * 1000, 1, false, 'enter-confirm', 5, false, ) const runtime = new GameRuntime() runtime.loadDefinition(definition) runtime.startSession(1) runtime.dispatch({ type: 'gps_updated', at: 2, lon: 120.0, lat: 30.0, accuracyMeters: null, }) runtime.dispatch({ type: 'punch_requested', at: 3, lon: 120.0, lat: 30.0, }) runtime.dispatch({ type: 'gps_updated', at: 4, lon: 120.0001, lat: 30.0, accuracyMeters: null, }) runtime.dispatch({ type: 'punch_requested', at: 5, lon: 120.0001, lat: 30.0, }) const savedState = runtime.state assert(!!savedState, '恢复测试前应存在对局状态') const restoredRuntime = new GameRuntime() const restoreResult = restoredRuntime.restoreDefinition(definition, savedState!) assert(restoredRuntime.state !== null, '恢复后应保留对局状态') assert(restoredRuntime.state!.completedControlIds.includes('control-1'), '恢复后应保留已完成检查点') assert(restoredRuntime.state!.status === 'running', '恢复后对局应继续保持 running') assert(restoreResult.presentation.hud.punchButtonText === runtime.presentation.hud.punchButtonText, '恢复后 HUD 关键按钮文案应可重建') } function testLaunchRuntimeAdapter(): void { const launchResult: BackendLaunchResult = { event: { id: 'evt_demo_variant_manual_001', displayName: 'Manual Variant Demo', }, launch: { source: 'event', resolvedRelease: { launchMode: 'formal-release', source: 'current-release', eventId: 'evt_demo_variant_manual_001', releaseId: 'rel_runtime_001', configLabel: 'runtime demo', manifestUrl: 'https://example.com/releases/rel_runtime_001/manifest.json', manifestChecksumSha256: 'manifest-sha-001', routeCode: 'route-variant-b', }, config: { configUrl: 'https://example.com/runtime.json', configLabel: 'runtime demo', releaseId: 'rel_runtime_001', routeCode: 'route-variant-b', }, business: { source: 'direct-event', eventId: 'evt_demo_variant_manual_001', sessionId: 'sess_001', sessionToken: 'token_001', sessionTokenExpiresAt: '2026-04-03T16:00:00+08:00', routeCode: 'route-variant-b', }, variant: { id: 'variant_b', name: 'B 线', routeCode: 'route-variant-b', assignmentMode: 'manual', }, runtime: { runtimeBindingId: 'rtb_001', placeId: 'place_campus', placeName: '示范校园', mapId: 'map_main', mapName: '主图', tileReleaseId: 'tile_rel_001', courseSetId: 'course_set_001', courseVariantId: 'variant_b', routeCode: 'route-variant-b', }, presentation: { presentationId: 'pres_001', templateKey: 'campus-v1', version: 'v3', }, contentBundle: { bundleId: 'bundle_001', bundleType: 'quiz-pack', version: 'v7', }, }, } const envelope = adaptBackendLaunchResultToEnvelope(launchResult) assert(!!envelope.resolvedRelease, 'resolvedRelease 应映射到 GameLaunchEnvelope.resolvedRelease') assert(envelope.resolvedRelease!.manifestUrl === 'https://example.com/releases/rel_runtime_001/manifest.json', 'resolvedRelease.manifestUrl 应正确适配') assert(envelope.resolvedRelease!.releaseId === 'rel_runtime_001', 'resolvedRelease.releaseId 应正确适配') assert(!!envelope.runtime, 'launch.runtime 应映射到 GameLaunchEnvelope.runtime') assert(envelope.runtime!.runtimeBindingId === 'rtb_001', 'runtimeBindingId 应正确适配') assert(envelope.runtime!.placeName === '示范校园', 'placeName 应正确适配') assert(envelope.runtime!.mapName === '主图', 'mapName 应正确适配') assert(envelope.runtime!.courseVariantId === 'variant_b', 'courseVariantId 应正确适配') assert(envelope.runtime!.routeCode === 'route-variant-b', 'runtime routeCode 应优先保留后端透出值') assert(!!envelope.variant && envelope.variant.variantName === 'B 线', 'variant 摘要应继续保持兼容') assert(!!envelope.presentation && envelope.presentation.presentationId === 'pres_001', 'launch.presentation 应映射到 GameLaunchEnvelope.presentation') assert(!!envelope.contentBundle && envelope.contentBundle.bundleId === 'bundle_001', 'launch.contentBundle 应映射到 GameLaunchEnvelope.contentBundle') } function run(): void { createWxStorage({}) testControlInheritance() testScoreOFreePunchAndFinishGate() testSettingsLockLifecycle() testTimeoutEndReason() testClassicSequentialSkipConfirmDefault() testRuntimeRestoreDefinition() testLaunchRuntimeAdapter() console.log('runtime smoke tests passed') } run()