diff --git a/.vscode/settings.json b/.vscode/settings.json index 07606083..3f9f9a6f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,5 +17,6 @@ ".*Styles", ".*Class", ".*ClassName" - ] + ], + "cSpell.words": ["Arrayish"] } diff --git a/src/lib/parser/recording-files/combine-recording-context.ts b/src/lib/parser/recording-files/combine-recording-context.ts new file mode 100644 index 00000000..2df089c7 --- /dev/null +++ b/src/lib/parser/recording-files/combine-recording-context.ts @@ -0,0 +1,42 @@ +import { HeroStateField } from '../hero-state/hero-states'; +import { PlayerDataField } from '../player-data'; +import { RecordingFileVersion } from '../recording-file-version'; +import { FrameEndEvent, PlayerPositionEvent, SceneEvent } from './events'; +import { PlayerDataEvent } from './events/player-data-event'; +import { HeroStateEvent } from './recording'; + +export class CombineRecordingsContext { + msIntoGame = 0; + lastTimestamp: number = 0; + + isPaused = true; + isTransitioning = false; + + previousPlayerDataEventsByField = new Map>(); + + getPreviousPlayerData = (field: TField) => { + return this.previousPlayerDataEventsByField.get(field) as PlayerDataEvent | undefined; + }; + + previousHeroStateByField = new Map(); + getPreviousHeroState = (field: HeroStateField) => { + return this.previousHeroStateByField.get(field); + }; + + createEndFrameEvent = false; + + previousPlayerPositionEvent: PlayerPositionEvent | null = null; + previousPositionEventWithChangedPosition: PlayerPositionEvent | null = null; + previousPlayerPositionEventWithMapPosition: PlayerPositionEvent | null = null; + previousFrameEndEvent: FrameEndEvent | null = null; + previousSceneEvent: SceneEvent | null = null; + + recordingFileVersion: RecordingFileVersion = '0.0.0'; + + visitedScenesToCheckIfInPlayerData = [] as { sceneName: string; msIntoGame: number }[]; + + allModVersions = new Map>(); + allHkVizModVersions = new Set(); + + hasCreatedFirstEndFrameEvent = false; +} diff --git a/src/lib/parser/recording-files/combine-recordings.ts b/src/lib/parser/recording-files/combine-recordings.ts index 00feacc9..3876305b 100644 --- a/src/lib/parser/recording-files/combine-recordings.ts +++ b/src/lib/parser/recording-files/combine-recordings.ts @@ -1,365 +1,379 @@ -import { type HeroStateField } from '../hero-state/hero-states'; import { getDefaultValue, playerDataFields, type PlayerDataField } from '../player-data/player-data'; +import { isVersionBefore1_4_0 } from '../recording-file-version'; +import { raise } from '../util'; +import { ReadonlyArrayish } from '../util/array/append-only-signal-array'; +import { CombineRecordingsContext } from './combine-recording-context'; import { FrameEndEvent, frameEndEventHeroStateFields, frameEndEventPlayerDataFields } from './events/frame-end-event'; import { HKVizModVersionEvent } from './events/hkviz-mod-version-event'; import { ModdingInfoEvent } from './events/modding-info-event'; import { PlayerDataEvent } from './events/player-data-event'; import { PlayerPositionEvent } from './events/player-position-event'; import { SceneEvent } from './events/scene-event'; -import { isVersionBefore1_4_0, type RecordingFileVersion } from '../recording-file-version'; import { - CombinedRecording, - HeroStateEvent, - RecordingFileVersionEvent, - isPlayerDataEventOfField, - isPlayerDataEventWithFieldType, - type ParsedRecording, - type RecordingEvent, + CombinedRecording, + HeroStateEvent, + RecordingFileVersionEvent, + isPlayerDataEventOfField, + isPlayerDataEventWithFieldType, + type ParsedRecording, + type RecordingEvent, } from './recording'; -import { raise } from '../util'; function isPantheonRoom(sceneName: string) { - return sceneName.startsWith('GG_') && !sceneName.startsWith('GG_Atrium'); + return sceneName.startsWith('GG_') && !sceneName.startsWith('GG_Atrium'); } -export function combineRecordings(recordings: ParsedRecording[]): CombinedRecording { - const events: RecordingEvent[] = []; - let msIntoGame = 0; - let lastTimestamp: number = - recordings[0]?.events?.[0]?.timestamp ?? raise(new Error('No events found in first recording')); - - let isPaused = true; - let isTransitioning = false; - - const previousPlayerDataEventsByField = new Map>(); - function getPreviousPlayerData(field: TField) { - return previousPlayerDataEventsByField.get(field) as PlayerDataEvent | undefined; - } - - const previousHeroStateByField = new Map(); - function getPreviousHeroState(field: HeroStateField) { - return previousHeroStateByField.get(field); - } - - let createEndFrameEvent = false; - - let previousPlayerPositionEvent: PlayerPositionEvent | null = null; - let previousPositionEventWithChangedPosition: PlayerPositionEvent | null = null; - let previousPlayerPositionEventWithMapPosition: PlayerPositionEvent | null = null; - let previousFrameEndEvent: FrameEndEvent | null = null; - let previousSceneEvent: SceneEvent | null = null; - - let recordingFileVersion: RecordingFileVersion = '0.0.0'; - - const visitedScenesToCheckIfInPlayerData = [] as { sceneName: string; msIntoGame: number }[]; - - const allModVersions = new Map>(); - const allHkVizModVersions = new Set(); - - const hasCreatedFirstEndFrameEvent = false; - - for (const recording of recordings.sort((a, b) => a.combinedPartNumber! - b.combinedPartNumber!)) { - for (const event of recording.events) { - // create together player data event if needed - // TODO might be good to exclude some fields here since they are updated very often and not needed - // for the visualizations - if (event.timestamp > lastTimestamp) { - if (!hasCreatedFirstEndFrameEvent && recording.combinedPartNumber === 1) { - Object.values(playerDataFields.byFieldName).forEach((field) => { - if (!previousPlayerDataEventsByField.has(field)) { - // if part number = 1 all non default player data fields should have been added - // so we can add default values for the rest - const event = new PlayerDataEvent({ - field, - timestamp: 0, - value: getDefaultValue(field), - } as any); - events.push(event); - previousPlayerDataEventsByField.set(field, event); - if (frameEndEventPlayerDataFields.has(field)) { - createEndFrameEvent = true; - } - } - }); - } - if (createEndFrameEvent) { - const endFrameEvent: FrameEndEvent = new FrameEndEvent({ - timestamp: lastTimestamp, - getPreviousPlayerData, - msIntoGame, - previousFrameEndEvent, - previousPlayerPositionEvent, - getPreviousHeroState, - }); - previousFrameEndEvent = endFrameEvent; - events.push(endFrameEvent); - createEndFrameEvent = false; - } - } - - // msIntoGame calculation - if (event instanceof RecordingFileVersionEvent) { - // time before the previous event and this event is not counted, - // since either the session just started again, or pause has been active, or a scene has been loaded - // TODO add remaining event checks - // console.log('time between sessions not counted', event.timestamp - lastTimestamp); - lastTimestamp = event.timestamp; - isPaused = false; - recordingFileVersion = event.version; - } else if (event instanceof ModdingInfoEvent) { - for (const mod of event.mods) { - if (mod.enabled !== false) { - const versions = allModVersions.get(mod.name) ?? new Set(); - mod.versions.forEach((v) => versions.add(v)); - allModVersions.set(mod.name, versions); - } - } - } else if (event instanceof HKVizModVersionEvent) { - allHkVizModVersions.add(event.version); - } else if (event instanceof SceneEvent) { - event.previousSceneEvent = previousSceneEvent; - const previousCurrentBossSequenceEvent = getPreviousPlayerData( - playerDataFields.byFieldName.currentBossSequence, - ); - if (previousCurrentBossSequenceEvent?.value && !isPantheonRoom(event.sceneName)) { - // pantheon stopped, but game does not change player data to reflect that - // so a event is faked here - - const currentBossSequenceEvent = new PlayerDataEvent< - typeof playerDataFields.byFieldName.currentBossSequence - >({ - timestamp: lastTimestamp, - value: null, - field: playerDataFields.byFieldName.currentBossSequence, - previousPlayerPositionEvent: previousPlayerPositionEvent, - previousPlayerDataEventOfField: previousCurrentBossSequenceEvent ?? null, - }); - currentBossSequenceEvent.msIntoGame = msIntoGame; - previousPlayerDataEventsByField.set( - playerDataFields.byFieldName.currentBossSequence, - currentBossSequenceEvent, - ); - events.push(currentBossSequenceEvent); - } - - const currentBossSequence = - getPreviousPlayerData(playerDataFields.byFieldName.currentBossSequence)?.value ?? null; - event.currentBossSequence = currentBossSequence; - - const visitedScenes = getPreviousPlayerData(playerDataFields.byFieldName.scenesVisited)?.value ?? []; - - // if scene is not in player data, it might still be added in a few seconds or so, but if its not - // its added to the scenes below. - if (!visitedScenes.includes(event.sceneName)) { - visitedScenesToCheckIfInPlayerData.push({ sceneName: event.sceneName, msIntoGame }); - } - - // in version < 1.4.0 the mod did not record the transitioning bool - // therefore, here we try to detect player events which where transitioned to a new scene - // and remove them: - if (isVersionBefore1_4_0(recordingFileVersion) && previousPlayerPositionEvent) { - const lastPlayerPositionEvent: PlayerPositionEvent = previousPlayerPositionEvent; - const sceneEvent = lastPlayerPositionEvent.sceneEvent; - const sceneOriginOffset = sceneEvent.originOffset; - const sceneSize = sceneEvent.sceneSize; - - let currentPlayerPositionEvent: PlayerPositionEvent | null = lastPlayerPositionEvent; - while ( - sceneOriginOffset && - sceneSize && - currentPlayerPositionEvent && - // max 6 seconds of removed events allowed - Math.abs(lastPlayerPositionEvent.timestamp - currentPlayerPositionEvent.timestamp) < 20_000 && - currentPlayerPositionEvent.previousPlayerPositionEvent && - currentPlayerPositionEvent.sceneEvent === - currentPlayerPositionEvent.previousPlayerPositionEvent.sceneEvent && - // when transitioning upwards, y does not change - (currentPlayerPositionEvent.position.x < sceneOriginOffset.x - 2 || - currentPlayerPositionEvent.position.x > sceneOriginOffset.x + sceneSize.x + 2 || - currentPlayerPositionEvent.position.y < sceneOriginOffset.y - 2 || - currentPlayerPositionEvent.position.y > sceneOriginOffset.y + sceneSize.y + 2) - ) { - events.splice(events.indexOf(currentPlayerPositionEvent), 1); - currentPlayerPositionEvent = currentPlayerPositionEvent.previousPlayerPositionEvent; - } - // found first event with this x position (hopefully last position before transition) - // now all other events which have a player position reference should instead - // reference the new last one - previousPlayerPositionEvent = currentPlayerPositionEvent; - previousPlayerPositionEventWithMapPosition = currentPlayerPositionEvent?.mapPosition - ? currentPlayerPositionEvent - : currentPlayerPositionEvent?.previousPlayerPositionEventWithMapPosition ?? null; - - if (currentPlayerPositionEvent) { - const startIndex = events.indexOf(currentPlayerPositionEvent) + 1; - for (let i = startIndex; i < events.length; i++) { - const event = events[i]; - if (event && 'previousPlayerPositionEvent' in event) { - event.previousPlayerPositionEvent = currentPlayerPositionEvent; - } - if (event && 'previousPlayerPositionEventWithMapPosition' in event) { - event.previousPlayerPositionEventWithMapPosition = - previousPlayerPositionEventWithMapPosition; - } - if (event && 'mapDistanceToPrevious' in event) { - // event.mapDistanceToPrevious = null; - } - } - } - } - previousSceneEvent = event; - } else if (event instanceof HeroStateEvent) { - if (event.field.name === 'isPaused') { - isPaused = event.value; - if (!isPaused) { - lastTimestamp = event.timestamp; - } - } else if (event.field.name === 'transitioning') { - isTransitioning = event.value; - lastTimestamp = event.timestamp; - } - previousHeroStateByField.set(event.field, event); - if (frameEndEventHeroStateFields.has(event.field)) { - createEndFrameEvent = true; - } - } else { - if (event instanceof PlayerPositionEvent) { - if (isTransitioning) { - continue; - } - event.calcMapPosition(); - const playerPositionChanged = - previousPositionEventWithChangedPosition?.position?.equals(event.position) !== true; - if (playerPositionChanged) { - previousPositionEventWithChangedPosition = event; - } - event.previousPlayerPositionEvent = previousPlayerPositionEvent; - event.previousPlayerPositionEventWithMapPosition = previousPlayerPositionEventWithMapPosition; - if (event.mapPosition != null && previousPlayerPositionEventWithMapPosition?.mapPosition != null) { - event.mapDistanceToPrevious = previousPlayerPositionEventWithMapPosition.mapPosition.distanceTo( - event.mapPosition, - ); - } - if (event.mapPosition != null) { - previousPlayerPositionEventWithMapPosition = event; - } - previousPlayerPositionEvent = event; - } - - if (!isPaused) { - const diff = event.timestamp - lastTimestamp; - const msSinceLastPositionChange = - event.timestamp - (previousPositionEventWithChangedPosition?.timestamp ?? 0); - - // starting with 10 seconds of no events, the time is not counted - // this might happen, because sb closed their laptop / turned off their pc, - // without closing Hollow Knight, and when opening the laptop again, the recorder just continues. - const skipTimeDeltaBecauseOfNoEvents = diff > 10 * 1000; - - // even when we have a position change, if it hasn't changed for 30 seconds, one probably has left - // hollow knight open accidentally. So time is not counted. - // TODO add option to UI to make this filtering optional. - const skipTimeDeltaBecauseNoPositionChange = msSinceLastPositionChange > 30 * 1000; - - if (!skipTimeDeltaBecauseOfNoEvents && !skipTimeDeltaBecauseNoPositionChange) { - msIntoGame += event.timestamp - lastTimestamp; - } - } - lastTimestamp = event.timestamp; - } - event.msIntoGame = msIntoGame; - - // previousPlayerDataEventsByField - if (event instanceof PlayerDataEvent) { - event.previousPlayerDataEventOfField = previousPlayerDataEventsByField.get(event.field) ?? null; - previousPlayerDataEventsByField.set(event.field, event); - if (frameEndEventPlayerDataFields.has(event.field)) { - createEndFrameEvent = true; - } - - if (isPlayerDataEventOfField(event, playerDataFields.byFieldName.currentBossSequence)) { - const sceneEvent = previousPlayerPositionEvent?.sceneEvent; - if (sceneEvent && isPantheonRoom(sceneEvent.sceneName)) { - sceneEvent.currentBossSequence = event.value; - } - } - - if (isPlayerDataEventWithFieldType(event, 'List`1')) { - event.value = event.value.flatMap((it) => - it === '::' ? event.previousPlayerDataEventOfField?.value ?? [] : [it], - ); - if (isPlayerDataEventOfField(event, playerDataFields.byFieldName.scenesVisited)) { - for (const it of event.previousPlayerDataEventOfField?.value ?? []) { - // even if scenes are removed again from the player data (e.g. by loading an old save or modding), - // we don't want to loose the scenes visited in the recording. - if (!event.value.includes(it)) { - event.value.push(it); - // console.log('scene not in visitedScenes anymore, added it again', { - // sceneName: it, - // msIntoGame, - // }); - } - } - } - } - } - - events.push(event); - addScenesWhichWhereNotAdded(); - } - } - addScenesWhichWhereNotAdded(true); - // there might not have been a end frame event for a bit at the end, so we duplicate the last one - // so graphs can depend on there being one at the end of the msIntoGame - if (previousFrameEndEvent) { - events.push( - new FrameEndEvent({ - timestamp: lastTimestamp, - getPreviousPlayerData, - msIntoGame, - previousFrameEndEvent, - previousPlayerPositionEvent, - getPreviousHeroState, - }), - ); - } - - function addScenesWhichWhereNotAdded(all = false) { - while ( - visitedScenesToCheckIfInPlayerData.length > 0 && - (all || visitedScenesToCheckIfInPlayerData[0]!.msIntoGame + 2000 < msIntoGame) - ) { - const { sceneName, msIntoGame } = visitedScenesToCheckIfInPlayerData.shift()!; - const previousScenesVisitedEvent = getPreviousPlayerData(playerDataFields.byFieldName.scenesVisited); - const previousValue = previousScenesVisitedEvent?.value ?? []; +/** + * + * @param combinedEvents combined events are written to this array + * @param ctx + * @param all if true, all scenes are added, if false, only scenes which are at least 2 seconds in the past are added + */ +function addScenesWhichWhereNotAdded(combinedEvents: RecordingEvent[], ctx: CombineRecordingsContext, all: boolean) { + while ( + ctx.visitedScenesToCheckIfInPlayerData.length > 0 && + (all || ctx.visitedScenesToCheckIfInPlayerData[0]!.msIntoGame + 2000 < ctx.msIntoGame) + ) { + const { sceneName, msIntoGame } = ctx.visitedScenesToCheckIfInPlayerData.shift()!; + const previousScenesVisitedEvent = ctx.getPreviousPlayerData(playerDataFields.byFieldName.scenesVisited); + const previousValue = previousScenesVisitedEvent?.value ?? []; + + if (!previousValue.includes(sceneName)) { + const visitedScenesEvent = new PlayerDataEvent({ + timestamp: ctx.lastTimestamp, + value: [...previousValue, sceneName], + field: playerDataFields.byFieldName.scenesVisited, + + previousPlayerPositionEvent: ctx.previousPlayerPositionEvent, + previousPlayerDataEventOfField: previousScenesVisitedEvent ?? null, + }); + visitedScenesEvent.msIntoGame = msIntoGame; + ctx.previousPlayerDataEventsByField.set(playerDataFields.byFieldName.scenesVisited, visitedScenesEvent); + if (frameEndEventPlayerDataFields.has(playerDataFields.byFieldName.scenesVisited)) { + ctx.createEndFrameEvent = true; + } + combinedEvents.push(visitedScenesEvent); + } + } +} - if (!previousValue.includes(sceneName)) { - const visitedScenesEvent = new PlayerDataEvent({ - timestamp: lastTimestamp, - value: [...previousValue, sceneName], - field: playerDataFields.byFieldName.scenesVisited, +/** + * + * @param combinedEvents combined events are written to this array + * @param uncombinedEvents events that are not yet combined + * @param ctx + * @param combinedPartNumber only relevant at first part, can be null for live recorded events + */ +function internalCombineRecordingEvents( + combinedEvents: RecordingEvent[], + uncombinedEvents: ReadonlyArrayish, + ctx: CombineRecordingsContext, + combinedPartNumber: number | null, +) { + for (const event of uncombinedEvents) { + // create together player data event if needed + // TODO might be good to exclude some fields here since they are updated very often and not needed + // for the visualizations + if (event.timestamp > ctx.lastTimestamp) { + if (!ctx.hasCreatedFirstEndFrameEvent && combinedPartNumber === 1) { + Object.values(playerDataFields.byFieldName).forEach((field) => { + if (!ctx.previousPlayerDataEventsByField.has(field)) { + // if part number = 1 all non default player data fields should have been added + // so we can add default values for the rest + const event = new PlayerDataEvent({ + field, + timestamp: 0, + value: getDefaultValue(field), + } as any); + combinedEvents.push(event); + ctx.previousPlayerDataEventsByField.set(field, event); + if (frameEndEventPlayerDataFields.has(field)) { + ctx.createEndFrameEvent = true; + } + } + }); + } + if (ctx.createEndFrameEvent) { + const endFrameEvent: FrameEndEvent = new FrameEndEvent({ + timestamp: ctx.lastTimestamp, + getPreviousPlayerData: ctx.getPreviousPlayerData, + msIntoGame: ctx.msIntoGame, + previousFrameEndEvent: ctx.previousFrameEndEvent, + previousPlayerPositionEvent: ctx.previousPlayerPositionEvent, + getPreviousHeroState: ctx.getPreviousHeroState, + }); + // TODO test if this had an impact. + ctx.hasCreatedFirstEndFrameEvent = true; + ctx.previousFrameEndEvent = endFrameEvent; + combinedEvents.push(endFrameEvent); + ctx.createEndFrameEvent = false; + } + } + + // msIntoGame calculation + if (event instanceof RecordingFileVersionEvent) { + // time before the previous event and this event is not counted, + // since either the session just started again, or pause has been active, or a scene has been loaded + // TODO add remaining event checks + // console.log('time between sessions not counted', event.timestamp - lastTimestamp); + ctx.lastTimestamp = event.timestamp; + ctx.isPaused = false; + ctx.recordingFileVersion = event.version; + } else if (event instanceof ModdingInfoEvent) { + for (const mod of event.mods) { + if (mod.enabled !== false) { + const versions = ctx.allModVersions.get(mod.name) ?? new Set(); + mod.versions.forEach((v) => versions.add(v)); + ctx.allModVersions.set(mod.name, versions); + } + } + } else if (event instanceof HKVizModVersionEvent) { + ctx.allHkVizModVersions.add(event.version); + } else if (event instanceof SceneEvent) { + event.previousSceneEvent = ctx.previousSceneEvent; + const previousCurrentBossSequenceEvent = ctx.getPreviousPlayerData( + playerDataFields.byFieldName.currentBossSequence, + ); + if (previousCurrentBossSequenceEvent?.value && !isPantheonRoom(event.sceneName)) { + // pantheon stopped, but game does not change player data to reflect that + // so a event is faked here + + const currentBossSequenceEvent = new PlayerDataEvent< + typeof playerDataFields.byFieldName.currentBossSequence + >({ + timestamp: ctx.lastTimestamp, + value: null, + field: playerDataFields.byFieldName.currentBossSequence, + previousPlayerPositionEvent: ctx.previousPlayerPositionEvent, + previousPlayerDataEventOfField: previousCurrentBossSequenceEvent ?? null, + }); + currentBossSequenceEvent.msIntoGame = ctx.msIntoGame; + ctx.previousPlayerDataEventsByField.set( + playerDataFields.byFieldName.currentBossSequence, + currentBossSequenceEvent, + ); + combinedEvents.push(currentBossSequenceEvent); + } + + const currentBossSequence = + ctx.getPreviousPlayerData(playerDataFields.byFieldName.currentBossSequence)?.value ?? null; + event.currentBossSequence = currentBossSequence; + + const visitedScenes = ctx.getPreviousPlayerData(playerDataFields.byFieldName.scenesVisited)?.value ?? []; + + // if scene is not in player data, it might still be added in a few seconds or so, but if its not + // its added to the scenes below. + if (!visitedScenes.includes(event.sceneName)) { + ctx.visitedScenesToCheckIfInPlayerData.push({ + sceneName: event.sceneName, + msIntoGame: ctx.msIntoGame, + }); + } + + // in version < 1.4.0 the mod did not record the transitioning bool + // therefore, here we try to detect player events which where transitioned to a new scene + // and remove them: + if (isVersionBefore1_4_0(ctx.recordingFileVersion) && ctx.previousPlayerPositionEvent) { + const lastPlayerPositionEvent: PlayerPositionEvent = ctx.previousPlayerPositionEvent; + const sceneEvent = lastPlayerPositionEvent.sceneEvent; + const sceneOriginOffset = sceneEvent.originOffset; + const sceneSize = sceneEvent.sceneSize; + + let currentPlayerPositionEvent: PlayerPositionEvent | null = lastPlayerPositionEvent; + while ( + sceneOriginOffset && + sceneSize && + currentPlayerPositionEvent && + // max 6 seconds of removed events allowed + Math.abs(lastPlayerPositionEvent.timestamp - currentPlayerPositionEvent.timestamp) < 20_000 && + currentPlayerPositionEvent.previousPlayerPositionEvent && + currentPlayerPositionEvent.sceneEvent === + currentPlayerPositionEvent.previousPlayerPositionEvent.sceneEvent && + // when transitioning upwards, y does not change + (currentPlayerPositionEvent.position.x < sceneOriginOffset.x - 2 || + currentPlayerPositionEvent.position.x > sceneOriginOffset.x + sceneSize.x + 2 || + currentPlayerPositionEvent.position.y < sceneOriginOffset.y - 2 || + currentPlayerPositionEvent.position.y > sceneOriginOffset.y + sceneSize.y + 2) + ) { + combinedEvents.splice(combinedEvents.indexOf(currentPlayerPositionEvent), 1); + currentPlayerPositionEvent = currentPlayerPositionEvent.previousPlayerPositionEvent; + } + // found first event with this x position (hopefully last position before transition) + // now all other events which have a player position reference should instead + // reference the new last one + ctx.previousPlayerPositionEvent = currentPlayerPositionEvent; + ctx.previousPlayerPositionEventWithMapPosition = currentPlayerPositionEvent?.mapPosition + ? currentPlayerPositionEvent + : (currentPlayerPositionEvent?.previousPlayerPositionEventWithMapPosition ?? null); + + if (currentPlayerPositionEvent) { + const startIndex = combinedEvents.indexOf(currentPlayerPositionEvent) + 1; + for (let i = startIndex; i < combinedEvents.length; i++) { + const event = combinedEvents[i]; + if (event && 'previousPlayerPositionEvent' in event) { + event.previousPlayerPositionEvent = currentPlayerPositionEvent; + } + if (event && 'previousPlayerPositionEventWithMapPosition' in event) { + event.previousPlayerPositionEventWithMapPosition = + ctx.previousPlayerPositionEventWithMapPosition; + } + if (event && 'mapDistanceToPrevious' in event) { + // event.mapDistanceToPrevious = null; + } + } + } + } + ctx.previousSceneEvent = event; + } else if (event instanceof HeroStateEvent) { + if (event.field.name === 'isPaused') { + ctx.isPaused = event.value; + if (!ctx.isPaused) { + ctx.lastTimestamp = event.timestamp; + } + } else if (event.field.name === 'transitioning') { + ctx.isTransitioning = event.value; + ctx.lastTimestamp = event.timestamp; + } + ctx.previousHeroStateByField.set(event.field, event); + if (frameEndEventHeroStateFields.has(event.field)) { + ctx.createEndFrameEvent = true; + } + } else { + if (event instanceof PlayerPositionEvent) { + if (ctx.isTransitioning) { + continue; + } + event.calcMapPosition(); + const playerPositionChanged = + ctx.previousPositionEventWithChangedPosition?.position?.equals(event.position) !== true; + if (playerPositionChanged) { + ctx.previousPositionEventWithChangedPosition = event; + } + event.previousPlayerPositionEvent = ctx.previousPlayerPositionEvent; + event.previousPlayerPositionEventWithMapPosition = ctx.previousPlayerPositionEventWithMapPosition; + if (event.mapPosition != null && ctx.previousPlayerPositionEventWithMapPosition?.mapPosition != null) { + event.mapDistanceToPrevious = ctx.previousPlayerPositionEventWithMapPosition.mapPosition.distanceTo( + event.mapPosition, + ); + } + if (event.mapPosition != null) { + ctx.previousPlayerPositionEventWithMapPosition = event; + } + ctx.previousPlayerPositionEvent = event; + } + + if (!ctx.isPaused) { + const diff = event.timestamp - ctx.lastTimestamp; + const msSinceLastPositionChange = + event.timestamp - (ctx.previousPositionEventWithChangedPosition?.timestamp ?? 0); + + // starting with 10 seconds of no events, the time is not counted + // this might happen, because sb closed their laptop / turned off their pc, + // without closing Hollow Knight, and when opening the laptop again, the recorder just continues. + const skipTimeDeltaBecauseOfNoEvents = diff > 10 * 1000; + + // even when we have a position change, if it hasn't changed for 30 seconds, one probably has left + // hollow knight open accidentally. So time is not counted. + // TODO add option to UI to make this filtering optional. + const skipTimeDeltaBecauseNoPositionChange = msSinceLastPositionChange > 30 * 1000; + + if (!skipTimeDeltaBecauseOfNoEvents && !skipTimeDeltaBecauseNoPositionChange) { + ctx.msIntoGame += event.timestamp - ctx.lastTimestamp; + } + } + ctx.lastTimestamp = event.timestamp; + } + event.msIntoGame = ctx.msIntoGame; + + // previousPlayerDataEventsByField + if (event instanceof PlayerDataEvent) { + event.previousPlayerDataEventOfField = ctx.previousPlayerDataEventsByField.get(event.field) ?? null; + ctx.previousPlayerDataEventsByField.set(event.field, event); + if (frameEndEventPlayerDataFields.has(event.field)) { + ctx.createEndFrameEvent = true; + } + + if (isPlayerDataEventOfField(event, playerDataFields.byFieldName.currentBossSequence)) { + const sceneEvent = ctx.previousPlayerPositionEvent?.sceneEvent; + if (sceneEvent && isPantheonRoom(sceneEvent.sceneName)) { + sceneEvent.currentBossSequence = event.value; + } + } + + if (isPlayerDataEventWithFieldType(event, 'List`1')) { + event.value = event.value.flatMap((it) => + it === '::' ? (event.previousPlayerDataEventOfField?.value ?? []) : [it], + ); + if (isPlayerDataEventOfField(event, playerDataFields.byFieldName.scenesVisited)) { + for (const it of event.previousPlayerDataEventOfField?.value ?? []) { + // even if scenes are removed again from the player data (e.g. by loading an old save or modding), + // we don't want to loose the scenes visited in the recording. + if (!event.value.includes(it)) { + event.value.push(it); + // console.log('scene not in visitedScenes anymore, added it again', { + // sceneName: it, + // msIntoGame, + // }); + } + } + } + } + } + + combinedEvents.push(event); + addScenesWhichWhereNotAdded(combinedEvents, ctx, false); + } +} - previousPlayerPositionEvent: previousPlayerPositionEvent, - previousPlayerDataEventOfField: previousScenesVisitedEvent ?? null, - }); - visitedScenesEvent.msIntoGame = msIntoGame; - previousPlayerDataEventsByField.set(playerDataFields.byFieldName.scenesVisited, visitedScenesEvent); - if (frameEndEventPlayerDataFields.has(playerDataFields.byFieldName.scenesVisited)) { - createEndFrameEvent = true; - } - events.push(visitedScenesEvent); - } - } - } +/** + * Called to combine initially parsed static recording files (i.e. not live part of a gameplay) + * @param recordings + * @returns newly created combined recording + */ +export function combineRecordings(recordings: ParsedRecording[]): CombinedRecording { + const combinedEvents: RecordingEvent[] = []; + + const ctx = new CombineRecordingsContext(); + ctx.lastTimestamp = recordings[0]?.events?.[0]?.timestamp ?? raise(new Error('No events found in first recording')); + + for (const recording of recordings.sort((a, b) => a.combinedPartNumber! - b.combinedPartNumber!)) { + internalCombineRecordingEvents(combinedEvents, recording.events, ctx, recording.combinedPartNumber); + } + addScenesWhichWhereNotAdded(combinedEvents, ctx, true); + // there might not have been a end frame event for a bit at the end, so we duplicate the last one + // so graphs can depend on there being one at the end of the msIntoGame + if (ctx.previousFrameEndEvent) { + combinedEvents.push( + new FrameEndEvent({ + timestamp: ctx.lastTimestamp, + getPreviousPlayerData: ctx.getPreviousPlayerData, + msIntoGame: ctx.msIntoGame, + previousFrameEndEvent: ctx.previousFrameEndEvent, + previousPlayerPositionEvent: ctx.previousPlayerPositionEvent, + getPreviousHeroState: ctx.getPreviousHeroState, + }), + ); + } + + return new CombinedRecording( + combinedEvents, + recordings.reduce((sum, recording) => sum + recording.unknownEvents, 0), + recordings.reduce((sum, recording) => sum + recording.parsingErrors, 0), + [...ctx.allModVersions.entries()].map(([name, versions]) => ({ + name, + versions: [...versions.values()].sort(), + })), + [...ctx.allHkVizModVersions].sort(), + ctx, + // todo make not live when not live? + true, + ); +} - (window as any).hkvizEvents = () => events; +export function combineRecordingsAppend(recording: CombinedRecording, events: RecordingEvent[]) { + const ctx = recording.combiningContext; + const combinedEvents: RecordingEvent[] = []; - return new CombinedRecording( - events, - recordings.reduce((sum, recording) => sum + recording.unknownEvents, 0), - recordings.reduce((sum, recording) => sum + recording.parsingErrors, 0), - previousPlayerDataEventsByField, - [...allModVersions.entries()].map(([name, versions]) => ({ name, versions: [...versions.values()].sort() })), - [...allHkVizModVersions].sort(), - ); + internalCombineRecordingEvents(combinedEvents, events, ctx, null); + recording.append(combinedEvents); } diff --git a/src/lib/parser/recording-files/freezable-signal.ts b/src/lib/parser/recording-files/freezable-signal.ts new file mode 100644 index 00000000..a394f7ff --- /dev/null +++ b/src/lib/parser/recording-files/freezable-signal.ts @@ -0,0 +1,54 @@ +import { Accessor, createSignal, Setter } from 'solid-js'; + +const frozenSetter = () => { + throw new Error('Cannot set value of frozen signal'); +}; + +const frozenFrozen = () => { + return; +}; + +export interface FreezableSignal { + get: Accessor; + set: Setter; + freeze(): void; +} + +/** + * A signal that can be frozen. When frozen, the value is fixed and cannot be changed. + * Therefore, will not contribute to the reactive graph. + * + * If already frozen when created, the signal will never be created. + */ +export function createFreezableSignal(value: T, frozen: boolean) { + if (frozen) { + return { get: () => value, set: frozenSetter, freeze: frozenFrozen }; + } + + let _frozen = false; + let _value = value; + const [_get, _set] = createSignal(value); + + function get(): T { + if (_frozen) { + return value; + } + return _get(); + } + const set: Setter = ((v: any) => { + if (_frozen) { + throw new Error('Cannot set value of frozen signal'); + } + return _set(v); + }) as any; + + function freeze() { + if (_frozen) { + return; + } + _frozen = true; + _value = _get(); + } + + return { get, set, freeze }; +} diff --git a/src/lib/parser/recording-files/recording-file-parser.ts b/src/lib/parser/recording-files/recording-file-parser.ts index 6f2ce4a0..717edfde 100644 --- a/src/lib/parser/recording-files/recording-file-parser.ts +++ b/src/lib/parser/recording-files/recording-file-parser.ts @@ -51,20 +51,29 @@ function parseVector2_v1(str: string, factor = 1) { ); } -export function parseRecordingFile(recordingFileContent: string, combinedPartNumber: number): ParsedRecording { - const lines = recordingFileContent.split('\n'); - const events: RecordingEvent[] = []; - let unknownEvents = 0; - let parsingErrors = 0; +export class ParseRecordingFileContext { + unknownEvents = 0; + parsingErrors = 0; - let lastSceneEvent: SceneEvent | undefined = undefined; - let previousPlayerPosition: Vector2 | undefined = undefined; - let previousPlayerPositionEvent: PlayerPositionEvent | null = null; - let previousTimestamp: number | undefined = undefined; + lastSceneEvent: SceneEvent | undefined = undefined; + previousPlayerPosition: Vector2 | undefined = undefined; + previousPlayerPositionEvent: PlayerPositionEvent | null = null; + previousTimestamp: number | undefined = undefined; // defaults to 0.0.0 since in early version of the mod, the version was only // written at the beginning of a session, not for each part - let currentRecordingFileVersion: RecordingFileVersion = '0.0.0'; + currentRecordingFileVersion: RecordingFileVersion = '0.0.0'; +} + +export function parseRecordingFile( + recordingFileContent: string, + combinedPartNumber: number, + context?: ParseRecordingFileContext, +): RecordingEvent[] { + const lines = recordingFileContent.split('\n'); + const events: RecordingEvent[] = []; + + const ctx = context ?? new ParseRecordingFileContext(); let i = 0; LINE_LOOP: for (let line of lines) { @@ -84,19 +93,19 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum let timestamp: number; if (timestampStr == null || timestampStr === '') { - if (previousTimestamp === undefined) { + if (ctx.previousTimestamp === undefined) { throw new Error('Relative timestamp found, but no previous timestamp found'); } - timestamp = previousTimestamp!; + timestamp = ctx.previousTimestamp!; } else if (isRelativeTimestamp) { - if (previousTimestamp == null) { + if (ctx.previousTimestamp == null) { throw new Error('Relative timestamp found, but no previous timestamp found'); } - timestamp = previousTimestamp + parseInt(timestampStr); + timestamp = ctx.previousTimestamp + parseInt(timestampStr); } else { timestamp = parseInt(timestampStr); } - previousTimestamp = timestamp; + ctx.previousTimestamp = timestamp; // ------ EVENT TYPE ------ const partialEventType = eventType[0] as PartialEventPrefix; @@ -114,7 +123,7 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum field, value, - previousPlayerPositionEvent: previousPlayerPositionEvent, + previousPlayerPositionEvent: ctx.previousPlayerPositionEvent, previousPlayerDataEventOfField: null, // filled in combiner }), ); @@ -133,7 +142,7 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum timestamp, field, value: args[0] === '1', - previousPlayerPositionEvent: previousPlayerPositionEvent, + previousPlayerPositionEvent: ctx.previousPlayerPositionEvent, }), ); continue LINE_LOOP; @@ -150,22 +159,22 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum const eventTypePrefix = eventType as EventPrefix; switch (eventTypePrefix) { case EVENT_PREFIXES.SCENE_CHANGE: { - lastSceneEvent = new SceneEvent({ + ctx.lastSceneEvent = new SceneEvent({ timestamp, sceneName: args[0]!, originOffset: undefined, sceneSize: undefined, }); - previousPlayerPosition = undefined; - events.push(lastSceneEvent); + ctx.previousPlayerPosition = undefined; + events.push(ctx.lastSceneEvent); break; } case EVENT_PREFIXES.ROOM_DIMENSIONS: { - if (lastSceneEvent) { + if (ctx.lastSceneEvent) { let originOffset: Vector2; let sceneSize: Vector2; - if (isVersion0xx(currentRecordingFileVersion)) { + if (isVersion0xx(ctx.currentRecordingFileVersion)) { originOffset = parseVector2_v0(args[0]!, args[1]!); sceneSize = parseVector2_v0(args[2]!, args[3]!); } else { @@ -176,34 +185,34 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum // for some reason in Abyss_10 (the scene right to the light house), // the origin offset is always first set to correct values, and then shortly after to zero if ( - (!lastSceneEvent.originOffset && !lastSceneEvent.sceneSize) || + (!ctx.lastSceneEvent.originOffset && !ctx.lastSceneEvent.sceneSize) || (!originOffset.isZero() && !sceneSize.isZero()) ) { - lastSceneEvent.originOffset = originOffset; - lastSceneEvent.sceneSize = sceneSize; + ctx.lastSceneEvent.originOffset = originOffset; + ctx.lastSceneEvent.sceneSize = sceneSize; } } break; } case EVENT_PREFIXES.ENTITY_POSITIONS: { - if (lastSceneEvent) { + if (ctx.lastSceneEvent) { const position: Vector2 | undefined = args[0] === '=' - ? previousPlayerPosition - : isVersion0xx(currentRecordingFileVersion) + ? ctx.previousPlayerPosition + : isVersion0xx(ctx.currentRecordingFileVersion) ? parseVector2_v0(args[0]!, args[1]!) : parseVector2_v1(args[0]!, 1 / 10); if (!position) { continue; // throw new Error('Could not assign player position to player position event'); } - previousPlayerPosition = position; - previousPlayerPositionEvent = new PlayerPositionEvent({ + ctx.previousPlayerPosition = position; + ctx.previousPlayerPositionEvent = new PlayerPositionEvent({ timestamp, position, - sceneEvent: lastSceneEvent, + sceneEvent: ctx.lastSceneEvent, }); - events.push(previousPlayerPositionEvent); + events.push(ctx.previousPlayerPositionEvent); } break; } @@ -240,18 +249,18 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum const version = args[0]!; if (isKnownRecordingFileVersion(version)) { - currentRecordingFileVersion = version; + ctx.currentRecordingFileVersion = version; } else { console.error( `Unknown recording file version ${version} falling back to newest known version ${newestRecordingFileVersion}`, ); - currentRecordingFileVersion = newestRecordingFileVersion; + ctx.currentRecordingFileVersion = newestRecordingFileVersion; } events.push( new RecordingFileVersionEvent({ timestamp, - version: currentRecordingFileVersion as RecordingFileVersion, + version: ctx.currentRecordingFileVersion as RecordingFileVersion, }), ); @@ -269,7 +278,7 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum events.push( new SpellFireballEvent({ timestamp, - previousPlayerPositionEvent: previousPlayerPositionEvent, + previousPlayerPositionEvent: ctx.previousPlayerPositionEvent, }), ); break; @@ -278,7 +287,7 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum events.push( new SpellUpEvent({ timestamp, - previousPlayerPositionEvent: previousPlayerPositionEvent, + previousPlayerPositionEvent: ctx.previousPlayerPositionEvent, }), ); break; @@ -287,7 +296,7 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum events.push( new SpellDownEvent({ timestamp, - previousPlayerPositionEvent: previousPlayerPositionEvent, + previousPlayerPositionEvent: ctx.previousPlayerPositionEvent, }), ); break; @@ -347,20 +356,20 @@ export function parseRecordingFile(recordingFileContent: string, combinedPartNum default: { typeCheckNever(eventTypePrefix); console.log(`Unexpected event type |${eventType}| ignoring line |${line}|`); - unknownEvents++; + ctx.unknownEvents++; } } } catch (e) { console.error( - `Error while parsing line ${i}: |${line}| using file version ${currentRecordingFileVersion} in part number ${combinedPartNumber}`, + `Error while parsing line ${i}: |${line}| using file version ${ctx.currentRecordingFileVersion} in part number ${combinedPartNumber}`, e, ); ((window as any).errorLines = (window as any).errorLines ?? []).push(line); - parsingErrors++; + ctx.parsingErrors++; } i++; } - return new ParsedRecording(events, unknownEvents, parsingErrors, combinedPartNumber); + return events; } diff --git a/src/lib/parser/recording-files/recording-splits.ts b/src/lib/parser/recording-files/recording-splits.ts index e3d6560e..26a85a47 100644 --- a/src/lib/parser/recording-files/recording-splits.ts +++ b/src/lib/parser/recording-files/recording-splits.ts @@ -18,7 +18,7 @@ import { playerDataFields, } from '../player-data/player-data'; import { type PlayerPositionEvent } from './events/player-position-event'; -import { type CombinedRecording } from './recording'; +import { RecordingEvent, type CombinedRecording } from './recording'; import { assertNever, parseHtmlEntities } from '../util'; export const recordingSplitGroups = [ @@ -111,7 +111,9 @@ function createRecordingSplitFromEnemy( // } // } -export function createRecordingSplits(recording: CombinedRecording): RecordingSplit[] { +export interface CreatingRecordingSplitsContext {} + +export function createRecordingSplits(events: RecordingEvent[]): RecordingSplit[] { const splits: RecordingSplit[] = []; for (const field of Object.values(playerDataFields.byFieldName)) { diff --git a/src/lib/parser/recording-files/recording.ts b/src/lib/parser/recording-files/recording.ts index 7075aaf5..59a9e2b6 100644 --- a/src/lib/parser/recording-files/recording.ts +++ b/src/lib/parser/recording-files/recording.ts @@ -1,182 +1,266 @@ +import { createSignal, Signal } from 'solid-js'; import { type HeroStateField } from '../hero-state/hero-states'; import { type PlayerDataField } from '../player-data/player-data'; import { type RecordingFileVersion } from '../recording-file-version'; import { binarySearchLastIndexBefore, raise } from '../util'; +import { + AppendOnlyOrArray, + AppendOnlySignalArray, + createAppendOnlyReactiveArray, + isAppendOnlyReactiveArray, +} from '../util/array/append-only-signal-array'; import { FrameEndEvent } from './events/frame-end-event'; import { type HKVizModVersionEvent } from './events/hkviz-mod-version-event'; -import { type ModInfo, type ModdingInfoEvent } from './events/modding-info-event'; +import { type ModdingInfoEvent, type ModInfo } from './events/modding-info-event'; import { PlayerDataEvent } from './events/player-data-event'; import { PlayerPositionEvent } from './events/player-position-event'; import { RecordingEventBase, type RecordingEventBaseOptions } from './events/recording-event-base'; import { SceneEvent } from './events/scene-event'; +import { ParseRecordingFileContext } from './recording-file-parser'; import { createRecordingSplits, type RecordingSplit } from './recording-splits'; +import { CombineRecordingsContext } from './combine-recording-context'; type RecordingFileVersionEventOptions = RecordingEventBaseOptions & Pick; export class RecordingFileVersionEvent extends RecordingEventBase { - public version: RecordingFileVersion; + public version: RecordingFileVersion; - constructor(options: RecordingFileVersionEventOptions) { - super(options); - this.version = options.version; - } + constructor(options: RecordingFileVersionEventOptions) { + super(options); + this.version = options.version; + } } export function isPlayerDataEventOfField( - event: RecordingEvent, - field: TField, + event: RecordingEvent, + field: TField, ): event is PlayerDataEvent { - return event instanceof PlayerDataEvent && event.field === field; + return event instanceof PlayerDataEvent && event.field === field; } export function isPlayerDataEventWithFieldType( - event: RecordingEvent, - type: FieldType, + event: RecordingEvent, + type: FieldType, ): event is PlayerDataEvent> { - return event instanceof PlayerDataEvent && event.field.type === type; + return event instanceof PlayerDataEvent && event.field.type === type; } type HeroStateEventOptions = RecordingEventBaseOptions & - Pick; + Pick; export class HeroStateEvent extends RecordingEventBase { - public previousPlayerPositionEvent: PlayerPositionEvent | null = null; - public readonly field: HeroStateField; - public readonly value: boolean; - - constructor(options: HeroStateEventOptions) { - super(options); - this.previousPlayerPositionEvent = options.previousPlayerPositionEvent; - this.field = options.field; - this.value = options.value; - } + public previousPlayerPositionEvent: PlayerPositionEvent | null = null; + public readonly field: HeroStateField; + public readonly value: boolean; + + constructor(options: HeroStateEventOptions) { + super(options); + this.previousPlayerPositionEvent = options.previousPlayerPositionEvent; + this.field = options.field; + this.value = options.value; + } } type SpellFireballEventOptions = RecordingEventBaseOptions & Pick; export class SpellFireballEvent extends RecordingEventBase { - public previousPlayerPositionEvent: PlayerPositionEvent | null = null; + public previousPlayerPositionEvent: PlayerPositionEvent | null = null; - constructor(options: SpellFireballEventOptions) { - super(options); - this.previousPlayerPositionEvent = options.previousPlayerPositionEvent; - } + constructor(options: SpellFireballEventOptions) { + super(options); + this.previousPlayerPositionEvent = options.previousPlayerPositionEvent; + } } type SpellUpEventOptions = RecordingEventBaseOptions & Pick; export class SpellUpEvent extends RecordingEventBase { - public previousPlayerPositionEvent: PlayerPositionEvent | null = null; + public previousPlayerPositionEvent: PlayerPositionEvent | null = null; - constructor(options: SpellUpEventOptions) { - super(options); - this.previousPlayerPositionEvent = options.previousPlayerPositionEvent; - } + constructor(options: SpellUpEventOptions) { + super(options); + this.previousPlayerPositionEvent = options.previousPlayerPositionEvent; + } } type SpellDownEventOptions = RecordingEventBaseOptions & Pick; export class SpellDownEvent extends RecordingEventBase { - public previousPlayerPositionEvent: PlayerPositionEvent | null = null; + public previousPlayerPositionEvent: PlayerPositionEvent | null = null; - constructor(options: SpellDownEventOptions) { - super(options); - this.previousPlayerPositionEvent = options.previousPlayerPositionEvent; - } + constructor(options: SpellDownEventOptions) { + super(options); + this.previousPlayerPositionEvent = options.previousPlayerPositionEvent; + } } export type RecordingEvent = - | SceneEvent - | PlayerPositionEvent - | PlayerDataEvent - | RecordingFileVersionEvent - | HeroStateEvent - | SpellFireballEvent - | SpellDownEvent - | SpellUpEvent - | FrameEndEvent - | ModdingInfoEvent - | HKVizModVersionEvent; + | SceneEvent + | PlayerPositionEvent + | PlayerDataEvent + | RecordingFileVersionEvent + | HeroStateEvent + | SpellFireballEvent + | SpellDownEvent + | SpellUpEvent + | FrameEndEvent + | ModdingInfoEvent + | HKVizModVersionEvent; export class ParsedRecording { - constructor( - public readonly events: RecordingEvent[], - public readonly unknownEvents: number, - public readonly parsingErrors: number, - public readonly combinedPartNumber: number | null, - ) {} - - lastEvent() { - return ( - this.events[this.events.length - 1] ?? - raise(new Error(`Recording file ${this.combinedPartNumber} does not contain any events`)) - ); - } - firstEvent() { - return ( - this.events[0] ?? raise(new Error(`Recording file ${this.combinedPartNumber} does not contain any events`)) - ); - } + events: readonly RecordingEvent[] | AppendOnlySignalArray; + #isLive: boolean; + isLive(): this is ParsedRecording & { events: AppendOnlySignalArray } { + return this.#isLive; + } + + constructor( + events: RecordingEvent[], + public unknownEvents: number, + public parsingErrors: number, + public readonly combinedPartNumber: number | null, + isLive: boolean = false, + ) { + this.events = isLive ? createAppendOnlyReactiveArray(events) : events; + this.#isLive = isLive; + } + + lastEvent() { + return ( + this.events[this.events.length - 1] ?? + raise(new Error(`Recording file ${this.combinedPartNumber} does not contain any events`)) + ); + } + firstEvent() { + return ( + this.events[0] ?? raise(new Error(`Recording file ${this.combinedPartNumber} does not contain any events`)) + ); + } + makeUnlive() { + this.#isLive = false; + if (isAppendOnlyReactiveArray(this.events)) { + this.events = this.events.unwrap(); + } + } + append(events: RecordingEvent[], currentParsingContext: ParseRecordingFileContext) { + if (!this.isLive()) { + throw new Error('Cannot append to non-live recording'); + } + this.events.push(...events); + this.unknownEvents += currentParsingContext.unknownEvents; + this.parsingErrors += currentParsingContext.parsingErrors; + } } -export class CombinedRecording extends ParsedRecording { - public playerDataEventsPerField = new Map[]>(); - public frameEndEvents: FrameEndEvent[] = []; - public sceneEvents: SceneEvent[] = []; - public splits: RecordingSplit[]; - public playerPositionEventsWithTracePosition: PlayerPositionEvent[] = []; - - constructor( - events: RecordingEvent[], - unknownEvents: number, - parsingErrors: number, - public readonly lastPlayerDataEventsByField: Map>, - public readonly allModVersions: ModInfo[], - public readonly allHkVizModVersions: string[], - ) { - super(events, unknownEvents, parsingErrors, null); - - for (const event of events) { - if (event instanceof PlayerDataEvent) { - const eventsOfField = this.playerDataEventsPerField.get(event.field) ?? []; - eventsOfField.push(event); - this.playerDataEventsPerField.set(event.field, eventsOfField); - } else if (event instanceof SceneEvent) { - this.sceneEvents.push(event); - } else if (event instanceof FrameEndEvent) { - this.frameEndEvents.push(event); - } else if (event instanceof PlayerPositionEvent) { - if ( - event.mapPosition != null && - event.previousPlayerPositionEventWithMapPosition?.mapPosition != null && - !event.previousPlayerPositionEventWithMapPosition.mapPosition.equals(event.mapPosition) - ) { - this.playerPositionEventsWithTracePosition.push(event); - } - } - } - this.splits = createRecordingSplits(this); - } - - lastPlayerDataEventOfField(field: TField): PlayerDataEvent | null { - - return (this.lastPlayerDataEventsByField.get(field) as any) ?? null; - } - - allPlayerDataEventsOfField(field: TField): PlayerDataEvent[] { - - return (this.playerDataEventsPerField.get(field) as any) ?? []; - } - - sceneEventIndexFromMs(ms: number): number { - return binarySearchLastIndexBefore(this.sceneEvents, ms, (it) => it.msIntoGame); - } - - sceneEventFromMs(ms: number): SceneEvent | null { - const index = this.sceneEventIndexFromMs(ms); - return this.sceneEvents[index] ?? null; - } - - frameEndEventIndexFromMs(ms: number): number { - return binarySearchLastIndexBefore(this.frameEndEvents, ms, (it) => it.msIntoGame); - } - frameEndEventFromMs(ms: number): FrameEndEvent | null { - const index = this.frameEndEventIndexFromMs(ms); - return this.frameEndEvents[index] ?? null; - } +export class CombinedRecording { + public events: AppendOnlyOrArray; + + public playerDataEventsPerField = new Map>>(); + private lastPlayerDataEventsByField = new Map | null>>(); + + public frameEndEvents: AppendOnlyOrArray; + public sceneEvents: AppendOnlyOrArray; + public splits: AppendOnlyOrArray; + public playerPositionEventsWithTracePosition: AppendOnlyOrArray; + + #isLive: boolean; + isLive(): this is CombinedRecording & { events: AppendOnlySignalArray } { + return this.#isLive; + } + + constructor( + events: RecordingEvent[], + public unknownEvents: number, + public parsingErrors: number, + // TODO fix all + public readonly allModVersions: ModInfo[], + public readonly allHkVizModVersions: string[], + public readonly combiningContext: CombineRecordingsContext, + isLive: boolean = false, + ) { + this.#isLive = isLive; + + this.events = isLive ? createAppendOnlyReactiveArray(events) : events; + this.frameEndEvents = isLive ? createAppendOnlyReactiveArray([]) : []; + this.sceneEvents = isLive ? createAppendOnlyReactiveArray([]) : []; + this.splits = isLive ? createAppendOnlyReactiveArray([]) : []; + this.playerPositionEventsWithTracePosition = isLive + ? createAppendOnlyReactiveArray([]) + : []; + + this.#processAddedEvents(events); + + this.splits = createRecordingSplits(this); + } + + append(events: RecordingEvent[]) { + if (!this.isLive()) { + throw new Error('Cannot append to non-live recording'); + } + this.events.push(...events); + this.#processAddedEvents(events); + } + + #processAddedEvents(events: RecordingEvent[]) { + for (const event of events) { + if (event instanceof PlayerDataEvent) { + const eventsOfField = this.playerDataEventsPerField.get(event.field) ?? []; + eventsOfField.push(event); + this.playerDataEventsPerField.set(event.field, eventsOfField); + this.#getLastPlayerDataEventSignal(event.field)[1](event); + } else if (event instanceof SceneEvent) { + this.sceneEvents.push(event); + } else if (event instanceof FrameEndEvent) { + this.frameEndEvents.push(event); + } else if (event instanceof PlayerPositionEvent) { + if ( + event.mapPosition != null && + event.previousPlayerPositionEventWithMapPosition?.mapPosition != null && + !event.previousPlayerPositionEventWithMapPosition.mapPosition.equals(event.mapPosition) + ) { + this.playerPositionEventsWithTracePosition.push(event); + } + } + } + } + + #getLastPlayerDataEventSignal(field: TField): Signal> { + const signal = this.lastPlayerDataEventsByField.get(field); + if (signal == null) { + // eslint-disable-next-line solid/reactivity + const newSignal = createSignal | null>(null); + this.lastPlayerDataEventsByField.set(field, newSignal); + return newSignal as unknown as Signal>; + } + return signal as unknown as Signal>; + } + + lastPlayerDataEventOfField(field: TField): PlayerDataEvent | null { + const signal = this.#getLastPlayerDataEventSignal(field); + return signal[0](); + } + + allPlayerDataEventsOfField(field: TField): PlayerDataEvent[] { + return (this.playerDataEventsPerField.get(field) as any) ?? []; + } + + sceneEventIndexFromMs(ms: number): number { + return binarySearchLastIndexBefore(this.sceneEvents, ms, (it) => it.msIntoGame); + } + + sceneEventFromMs(ms: number): SceneEvent | null { + const index = this.sceneEventIndexFromMs(ms); + return this.sceneEvents[index] ?? null; + } + + frameEndEventIndexFromMs(ms: number): number { + return binarySearchLastIndexBefore(this.frameEndEvents, ms, (it) => it.msIntoGame); + } + frameEndEventFromMs(ms: number): FrameEndEvent | null { + const index = this.frameEndEventIndexFromMs(ms); + return this.frameEndEvents[index] ?? null; + } + + lastEvent() { + return this.events.at(-1) ?? raise(new Error('CombinedRecording does not contain any events')); + } + + firstEvent() { + return this.events[0] ?? raise(new Error('CombinedRecording does not contain any events')); + } } diff --git a/src/lib/parser/util/array/append-only-signal-array.test.ts b/src/lib/parser/util/array/append-only-signal-array.test.ts new file mode 100644 index 00000000..50db5294 --- /dev/null +++ b/src/lib/parser/util/array/append-only-signal-array.test.ts @@ -0,0 +1,100 @@ +/* eslint-disable solid/reactivity */ +import { Accessor, createMemo, Setter, Signal } from 'solid-js'; +import { expect, test } from 'vitest'; +import { AppendOnlySignalArray, createAppendOnlyReactiveArray, ReadonlyArrayish } from './append-only-signal-array'; + +interface TestAppendOnlySignalArray extends AppendOnlySignalArray { + _items: T[]; + _signalPerIndex: Map>; + _getLength: Accessor; + _setLength: Setter; +} + +test('does not create signal for already existing value', () => { + const arr = createAppendOnlyReactiveArray([1, 2, 3]) as TestAppendOnlySignalArray; + const x = createMemo(() => arr[0]); + expect(x()).toBe(1); + expect(arr._signalPerIndex.size).toBe(0); + arr._items[0] = 4; + // should not update, since no signal was created internally. + expect(x()).toBe(1); + // read would still be 2, but can only happen when types are ignored + expect(arr[0]).toBe(4); + expect(arr._signalPerIndex.size).toBe(0); +}); + +test('creates signal for non existing value', () => { + const arr = createAppendOnlyReactiveArray([1, 2, 3]) as TestAppendOnlySignalArray; + expect(arr._signalPerIndex.size).toBe(0); + const x = createMemo(() => arr[3]); + + expect(arr._signalPerIndex.size).toBe(1); + expect(x()).toBe(undefined); + + arr.push(4); + expect(arr._signalPerIndex.size).toBe(0); + expect(x()).toBe(4); + expect(arr[3]).toBe(4); +}); + +test('length is reactive', () => { + const arr = createAppendOnlyReactiveArray([] as number[]) as TestAppendOnlySignalArray; + const x = createMemo(() => arr.length); + + expect(x()).toBe(0); + expect(arr._signalPerIndex.size).toBe(0); + + arr.push(1); + expect(x()).toBe(1); + expect(arr._signalPerIndex.size).toBe(0); + + arr.push(2, 3); + expect(x()).toBe(3); + expect(arr._signalPerIndex.size).toBe(0); +}); + +test('at(-1) is reactive', () => { + const arr = createAppendOnlyReactiveArray([] as number[]) as TestAppendOnlySignalArray; + const x = createMemo(() => arr.at(-1)); + + expect(x()).toBe(undefined); + expect(arr._signalPerIndex.size).toBe(0); + + arr.push(1); + expect(x()).toBe(1); + expect(arr._signalPerIndex.size).toBe(0); + + arr.push(2, 3); + expect(x()).toBe(3); + expect(arr._signalPerIndex.size).toBe(0); +}); + +test('at(-2) is reactive', () => { + const arr = createAppendOnlyReactiveArray([] as number[]) as TestAppendOnlySignalArray; + const x = createMemo(() => arr.at(-2)); + + expect(x()).toBe(undefined); + expect(arr._signalPerIndex.size).toBe(0); + + arr.push(1); + expect(x()).toBe(undefined); + expect(arr._signalPerIndex.size).toBe(0); + + arr.push(2); + expect(x()).toBe(1); + expect(arr._signalPerIndex.size).toBe(0); + + arr.push(3, 4); + expect(x()).toBe(3); + expect(arr._signalPerIndex.size).toBe(0); +}); + +test('types are assignable', () => { + const reactiveArr = createAppendOnlyReactiveArray( + [] as number[], + ) satisfies ArrayLike satisfies ReadonlyArrayish; + const unreactiveArr = [] as number[] satisfies ArrayLike satisfies ReadonlyArrayish; + + expect(reactiveArr.length).toBe(0); + expect(unreactiveArr.length).toBe(0); +}); diff --git a/src/lib/parser/util/array/append-only-signal-array.ts b/src/lib/parser/util/array/append-only-signal-array.ts new file mode 100644 index 00000000..d3607cfc --- /dev/null +++ b/src/lib/parser/util/array/append-only-signal-array.ts @@ -0,0 +1,177 @@ +// export function createAppendOnlySignalArray(initialValue: T[]): AppendOnlySignalArray { +// let _value = initialValue; +// const [_get, _set] = createSignal(initialValue); + +// function get(): T[] { +// return _get(); +// } +// const set: Setter = ((v: any) => { +// throw new Error('Cannot set value of append-only signal array'); +// }) as any; + +// function append(value: T) { +// _set([..._get(), value]); +// } + +// return { get, set, append }; +// } + +import { Accessor, batch, createSignal, Setter, Signal } from 'solid-js'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +type NotFunction = T extends Function ? never : T; + +export interface ReadonlyArrayish { + readonly [index: number]: T; + readonly at: (index: number) => T | undefined; + readonly length: number; + readonly [Symbol.iterator]: () => IterableIterator; + + readonly find: (predicate: (value: T, index: number) => unknown) => T | undefined; + readonly findIndex: (predicate: (value: T, index: number) => unknown) => number; + readonly findLast: (predicate: (value: T, index: number) => unknown) => T | undefined; + readonly findLastIndex: (predicate: (value: T, index: number) => unknown) => number; + + readonly filter: (predicate: (value: T, index: number) => unknown) => T[]; + readonly map: (callbackfn: (value: T, index: number) => U) => U[]; +} + +export interface Arrayish extends ReadonlyArrayish { + readonly push: (...values: T[]) => number; +} + +const REACTIVE_ARRAY = Symbol('ReactiveArray'); +export interface ReactiveArray extends Arrayish { + [REACTIVE_ARRAY]: true; + lengthUntracked: number; +} + +const APPEND_ONLY_REACTIVE_ARRAY_SYMBOL = Symbol('AppendOnlyReactiveArray'); +export interface AppendOnlySignalArray extends ReactiveArray { + [APPEND_ONLY_REACTIVE_ARRAY_SYMBOL]: true; + unwrap(): T[]; +} + +export type AppendOnlyOrArray = AppendOnlySignalArray | T[]; +export type AppendOnlyOrReadOnlyArray = AppendOnlySignalArray | readonly T[]; + +export class InternalAppendOnlyReactiveArray implements Omit, number> { + [APPEND_ONLY_REACTIVE_ARRAY_SYMBOL] = true as const; + [REACTIVE_ARRAY] = true as const; + private _items: T[]; + private _signalPerIndex: Map> = new Map(); + private _getLength: Accessor; + private _setLength: Setter; + + [Symbol.iterator] = Array.prototype[Symbol.iterator]; + filter = Array.prototype.filter; + findLast = Array.prototype.findLast; + findLastIndex = Array.prototype.findLastIndex; + + constructor(values: T[]) { + this._items = values; + // eslint-disable-next-line solid/reactivity + [this._getLength, this._setLength] = createSignal(values.length); + } + + get(index: number): T | undefined { + if (index < this._items.length) { + return this._items[index]; + } + const existingSignal = this._signalPerIndex.get(index); + if (existingSignal) { + return existingSignal[0](); + } + // eslint-disable-next-line solid/reactivity + const newSignal = createSignal(undefined); + this._signalPerIndex.set(index, newSignal); + return newSignal[0](); + } + + at(index: number): T | undefined { + return index >= 0 ? this.get(index) : this.get(this._getLength() + index); + } + + push(...newItems: T[]): number { + return batch(() => { + for (const item of newItems) { + const index = this._items.length; + this._items.push(item); + const signal = this._signalPerIndex.get(index); + if (signal) { + // functions should not be put into the array directly. The typesystem prevents this, + // so its fine, but if somebody ignores the types it would be undefined behavior (to me :D) + signal[1](item as NotFunction); + } + this._signalPerIndex.delete(index); + } + this._setLength(this._items.length); + return this._items.length; + }); + } + + get length() { + return this._getLength(); + } + + get lengthUntracked() { + return this._items.length; + } + + unwrap(): T[] { + return this._items; + } + + findIndex: Array['findIndex'] = (callbackfn: (value: T, index: number, array: T[]) => unknown) => { + const index = this._items.findIndex(callbackfn); + if (index !== -1) { + return index; + } + // could be impacted by appending new elements + this._getLength(); + return -1; + }; + + find: Array['find'] = (callbackfn: (value: T, index: number, array: T[]) => unknown) => { + const value = this._items.find(callbackfn); + if (value !== undefined) { + return value; + } + // could be impacted by appending new elements + this._getLength(); + return undefined; + }; + + map: Array['map'] = (callbackfn: (value: T, index: number, array: T[]) => U) => { + // TODO replace with another method? always dependent on map, but could replace with a optimized memo version + this._getLength(); + return this._items.map(callbackfn); + }; +} + +export function createAppendOnlyReactiveArray(values: NotFunction[]): AppendOnlySignalArray> { + const internalArray = new InternalAppendOnlyReactiveArray(values); + + // Use a Proxy to intercept numeric index access (e.g. arr[0]). + return new Proxy(internalArray as unknown as AppendOnlySignalArray>, { + get(target, prop, receiver) { + if (typeof prop === 'string' && /^\d+$/.test(prop)) { + const index = Number(prop); + return internalArray.get(index); + } + return Reflect.get(target, prop, receiver); + }, + }); +} + +export function isReactiveArray(arr: ReadonlyArrayish): arr is ReactiveArray { + return (arr as any)[REACTIVE_ARRAY] === true; +} + +export function isAppendOnlyReactiveArray(arr: ReadonlyArrayish): arr is AppendOnlySignalArray { + return (arr as any)[APPEND_ONLY_REACTIVE_ARRAY_SYMBOL] === true; +} + +export function lengthUntracked(arr: ReadonlyArrayish): number { + return isReactiveArray(arr) ? arr.lengthUntracked : arr.length; +} diff --git a/src/lib/parser/util/array/binary-search.ts b/src/lib/parser/util/array/binary-search.ts new file mode 100644 index 00000000..364ee8fb --- /dev/null +++ b/src/lib/parser/util/array/binary-search.ts @@ -0,0 +1,53 @@ +import { lengthUntracked, ReadonlyArrayish } from './append-only-signal-array'; + +/** + * Binary search for the first element that is greater than or equal to the value. + * @param arr array sorted by getValue(arr[i]) in ascending order + * @param value value to search for + * @param getValue function to get the value from the array element to compare with the value + * @returns -1 if value is smaller than the getValue(arr[0]) or arr.length === 0 or the index of the last element that is smaller than the value. + */ +export function binarySearchLastIndexBefore( + arr: ReadonlyArrayish, + value: number, + getValue: (v: T) => number, +): number { + let low = 0; + let high = lengthUntracked(arr) - 1; + while (low <= high) { + const mid = (low + high) >>> 1; + const midValue = getValue(arr[mid]!); + if (midValue <= value) { + low = mid + 1; + } else { + high = mid - 1; + } + } + if (high === -1) { + // when its not found it could be found after pushing new elements + // when its found it is not dependent on length / at least only dependent + // on the values already in the array. Which might reactive. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + arr.length; + } + return high; +} + +export function binarySearchLastIndexBeforeUnreactive( + arr: readonly T[], + value: number, + getValue: (v: T) => number, +): number { + let low = 0; + let high = arr.length - 1; + while (low <= high) { + const mid = (low + high) >>> 1; + const midValue = getValue(arr[mid]!); + if (midValue <= value) { + low = mid + 1; + } else { + high = mid - 1; + } + } + return high; +} diff --git a/src/lib/parser/util/binary-search.ts b/src/lib/parser/util/binary-search.ts deleted file mode 100644 index 330c8667..00000000 --- a/src/lib/parser/util/binary-search.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Binary search for the first element that is greater than or equal to the value. - * @param arr array sorted by getValue(arr[i]) in ascending order - * @param value value to search for - * @param getValue function to get the value from the array element to compare with the value - * @returns -1 if value is smaller than the getValue(arr[0]) or arr.length === 0 or the index of the last element that is smaller than the value. - */ -export function binarySearchLastIndexBefore(arr: readonly T[], value: number, getValue: (v: T) => number): number { - let low = 0; - let high = arr.length - 1; - while (low <= high) { - const mid = (low + high) >>> 1; - const midValue = getValue(arr[mid]!); - if (midValue <= value) { - low = mid + 1; - } else { - high = mid - 1; - } - } - return high; -} diff --git a/src/lib/parser/util/index.ts b/src/lib/parser/util/index.ts index 4ea3af0f..9f47f533 100644 --- a/src/lib/parser/util/index.ts +++ b/src/lib/parser/util/index.ts @@ -1,4 +1,4 @@ -export * from './binary-search'; +export * from './array/binary-search'; export * from './html'; export * from './omit'; export * from './other'; diff --git a/src/lib/viz/loader/combined/run-combined-loader.ts b/src/lib/viz/loader/combined/run-combined-loader.ts index 0ab458ce..22f94f90 100644 --- a/src/lib/viz/loader/combined/run-combined-loader.ts +++ b/src/lib/viz/loader/combined/run-combined-loader.ts @@ -14,7 +14,7 @@ export function createCombinedRunLoader(runData: () => GetRunResult | undefined) return createRunFileLoader(run.files); }); - const liveLoader = createRunLiveLoader(); + const liveLoader = createRunLiveLoader(() => loader()?.combinedRecording() ?? null); return { loader, liveLoader }; } diff --git a/src/lib/viz/loader/live/outgoing-message-model.ts b/src/lib/viz/loader/live/outgoing-message-model.ts new file mode 100644 index 00000000..6e53c258 --- /dev/null +++ b/src/lib/viz/loader/live/outgoing-message-model.ts @@ -0,0 +1,7 @@ +export interface ViewerAppendDataMessage { + type: 'viewer:append'; + filePartNr: number; + data: string[]; +} + +export type ViewerMessage = ViewerAppendDataMessage; diff --git a/src/lib/viz/loader/live/run-live-loader.ts b/src/lib/viz/loader/live/run-live-loader.ts index a55ef9bd..4a7dec2c 100644 --- a/src/lib/viz/loader/live/run-live-loader.ts +++ b/src/lib/viz/loader/live/run-live-loader.ts @@ -1,15 +1,73 @@ -import { createEffect, onCleanup } from 'solid-js'; +import { onCleanup, onMount } from 'solid-js'; +import { effect } from 'solid-js/web'; +import { + CombinedRecording, + combineRecordingsAppend, + ParsedRecording, + parseRecordingFile, + ParseRecordingFileContext, +} from '~/lib/parser'; +import { ViewerMessage } from './outgoing-message-model'; -export function createRunLiveLoader() { +export function createRunLiveLoader(combinedRecording: () => CombinedRecording | null) { // TODO take url and access token as argument - createEffect(() => { + onMount(() => { const ws = new WebSocket('ws://127.0.0.1:8787/websocket?mode=view&accessKey=abc'); + + let currentParsingContext!: ParseRecordingFileContext; + let currentRecordingFile!: ParsedRecording; + + let combinedRecordingReady = false; + let queue = [] as ViewerMessage[]; + + function processMessage(message: ViewerMessage) { + const recording = combinedRecording()!; + if (message.type === 'viewer:append') { + const isNewPart = + currentParsingContext == null || message.filePartNr !== currentRecordingFile?.combinedPartNumber; + + if (isNewPart) { + currentParsingContext = new ParseRecordingFileContext(); + } + + const events = parseRecordingFile(message.data.join('\n'), message.filePartNr, currentParsingContext); + + if (isNewPart) { + currentRecordingFile = new ParsedRecording( + events, + currentParsingContext.unknownEvents, + currentParsingContext.parsingErrors, + message.filePartNr, + true, + ); + } else { + currentRecordingFile.append(events, currentParsingContext); + } + combineRecordingsAppend(recording, events); + } + console.log('received', message); + } + + effect(() => { + if (combinedRecordingReady) return; + if (combinedRecording()) { + combinedRecordingReady = true; + queue.forEach((message) => processMessage(message)); + queue = []; + } + }); + ws.onopen = () => { console.log('connected'); }; ws.onmessage = (event) => { - console.log('received', event.data); + const message = JSON.parse(event.data) as ViewerMessage; + if (combinedRecordingReady) { + processMessage(message); + } else { + queue.push(message); + } }; onCleanup(() => { diff --git a/src/lib/viz/loader/parts/run-files-loader.ts b/src/lib/viz/loader/parts/run-files-loader.ts index a3c6d9a8..1a162cf2 100644 --- a/src/lib/viz/loader/parts/run-files-loader.ts +++ b/src/lib/viz/loader/parts/run-files-loader.ts @@ -1,5 +1,11 @@ -import { combineRecordings, parseRecordingFile } from '../../../parser'; -import { createDeferred, createMemo, createSignal } from 'solid-js'; +import { + CombinedRecording, + combineRecordings, + ParsedRecording, + parseRecordingFile, + ParseRecordingFileContext, +} from '../../../parser'; +import { createDeferred, createMemo, createSignal, onMount } from 'solid-js'; import { fetchWithRunfileCache, openRunfileCache } from './recording-file-browser-cache'; import { type RunFileInfo } from './run-files-info'; import { wrapResultWithProgress } from './wrap-result-with-progress'; @@ -20,14 +26,16 @@ async function loadFile(cache: Promise, file: RunFileInfo, onProgr }), ); const data = await response.text(); - const recording = parseRecordingFile(data, file.combinedPartNumber); - return recording; + const context = new ParseRecordingFileContext(); + const events = parseRecordingFile(data, file.combinedPartNumber, context); + return new ParsedRecording(events, context.unknownEvents, context.parsingErrors, file.combinedPartNumber); } export interface RunFileLoader { progress: () => number; done: () => boolean; abort: () => void; + combinedRecording: () => CombinedRecording | null; } export function createRunFileLoader(files: RunFileInfo[]): RunFileLoader { @@ -38,6 +46,7 @@ export function createRunFileLoader(files: RunFileInfo[]): RunFileLoader { abort: () => { // do nothing }, + combinedRecording: () => null, }; } @@ -53,6 +62,7 @@ export function createRunFileLoader(files: RunFileInfo[]): RunFileLoader { promise: loadFile(cache, file, setProgress), }; }); + const [combinedRecording, setCombinedRecording] = createSignal(null); const progress = createMemo(() => { const totalProgress = fileLoaders.reduce((acc, { progress }) => acc + progress(), 0); @@ -72,12 +82,18 @@ export function createRunFileLoader(files: RunFileInfo[]): RunFileLoader { if (abortController.signal.aborted) return; const combinedRecording = combineRecordings(recordings); storeInitializer.initializeFromRecording(combinedRecording); + setCombinedRecording(combinedRecording); setDone(true); }); + onMount(() => { + (window as any).recording = combinedRecording; + }); + return { progress: deferredProgress, done, + combinedRecording, abort: () => abortController.abort(), }; } diff --git a/src/lib/viz/map/traces-canvas.tsx b/src/lib/viz/map/traces-canvas.tsx index 971f9f56..1fb5a6dc 100644 --- a/src/lib/viz/map/traces-canvas.tsx +++ b/src/lib/viz/map/traces-canvas.tsx @@ -1,5 +1,6 @@ import { createEffect, type Component } from 'solid-js'; import { + PlayerPositionEvent, Vector2, binarySearchLastIndexBefore, mapVisualExtends, @@ -80,7 +81,9 @@ export const HKMapTraces: Component = () => { const minMsIntoGame = animationStore.msIntoGame() - traceStore.lengthMs(); const maxMsIntoGame = animationStore.msIntoGame(); - const positionEvents = gameplayStore.recording()?.playerPositionEventsWithTracePosition ?? EMPTY_ARRAY; + const positionEvents = + gameplayStore.recording()?.playerPositionEventsWithTracePosition ?? + (EMPTY_ARRAY as ReadonlyArray); const visibility = traceStore.visibility(); diff --git a/src/lib/viz/store/gameplay-store.ts b/src/lib/viz/store/gameplay-store.ts index fee1936f..a187bf10 100644 --- a/src/lib/viz/store/gameplay-store.ts +++ b/src/lib/viz/store/gameplay-store.ts @@ -1,4 +1,4 @@ -import { createContext, createMemo, createSignal, useContext } from 'solid-js'; +import { createContext, createEffect, createMemo, createSignal, useContext } from 'solid-js'; import { playerDataFields, type CombinedRecording } from '../../parser'; export function createGameplayStore() { @@ -21,6 +21,9 @@ export function createGameplayStore() { )?.value; return permaDeathValue === 1 || permaDeathValue === 2; }); + createEffect(() => { + console.log('tf', timeFrame()); + }); return { recording, diff --git a/src/lib/viz/time-charts/down-scale.ts b/src/lib/viz/time-charts/down-scale.ts index 07f2bd6c..116c0d4e 100644 --- a/src/lib/viz/time-charts/down-scale.ts +++ b/src/lib/viz/time-charts/down-scale.ts @@ -1,6 +1,6 @@ import { type FrameEndEvent, type FrameEndEventNumberKey } from '../../parser'; -export function downScale(data: FrameEndEvent[], fields: FrameEndEventNumberKey[], maxTimeDelta = 10000) { +export function downScale(data: ArrayLike, fields: FrameEndEventNumberKey[], maxTimeDelta = 10000) { console.log('Original length', data.length, fields); let previous: FrameEndEvent | undefined = undefined;