feat: 收敛玩法运行时配置并加入故障恢复

This commit is contained in:
2026-04-01 13:04:26 +08:00
parent 1635a11780
commit 3ef841ecc7
73 changed files with 8820 additions and 2122 deletions

View File

@@ -45,6 +45,16 @@ import {
type GpsMarkerStyleConfig,
type GpsMarkerStyleId,
} from '../game/presentation/gpsMarkerStyleConfig'
import {
getDefaultSkipRadiusMeters,
getGameModeDefaults,
resolveDefaultControlScore,
} from '../game/core/gameModeDefaults'
import {
type SystemSettingsConfig,
type SettingLockKey,
type StoredUserSettings,
} from '../game/core/systemSettingsState'
export interface TileZoomBounds {
minX: number
@@ -79,6 +89,9 @@ export interface RemoteMapConfig {
courseStatusText: string
cpRadiusMeters: number
gameMode: 'classic-sequential' | 'score-o'
sessionCloseAfterMs: number
sessionCloseWarningMs: number
minCompletedControlsBeforeFinish: number
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
requiresFocusSelection: boolean
@@ -88,7 +101,10 @@ export interface RemoteMapConfig {
autoFinishOnLastControl: boolean
controlScoreOverrides: Record<string, number>
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
defaultControlContentOverride: GameControlDisplayContentOverride | null
defaultControlPointStyleOverride: ControlPointStyleEntry | null
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
defaultLegStyleOverride: CourseLegStyleEntry | null
legStyleOverrides: Record<number, CourseLegStyleEntry>
defaultControlScore: number | null
courseStyleConfig: CourseStyleConfig
@@ -98,6 +114,7 @@ export interface RemoteMapConfig {
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
uiEffectsConfig: GameUiEffectsConfig
systemSettingsConfig: SystemSettingsConfig
}
interface ParsedGameConfig {
@@ -111,6 +128,9 @@ interface ParsedGameConfig {
cpRadiusMeters: number
defaultZoom: number | null
gameMode: 'classic-sequential' | 'score-o'
sessionCloseAfterMs: number
sessionCloseWarningMs: number
minCompletedControlsBeforeFinish: number
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
requiresFocusSelection: boolean
@@ -120,7 +140,10 @@ interface ParsedGameConfig {
autoFinishOnLastControl: boolean
controlScoreOverrides: Record<string, number>
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
defaultControlContentOverride: GameControlDisplayContentOverride | null
defaultControlPointStyleOverride: ControlPointStyleEntry | null
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
defaultLegStyleOverride: CourseLegStyleEntry | null
legStyleOverrides: Record<number, CourseLegStyleEntry>
defaultControlScore: number | null
courseStyleConfig: CourseStyleConfig
@@ -130,6 +153,7 @@ interface ParsedGameConfig {
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
uiEffectsConfig: GameUiEffectsConfig
systemSettingsConfig: SystemSettingsConfig
declinationDeg: number
}
@@ -279,6 +303,150 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
}
function parseSettingLockKey(rawValue: string): SettingLockKey | null {
const normalized = rawValue.trim().toLowerCase()
const table: Record<string, SettingLockKey> = {
animationlevel: 'lockAnimationLevel',
trackdisplaymode: 'lockTrackMode',
trackmode: 'lockTrackMode',
tracktaillength: 'lockTrackTailLength',
trackcolorpreset: 'lockTrackColor',
trackcolor: 'lockTrackColor',
trackstyleprofile: 'lockTrackStyle',
trackstyle: 'lockTrackStyle',
gpsmarkervisible: 'lockGpsMarkerVisible',
gpsmarkerstyle: 'lockGpsMarkerStyle',
gpsmarkersize: 'lockGpsMarkerSize',
gpsmarkercolorpreset: 'lockGpsMarkerColor',
gpsmarkercolor: 'lockGpsMarkerColor',
sidebuttonplacement: 'lockSideButtonPlacement',
autorotateenabled: 'lockAutoRotate',
autorotate: 'lockAutoRotate',
compasstuningprofile: 'lockCompassTuning',
compasstuning: 'lockCompassTuning',
showcenterscaleruler: 'lockScaleRulerVisible',
centerscaleruleranchormode: 'lockScaleRulerAnchor',
centerruleranchor: 'lockScaleRulerAnchor',
northreferencemode: 'lockNorthReference',
northreference: 'lockNorthReference',
heartratedevice: 'lockHeartRateDevice',
}
return table[normalized] || null
}
function assignParsedSettingValue(
target: Partial<StoredUserSettings>,
key: string,
rawValue: unknown,
): void {
const normalized = key.trim().toLowerCase()
if (normalized === 'animationlevel') {
if (rawValue === 'standard' || rawValue === 'lite') {
target.animationLevel = rawValue
}
return
}
if (normalized === 'trackdisplaymode' || normalized === 'trackmode') {
const parsed = parseTrackDisplayMode(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.mode)
target.trackDisplayMode = parsed
return
}
if (normalized === 'tracktaillength') {
if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
target.trackTailLength = rawValue
}
return
}
if (normalized === 'trackcolorpreset' || normalized === 'trackcolor') {
const parsed = parseTrackColorPreset(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset)
target.trackColorPreset = parsed
return
}
if (normalized === 'trackstyleprofile' || normalized === 'trackstyle') {
const parsed = parseTrackStyleProfile(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.style)
target.trackStyleProfile = parsed
return
}
if (normalized === 'gpsmarkervisible') {
target.gpsMarkerVisible = parseBoolean(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.visible)
return
}
if (normalized === 'gpsmarkerstyle') {
target.gpsMarkerStyle = parseGpsMarkerStyleId(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.style)
return
}
if (normalized === 'gpsmarkersize') {
target.gpsMarkerSize = parseGpsMarkerSizePreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.size)
return
}
if (normalized === 'gpsmarkercolorpreset' || normalized === 'gpsmarkercolor') {
target.gpsMarkerColorPreset = parseGpsMarkerColorPreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset)
return
}
if (normalized === 'sidebuttonplacement') {
if (rawValue === 'left' || rawValue === 'right') {
target.sideButtonPlacement = rawValue
}
return
}
if (normalized === 'autorotateenabled' || normalized === 'autorotate') {
target.autoRotateEnabled = parseBoolean(rawValue, true)
return
}
if (normalized === 'compasstuningprofile' || normalized === 'compasstuning') {
if (rawValue === 'smooth' || rawValue === 'balanced' || rawValue === 'responsive') {
target.compassTuningProfile = rawValue
}
return
}
if (normalized === 'northreferencemode' || normalized === 'northreference') {
if (rawValue === 'magnetic' || rawValue === 'true') {
target.northReferenceMode = rawValue
}
return
}
if (normalized === 'showcenterscaleruler') {
target.showCenterScaleRuler = parseBoolean(rawValue, false)
return
}
if (normalized === 'centerscaleruleranchormode' || normalized === 'centerruleranchor') {
if (rawValue === 'screen-center' || rawValue === 'compass-center') {
target.centerScaleRulerAnchorMode = rawValue
}
}
}
function parseSystemSettingsConfig(rawValue: unknown): SystemSettingsConfig {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return { values: {}, locks: {} }
}
const values: Partial<StoredUserSettings> = {}
const locks: Partial<Record<SettingLockKey, boolean>> = {}
for (const [key, entry] of Object.entries(normalized)) {
const normalizedEntry = normalizeObjectRecord(entry)
if (Object.keys(normalizedEntry).length) {
const hasValue = Object.prototype.hasOwnProperty.call(normalizedEntry, 'value')
const hasLocked = Object.prototype.hasOwnProperty.call(normalizedEntry, 'islocked')
if (hasValue) {
assignParsedSettingValue(values, key, normalizedEntry.value)
}
if (hasLocked) {
const lockKey = parseSettingLockKey(key)
if (lockKey) {
locks[lockKey] = parseBoolean(normalizedEntry.islocked, false)
}
}
continue
}
assignParsedSettingValue(values, key, entry)
}
return { values, locks }
}
function parseContentExperienceOverride(
rawValue: unknown,
baseUrl: string,
@@ -325,6 +493,152 @@ function parseContentExperienceOverride(
}
}
function parseControlDisplayContentOverride(
rawValue: unknown,
baseUrl: string,
): GameControlDisplayContentOverride | null {
const item = normalizeObjectRecord(rawValue)
if (!Object.keys(item).length) {
return null
}
const titleValue = typeof item.title === 'string' ? item.title.trim() : ''
const templateRaw = typeof item.template === 'string' ? item.template.trim().toLowerCase() : ''
const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
? templateRaw
: ''
const bodyValue = typeof item.body === 'string' ? item.body.trim() : ''
const clickTitleValue = typeof item.clickTitle === 'string' ? item.clickTitle.trim() : ''
const clickBodyValue = typeof item.clickBody === 'string' ? item.clickBody.trim() : ''
const autoPopupValue = item.autoPopup
const onceValue = item.once
const priorityNumeric = Number(item.priority)
const ctasValue = parseContentCardCtas(item.ctas)
const contentExperienceValue = parseContentExperienceOverride(item.contentExperience, baseUrl)
const clickExperienceValue = parseContentExperienceOverride(item.clickExperience, baseUrl)
const hasAutoPopup = typeof autoPopupValue === 'boolean'
const hasOnce = typeof onceValue === 'boolean'
const hasPriority = Number.isFinite(priorityNumeric)
if (
!templateValue
&& !titleValue
&& !bodyValue
&& !clickTitleValue
&& !clickBodyValue
&& !hasAutoPopup
&& !hasOnce
&& !hasPriority
&& !ctasValue
&& !contentExperienceValue
&& !clickExperienceValue
) {
return null
}
const parsed: GameControlDisplayContentOverride = {}
if (templateValue) {
parsed.template = templateValue
}
if (titleValue) {
parsed.title = titleValue
}
if (bodyValue) {
parsed.body = bodyValue
}
if (clickTitleValue) {
parsed.clickTitle = clickTitleValue
}
if (clickBodyValue) {
parsed.clickBody = clickBodyValue
}
if (hasAutoPopup) {
parsed.autoPopup = !!autoPopupValue
}
if (hasOnce) {
parsed.once = !!onceValue
}
if (hasPriority) {
parsed.priority = Math.max(0, Math.round(priorityNumeric))
}
if (ctasValue) {
parsed.ctas = ctasValue
}
if (contentExperienceValue) {
parsed.contentExperience = contentExperienceValue
}
if (clickExperienceValue) {
parsed.clickExperience = clickExperienceValue
}
return parsed
}
function parseControlPointStyleOverride(
rawValue: unknown,
fallbackPointStyle: ControlPointStyleEntry,
): ControlPointStyleEntry | null {
const item = normalizeObjectRecord(rawValue)
if (!Object.keys(item).length) {
return null
}
const rawPointStyle = getFirstDefined(item, ['pointstyle', 'style'])
const rawPointColor = getFirstDefined(item, ['pointcolorhex', 'pointcolor', 'color', 'colorhex'])
const rawPointSizeScale = getFirstDefined(item, ['pointsizescale', 'sizescale'])
const rawPointAccentRingScale = getFirstDefined(item, ['pointaccentringscale', 'accentringscale'])
const rawPointGlowStrength = getFirstDefined(item, ['pointglowstrength', 'glowstrength'])
const rawPointLabelScale = getFirstDefined(item, ['pointlabelscale', 'labelscale'])
const rawPointLabelColor = getFirstDefined(item, ['pointlabelcolorhex', 'pointlabelcolor', 'labelcolor', 'labelcolorhex'])
if (
rawPointStyle === undefined
&& rawPointColor === undefined
&& rawPointSizeScale === undefined
&& rawPointAccentRingScale === undefined
&& rawPointGlowStrength === undefined
&& rawPointLabelScale === undefined
&& rawPointLabelColor === undefined
) {
return null
}
return {
style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
}
}
function parseLegStyleOverride(
rawValue: unknown,
fallbackLegStyle: CourseLegStyleEntry,
): CourseLegStyleEntry | null {
const normalized = normalizeObjectRecord(rawValue)
const rawStyle = getFirstDefined(normalized, ['style'])
const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
if (
rawStyle === undefined
&& rawColor === undefined
&& rawWidthScale === undefined
&& rawGlowStrength === undefined
) {
return null
}
return {
style: parseCourseLegStyleId(rawStyle, fallbackLegStyle.style),
colorHex: normalizeHexColor(rawColor, fallbackLegStyle.colorHex),
widthScale: parsePositiveNumber(rawWidthScale, fallbackLegStyle.widthScale || 1),
glowStrength: clamp(parseNumber(rawGlowStrength, fallbackLegStyle.glowStrength || 0), 0, 1.2),
}
}
function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
if (typeof rawValue !== 'string') {
return 'classic-sequential'
@@ -684,6 +998,7 @@ function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
{ key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
{ key: 'guidance:distant', aliases: ['guidance:distant', 'guidance_distant', 'distant', 'far', 'far_distance'] },
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
{ key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
]
@@ -705,11 +1020,29 @@ function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
? parsePositiveNumber(normalized.volume, 1)
: undefined,
obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
distantDistanceMeters: normalized.distantdistancemeters !== undefined
? parsePositiveNumber(normalized.distantdistancemeters, 80)
: normalized.distantdistance !== undefined
? parsePositiveNumber(normalized.distantdistance, 80)
: normalized.fardistancemeters !== undefined
? parsePositiveNumber(normalized.fardistancemeters, 80)
: normalized.fardistance !== undefined
? parsePositiveNumber(normalized.fardistance, 80)
: undefined,
approachDistanceMeters: normalized.approachdistancemeters !== undefined
? parsePositiveNumber(normalized.approachdistancemeters, 20)
: normalized.approachdistance !== undefined
? parsePositiveNumber(normalized.approachdistance, 20)
: undefined,
readyDistanceMeters: normalized.readydistancemeters !== undefined
? parsePositiveNumber(normalized.readydistancemeters, 5)
: normalized.readydistance !== undefined
? parsePositiveNumber(normalized.readydistance, 5)
: normalized.punchreadydistancemeters !== undefined
? parsePositiveNumber(normalized.punchreadydistancemeters, 5)
: normalized.punchreadydistance !== undefined
? parsePositiveNumber(normalized.punchreadydistance, 5)
: undefined,
cues,
})
}
@@ -1120,6 +1453,7 @@ function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
{ key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
]
@@ -1153,6 +1487,7 @@ function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
{ key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
{ key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
{ key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
{ key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] },
{ key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
{ key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
]
@@ -1194,6 +1529,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
? parsed.map as Record<string, unknown>
: null
const rawSettings = parsed.settings && typeof parsed.settings === 'object' && !Array.isArray(parsed.settings)
? parsed.settings as Record<string, unknown>
: null
const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
? parsed.playfield as Record<string, unknown>
: null
@@ -1241,6 +1579,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
? rawGame.scoring as Record<string, unknown>
: null
const rawGameSettings = rawGame && rawGame.settings && typeof rawGame.settings === 'object' && !Array.isArray(rawGame.settings)
? rawGame.settings as Record<string, unknown>
: null
const mapRoot = rawMap && typeof rawMap.tiles === 'string'
? rawMap.tiles
@@ -1258,9 +1599,22 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
const gameMode = parseGameMode(modeValue)
const modeDefaults = getGameModeDefaults(gameMode)
const fallbackPointStyle = gameMode === 'score-o'
? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
: DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
const fallbackLegStyle = DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default
const rawControlDefaults = rawPlayfield && rawPlayfield.controlDefaults && typeof rawPlayfield.controlDefaults === 'object' && !Array.isArray(rawPlayfield.controlDefaults)
? rawPlayfield.controlDefaults as Record<string, unknown>
: null
const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
? rawPlayfield.controlOverrides as Record<string, unknown>
: null
const defaultControlScoreFromPlayfield = rawControlDefaults
? Number(getFirstDefined(normalizeObjectRecord(rawControlDefaults), ['score']))
: Number.NaN
const defaultControlContentOverride = parseControlDisplayContentOverride(rawControlDefaults, gameConfigUrl)
const defaultControlPointStyleOverride = parseControlPointStyleOverride(rawControlDefaults, fallbackPointStyle)
const controlScoreOverrides: Record<string, number> = {}
const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
@@ -1275,92 +1629,21 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
if (Number.isFinite(scoreValue)) {
controlScoreOverrides[key] = scoreValue
}
const rawPointStyle = getFirstDefined(item as Record<string, unknown>, ['pointStyle'])
const rawPointColor = getFirstDefined(item as Record<string, unknown>, ['pointColorHex'])
const rawPointSizeScale = getFirstDefined(item as Record<string, unknown>, ['pointSizeScale'])
const rawPointAccentRingScale = getFirstDefined(item as Record<string, unknown>, ['pointAccentRingScale'])
const rawPointGlowStrength = getFirstDefined(item as Record<string, unknown>, ['pointGlowStrength'])
const rawPointLabelScale = getFirstDefined(item as Record<string, unknown>, ['pointLabelScale'])
const rawPointLabelColor = getFirstDefined(item as Record<string, unknown>, ['pointLabelColorHex'])
if (
rawPointStyle !== undefined
|| rawPointColor !== undefined
|| rawPointSizeScale !== undefined
|| rawPointAccentRingScale !== undefined
|| rawPointGlowStrength !== undefined
|| rawPointLabelScale !== undefined
|| rawPointLabelColor !== undefined
) {
const fallbackPointStyle = gameMode === 'score-o'
? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
: DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
controlPointStyleOverrides[key] = {
style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
}
}
const titleValue = typeof (item as Record<string, unknown>).title === 'string'
? ((item as Record<string, unknown>).title as string).trim()
: ''
const templateRaw = typeof (item as Record<string, unknown>).template === 'string'
? ((item as Record<string, unknown>).template as string).trim().toLowerCase()
: ''
const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
? templateRaw
: ''
const bodyValue = typeof (item as Record<string, unknown>).body === 'string'
? ((item as Record<string, unknown>).body as string).trim()
: ''
const clickTitleValue = typeof (item as Record<string, unknown>).clickTitle === 'string'
? ((item as Record<string, unknown>).clickTitle as string).trim()
: ''
const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
? ((item as Record<string, unknown>).clickBody as string).trim()
: ''
const autoPopupValue = (item as Record<string, unknown>).autoPopup
const onceValue = (item as Record<string, unknown>).once
const priorityNumeric = Number((item as Record<string, unknown>).priority)
const ctasValue = parseContentCardCtas((item as Record<string, unknown>).ctas)
const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
const hasAutoPopup = typeof autoPopupValue === 'boolean'
const hasOnce = typeof onceValue === 'boolean'
const hasPriority = Number.isFinite(priorityNumeric)
if (
templateValue
|| titleValue
|| bodyValue
|| clickTitleValue
|| clickBodyValue
|| hasAutoPopup
|| hasOnce
|| hasPriority
|| ctasValue
|| contentExperienceValue
|| clickExperienceValue
) {
controlContentOverrides[key] = {
...(templateValue ? { template: templateValue } : {}),
...(titleValue ? { title: titleValue } : {}),
...(bodyValue ? { body: bodyValue } : {}),
...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
...(hasOnce ? { once: !!onceValue } : {}),
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
...(ctasValue ? { ctas: ctasValue } : {}),
...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
}
}
const styleOverride = parseControlPointStyleOverride(item, fallbackPointStyle)
if (styleOverride) {
controlPointStyleOverrides[key] = styleOverride
}
const contentOverride = parseControlDisplayContentOverride(item, gameConfigUrl)
if (contentOverride) {
controlContentOverrides[key] = contentOverride
}
}
}
const rawLegDefaults = rawPlayfield && rawPlayfield.legDefaults && typeof rawPlayfield.legDefaults === 'object' && !Array.isArray(rawPlayfield.legDefaults)
? rawPlayfield.legDefaults as Record<string, unknown>
: null
const defaultLegStyleOverride = parseLegStyleOverride(rawLegDefaults, fallbackLegStyle)
const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
? rawPlayfield.legOverrides as Record<string, unknown>
: null
@@ -1373,23 +1656,99 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
continue
}
const normalized = normalizeObjectRecord(item)
const rawStyle = getFirstDefined(normalized, ['style'])
const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
if (rawStyle === undefined && rawColor === undefined && rawWidthScale === undefined && rawGlowStrength === undefined) {
continue
}
legStyleOverrides[index] = {
style: parseCourseLegStyleId(rawStyle, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.style),
colorHex: normalizeHexColor(rawColor, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.colorHex),
widthScale: parsePositiveNumber(rawWidthScale, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.widthScale || 1),
glowStrength: clamp(parseNumber(rawGlowStrength, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.glowStrength || 0), 0, 1.2),
}
const legOverride = parseLegStyleOverride(item, fallbackLegStyle)
if (!legOverride) {
continue
}
legStyleOverrides[index] = legOverride
}
}
const punchRadiusMeters = parsePositiveNumber(
rawPunch && rawPunch.radiusMeters !== undefined
? rawPunch.radiusMeters
: normalizedGame.punchradiusmeters !== undefined
? normalizedGame.punchradiusmeters
: normalizedGame.punchradius !== undefined
? normalizedGame.punchradius
: normalized.punchradiusmeters !== undefined
? normalized.punchradiusmeters
: normalized.punchradius,
5,
)
const sessionCloseAfterMs = parsePositiveNumber(
rawSession && rawSession.closeAfterMs !== undefined
? rawSession.closeAfterMs
: rawSession && rawSession.sessionCloseAfterMs !== undefined
? rawSession.sessionCloseAfterMs
: normalizedGame.sessioncloseafterms !== undefined
? normalizedGame.sessioncloseafterms
: normalized.sessioncloseafterms,
modeDefaults.sessionCloseAfterMs,
)
const sessionCloseWarningMs = parsePositiveNumber(
rawSession && rawSession.closeWarningMs !== undefined
? rawSession.closeWarningMs
: rawSession && rawSession.sessionCloseWarningMs !== undefined
? rawSession.sessionCloseWarningMs
: normalizedGame.sessionclosewarningms !== undefined
? normalizedGame.sessionclosewarningms
: normalized.sessionclosewarningms,
modeDefaults.sessionCloseWarningMs,
)
const minCompletedControlsBeforeFinish = Math.max(0, Math.floor(parseNumber(
rawSession && rawSession.minCompletedControlsBeforeFinish !== undefined
? rawSession.minCompletedControlsBeforeFinish
: rawSession && rawSession.minControlsBeforeFinish !== undefined
? rawSession.minControlsBeforeFinish
: normalizedGame.mincompletedcontrolsbeforefinish !== undefined
? normalizedGame.mincompletedcontrolsbeforefinish
: normalizedGame.mincontrolsbeforefinish !== undefined
? normalizedGame.mincontrolsbeforefinish
: normalized.mincompletedcontrolsbeforefinish !== undefined
? normalized.mincompletedcontrolsbeforefinish
: normalized.mincontrolsbeforefinish,
modeDefaults.minCompletedControlsBeforeFinish,
)))
const requiresFocusSelection = parseBoolean(
rawPunch && rawPunch.requiresFocusSelection !== undefined
? rawPunch.requiresFocusSelection
: normalizedGame.requiresfocusselection !== undefined
? normalizedGame.requiresfocusselection
: rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
? (rawPunch as Record<string, unknown>).requiresfocusselection
: normalized.requiresfocusselection,
modeDefaults.requiresFocusSelection,
)
const skipEnabled = parseBoolean(
rawSkip && rawSkip.enabled !== undefined
? rawSkip.enabled
: normalizedGame.skipenabled !== undefined
? normalizedGame.skipenabled
: normalized.skipenabled,
modeDefaults.skipEnabled,
)
const skipRadiusMeters = parsePositiveNumber(
rawSkip && rawSkip.radiusMeters !== undefined
? rawSkip.radiusMeters
: normalizedGame.skipradiusmeters !== undefined
? normalizedGame.skipradiusmeters
: normalizedGame.skipradius !== undefined
? normalizedGame.skipradius
: normalized.skipradiusmeters !== undefined
? normalized.skipradiusmeters
: normalized.skipradius,
getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
)
const autoFinishOnLastControl = parseBoolean(
rawSession && rawSession.autoFinishOnLastControl !== undefined
? rawSession.autoFinishOnLastControl
: normalizedGame.autofinishonlastcontrol !== undefined
? normalizedGame.autofinishonlastcontrol
: normalized.autofinishonlastcontrol,
modeDefaults.autoFinishOnLastControl,
)
return {
title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
@@ -1410,6 +1769,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
: null,
gameMode,
sessionCloseAfterMs,
sessionCloseWarningMs,
minCompletedControlsBeforeFinish,
punchPolicy: parsePunchPolicy(
rawPunch && rawPunch.policy !== undefined
? rawPunch.policy
@@ -1417,71 +1779,34 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
? normalizedGame.punchpolicy
: normalized.punchpolicy,
),
punchRadiusMeters: parsePositiveNumber(
rawPunch && rawPunch.radiusMeters !== undefined
? rawPunch.radiusMeters
: normalizedGame.punchradiusmeters !== undefined
? normalizedGame.punchradiusmeters
: normalizedGame.punchradius !== undefined
? normalizedGame.punchradius
: normalized.punchradiusmeters !== undefined
? normalized.punchradiusmeters
: normalized.punchradius,
5,
),
requiresFocusSelection: parseBoolean(
rawPunch && rawPunch.requiresFocusSelection !== undefined
? rawPunch.requiresFocusSelection
: normalizedGame.requiresfocusselection !== undefined
? normalizedGame.requiresfocusselection
: rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
? (rawPunch as Record<string, unknown>).requiresfocusselection
: normalized.requiresfocusselection,
false,
),
skipEnabled: parseBoolean(
rawSkip && rawSkip.enabled !== undefined
? rawSkip.enabled
: normalizedGame.skipenabled !== undefined
? normalizedGame.skipenabled
: normalized.skipenabled,
false,
),
skipRadiusMeters: parsePositiveNumber(
rawSkip && rawSkip.radiusMeters !== undefined
? rawSkip.radiusMeters
: normalizedGame.skipradiusmeters !== undefined
? normalizedGame.skipradiusmeters
: normalizedGame.skipradius !== undefined
? normalizedGame.skipradius
: normalized.skipradiusmeters !== undefined
? normalized.skipradiusmeters
: normalized.skipradius,
30,
),
punchRadiusMeters,
requiresFocusSelection,
skipEnabled,
skipRadiusMeters,
skipRequiresConfirm: parseBoolean(
rawSkip && rawSkip.requiresConfirm !== undefined
? rawSkip.requiresConfirm
: normalizedGame.skiprequiresconfirm !== undefined
? normalizedGame.skiprequiresconfirm
: normalized.skiprequiresconfirm,
true,
),
autoFinishOnLastControl: parseBoolean(
rawSession && rawSession.autoFinishOnLastControl !== undefined
? rawSession.autoFinishOnLastControl
: normalizedGame.autofinishonlastcontrol !== undefined
? normalizedGame.autofinishonlastcontrol
: normalized.autofinishonlastcontrol,
true,
modeDefaults.skipRequiresConfirm,
),
autoFinishOnLastControl,
controlScoreOverrides,
controlContentOverrides,
defaultControlContentOverride,
defaultControlPointStyleOverride,
controlPointStyleOverrides,
defaultLegStyleOverride,
legStyleOverrides,
defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
? parsePositiveNumber(rawScoring.defaultControlScore, 10)
: null,
defaultControlScore: resolveDefaultControlScore(
gameMode,
Number.isFinite(defaultControlScoreFromPlayfield)
? defaultControlScoreFromPlayfield
: rawScoring && rawScoring.defaultControlScore !== undefined
? parsePositiveNumber(rawScoring.defaultControlScore, modeDefaults.defaultControlScore)
: null,
),
courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
@@ -1489,6 +1814,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
hapticsConfig: parseHapticsConfig(rawHaptics),
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
systemSettingsConfig: parseSystemSettingsConfig(rawGameSettings || rawSettings),
declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
}
}
@@ -1518,6 +1844,11 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
}
const gameMode = parseGameMode(config.gamemode)
const modeDefaults = getGameModeDefaults(gameMode)
const punchRadiusMeters = parsePositiveNumber(
config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
5,
)
return {
title: '',
@@ -1530,24 +1861,27 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
defaultZoom: null,
gameMode,
sessionCloseAfterMs: modeDefaults.sessionCloseAfterMs,
sessionCloseWarningMs: modeDefaults.sessionCloseWarningMs,
minCompletedControlsBeforeFinish: modeDefaults.minCompletedControlsBeforeFinish,
punchPolicy: parsePunchPolicy(config.punchpolicy),
punchRadiusMeters: parsePositiveNumber(
config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
5,
),
requiresFocusSelection: parseBoolean(config.requiresfocusselection, false),
skipEnabled: parseBoolean(config.skipenabled, false),
punchRadiusMeters,
requiresFocusSelection: parseBoolean(config.requiresfocusselection, modeDefaults.requiresFocusSelection),
skipEnabled: parseBoolean(config.skipenabled, modeDefaults.skipEnabled),
skipRadiusMeters: parsePositiveNumber(
config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
30,
getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters),
),
skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true),
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, modeDefaults.skipRequiresConfirm),
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, modeDefaults.autoFinishOnLastControl),
controlScoreOverrides: {},
controlContentOverrides: {},
defaultControlContentOverride: null,
defaultControlPointStyleOverride: null,
controlPointStyleOverrides: {},
defaultLegStyleOverride: null,
legStyleOverrides: {},
defaultControlScore: null,
defaultControlScore: modeDefaults.defaultControlScore,
courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
@@ -1567,7 +1901,21 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
enabled: config.audioenabled,
masterVolume: config.audiomastervolume,
obeyMuteSwitch: config.audioobeymuteswitch,
distantDistanceMeters: config.audiodistantdistancemeters !== undefined
? config.audiodistantdistancemeters
: config.audiodistantdistance !== undefined
? config.audiodistantdistance
: config.audiofardistancemeters !== undefined
? config.audiofardistancemeters
: config.audiofardistance,
approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
readyDistanceMeters: config.audioreadydistancemeters !== undefined
? config.audioreadydistancemeters
: config.audioreadydistance !== undefined
? config.audioreadydistance
: config.audiopunchreadydistancemeters !== undefined
? config.audiopunchreadydistancemeters
: config.audiopunchreadydistance,
cues: {
session_started: config.audiosessionstarted,
'control_completed:start': config.audiostartcomplete,
@@ -1575,6 +1923,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
'control_completed:finish': config.audiofinishcomplete,
'punch_feedback:warning': config.audiowarning,
'guidance:searching': config.audiosearching,
'guidance:distant': config.audiodistant,
'guidance:approaching': config.audioapproaching,
'guidance:ready': config.audioready,
},
@@ -1589,6 +1938,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
'control_completed:finish': config.hapticsfinishcomplete,
'punch_feedback:warning': config.hapticswarning,
'guidance:searching': config.hapticssearching,
'guidance:distant': config.hapticsdistant,
'guidance:approaching': config.hapticsapproaching,
'guidance:ready': config.hapticsready,
},
@@ -1605,6 +1955,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
},
}),
systemSettingsConfig: { values: {}, locks: {} },
declinationDeg: parseDeclinationValue(config.declination),
}
}
@@ -1827,6 +2178,9 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
courseStatusText,
cpRadiusMeters: gameConfig.cpRadiusMeters,
gameMode: gameConfig.gameMode,
sessionCloseAfterMs: gameConfig.sessionCloseAfterMs,
sessionCloseWarningMs: gameConfig.sessionCloseWarningMs,
minCompletedControlsBeforeFinish: gameConfig.minCompletedControlsBeforeFinish,
punchPolicy: gameConfig.punchPolicy,
punchRadiusMeters: gameConfig.punchRadiusMeters,
requiresFocusSelection: gameConfig.requiresFocusSelection,
@@ -1836,7 +2190,10 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
controlScoreOverrides: gameConfig.controlScoreOverrides,
controlContentOverrides: gameConfig.controlContentOverrides,
defaultControlContentOverride: gameConfig.defaultControlContentOverride,
defaultControlPointStyleOverride: gameConfig.defaultControlPointStyleOverride,
controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
defaultLegStyleOverride: gameConfig.defaultLegStyleOverride,
legStyleOverrides: gameConfig.legStyleOverrides,
defaultControlScore: gameConfig.defaultControlScore,
courseStyleConfig: gameConfig.courseStyleConfig,
@@ -1846,6 +2203,7 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
audioConfig: gameConfig.audioConfig,
hapticsConfig: gameConfig.hapticsConfig,
uiEffectsConfig: gameConfig.uiEffectsConfig,
systemSettingsConfig: gameConfig.systemSettingsConfig,
}
}