diff --git a/packages/gcdata/src/GameChanger.ts b/packages/gcdata/src/GameChanger.ts index babd3190..bb8b0100 100644 --- a/packages/gcdata/src/GameChanger.ts +++ b/packages/gcdata/src/GameChanger.ts @@ -20,6 +20,7 @@ import { type SchemaId, } from './types.js'; import { + computePointers, resolvePointer, resolvePointerInSchema, setValueAtPointer, @@ -100,13 +101,19 @@ export class GameChanger { } updateMoteData(moteId: string, dataPath: string, value: any) { + assert(moteId, 'Must specify mote ID'); + assert( + typeof dataPath === 'string' && dataPath.startsWith('data/'), + 'Data path must start with "data/"', + ); + // Make sure this is a valid request const workingMote = this.working.getMote(moteId); assert(workingMote, `Cannot update non-existent mote ${moteId}`); const schema = this.working.getSchema(workingMote.schema_id); assert(schema, `Mote schema ${workingMote.schema_id} does not exist`); const subschema = resolvePointerInSchema( - dataPath, + dataPath.replace(/^data\//, ''), workingMote, this.working, ); @@ -115,31 +122,44 @@ export class GameChanger { `Could not resolve ${dataPath} in schema ${workingMote.schema_id}}`, ); - // Only allow for scalar values - assert(!isBschemaObject(subschema), 'Can only set scalar values'); - // Do some basic schema validation to avoid really dumb errors if (typeof value === 'string') { assert( isBschemaString(subschema), - 'Invalid value. Bschema is not for a string.', + `Invalid value '${JSON.stringify( + value, + )}'. Schema for ${dataPath} is not for a string.`, ); } else if (typeof value === 'boolean') { assert( isBschemaBoolean(subschema), - 'Invalid value. Bschema is not boolean', + `Invalid value '${JSON.stringify( + value, + )}'. Schema for ${dataPath} is not boolean`, ); } else if (typeof value === 'number') { assert( isBschemaBoolean(subschema) || isBschemaNumeric(subschema), - 'Invalid value. Bschema is not numeric', + `Invalid value '${JSON.stringify( + value, + )}'. Schema for ${dataPath} is not numeric`, ); } - const fullPath = `data/${dataPath}`; + if (isBschemaObject(subschema) && value === null) { + // Then we are deleting a sub-object, so we need to find each + // entry by path and add a deletion for it + const subdata = resolvePointer(dataPath, this.workingData.motes[moteId]); + const pointers = computePointers(subdata, dataPath); + for (const pointer of pointers) { + this.updateMoteData(moteId, pointer, null); + } + // We don't store the deletion of the sub-object itself, so we're done! + return; + } // Update the working data - setValueAtPointer(this.workingData.motes[moteId], fullPath, value); + setValueAtPointer(this.workingData.motes[moteId], dataPath, value); // See if we have a change relative to the base const currentValue = @@ -151,12 +171,12 @@ export class GameChanger { if (currentValue == value) { // Then we haven't changed from the base data, but // we might be *undoing* a working data change. - delete this.changes.changes.motes?.[moteId]?.diffs?.[fullPath]; + delete this.changes.changes.motes?.[moteId]?.diffs?.[dataPath]; return; } this.createChange('motes', moteId, { type: 'changed', - pointer: fullPath, + pointer: dataPath, newValue: value, }); } @@ -244,6 +264,13 @@ export class GameChanger { if (change.type === 'deleted') { delete this.workingData[type][id]; continue; + } else if ( + change.type === 'changed' && + !Object.keys(change.diffs || {}).length + ) { + // Then we can remove this entry altogether + delete this.changes.changes[type][id]; + continue; } // Ensure the base object exists this.workingData[type][id] ||= {} as any; diff --git a/packages/gcdata/src/cl2.quest.parse.ts b/packages/gcdata/src/cl2.quest.parse.ts index c1c4711f..836d0858 100644 --- a/packages/gcdata/src/cl2.quest.parse.ts +++ b/packages/gcdata/src/cl2.quest.parse.ts @@ -1,6 +1,6 @@ import { GameChanger } from './GameChanger.js'; import { assert } from './assert.js'; -import { QuestMotePointer } from './cl2.quest.pointers.js'; +import { QuestMoteDataPointer } from './cl2.quest.pointers.js'; import { ParsedClue, ParsedDialog, @@ -23,11 +23,10 @@ import { Crashlands2 } from './types.cl2.js'; import { Position } from './types.editor.js'; import { Mote } from './types.js'; -export async function parseStringifiedQuest( +export function parseStringifiedQuest( text: string, - moteId: string, packed: GameChanger, -): Promise { +): QuestUpdateResult { const motes = getMoteLists(packed.working); const momentStyles = getMomentStyleNames(packed.working); // Remove 'Dialogue' and 'Emote' from the list of moment styles @@ -57,7 +56,6 @@ export async function parseStringifiedQuest( hovers: [], edits: [], completions: [], - saved: false, parsed: { clues: [], quest_end_moments: [], @@ -446,15 +444,10 @@ export async function parseStringifiedQuest( index += line.length; } - if (result.diagnostics.length === 0) { - await updateChangesFromParsedQuest(result.parsed, moteId, packed); - result.saved = true; - } - return result; } -async function updateChangesFromParsedQuest( +export async function updateChangesFromParsedQuest( parsed: QuestUpdateResult['parsed'], moteId: string, packed: GameChanger, @@ -471,30 +464,31 @@ async function updateChangesFromParsedQuest( const schema = packed.working.getSchema('cl2_quest')!; assert(schema.name, 'Quest mote must have a name pointer'); assert(schema, 'cl2_quest schema not found in working copy'); - const updateMote = (path: QuestMotePointer, value: any) => { + const updateMote = (path: QuestMoteDataPointer, value: any) => { packed.updateMoteData(moteId, path, value); }; - updateMote('name', parsed.name); - updateMote('quest_giver/item', parsed.quest_giver); - updateMote('quest_receiver/item', parsed.quest_receiver); - updateMote('quest_start_log/text', parsed.quest_start_log); - updateMote('wip/draft', parsed.draft); - updateMote('storyline', parsed.storyline); + updateMote('data/name', parsed.name); + updateMote('data/quest_giver/item', parsed.quest_giver); + updateMote('data/quest_receiver/item', parsed.quest_receiver); + updateMote('data/quest_start_log/text', parsed.quest_start_log); + updateMote('data/wip/draft', parsed.draft); + updateMote('data/storyline', parsed.storyline); const parsedComments = parsed.comments.filter((c) => !!c.text); + const parsedClues = parsed.clues.filter((c) => !!c.id && !!c.speaker); //#region COMMENTS // Add/Update COMMENTS for (const comment of parsedComments) { - updateMote(`wip/comments/${comment.id}/element`, comment.text); + updateMote(`data/wip/comments/${comment.id}/element`, comment.text); } // Remove deleted comments for (const existingComment of bsArrayToArray( questMoteBase?.data.wip?.comments || {}, )) { if (!parsedComments.find((c) => c.id === existingComment.id)) { - updateMote(`wip/comments/${existingComment.id}`, null); + updateMote(`data/wip/comments/${existingComment.id}`, null); } } // Get the BASE order of the comments (if any) and use those @@ -512,12 +506,54 @@ async function updateChangesFromParsedQuest( }); updateBsArrayOrder(comments); comments.forEach((comment) => { - updateMote(`wip/comments/${comment.id}/order`, comment.order); + updateMote(`data/wip/comments/${comment.id}/order`, comment.order); }); //#endregion - // TODO - parsed.clues; + //#region CLUES + // Add/update clues + for (const clue of parsedClues) { + updateMote(`data/clues/${clue.id}/element/speaker`, clue.speaker); + for (const phrase of clue.phrases) { + updateMote( + `data/clues/${clue.id}/element/phrases/${phrase.id}/element/phrase/text/text`, + phrase.text || '', + ); + if (phrase.emoji) { + updateMote( + `data/clues/${clue.id}/element/phrases/${phrase.id}/element/phrase/emoji`, + phrase.emoji, + ); + } + } + } + // Delete clues that were removed + for (const existingClue of bsArrayToArray(questMoteBase?.data.clues || {})) { + if (!parsedClues.find((c) => c.id === existingClue.id)) { + updateMote(`data/clues/${existingClue.id}`, null); + } + } + // TODO: Ensure proper order of clues and phrases + const clues = parsedClues.map((c) => { + // Look up the base clue + let clue = questMoteBase?.data.clues?.[c.id!]; + if (!clue) { + clue = questMoteWorking?.data.clues?.[c.id!]; + // @ts-expect-error - order is a required field, but it'll be re-added + delete clue?.order; + } + // TODO: Ensure proper order of phrases + assert(clue, `Clue ${c.id} not found in base or working mote`); + return { ...clue, id: c.id! }; + }); + updateBsArrayOrder(clues); + clues.forEach((clue) => { + updateMote(`data/clues/${clue.id}/order`, clue.order); + // TODO: Ensure proper order of phrases + }); + + //#endregion + // TODO parsed.quest_end_moments; // TODO diff --git a/packages/gcdata/src/cl2.quest.test.ts b/packages/gcdata/src/cl2.quest.test.ts index 66bbe85d..0a346e11 100644 --- a/packages/gcdata/src/cl2.quest.test.ts +++ b/packages/gcdata/src/cl2.quest.test.ts @@ -127,7 +127,7 @@ describe('Cl2 Quests', function () { ); for (const quest of quests) { const asText = stringifyQuest(quest, packed); - const results = await parseStringifiedQuest(asText, quest.id, packed); + const results = parseStringifiedQuest(asText, packed); if (results.diagnostics.length > 0) { console.error(results.diagnostics.map((d) => d.message).join('\n')); } diff --git a/packages/gcdata/src/cl2.quest.ts b/packages/gcdata/src/cl2.quest.ts index f471e4f5..2beee9e9 100644 --- a/packages/gcdata/src/cl2.quest.ts +++ b/packages/gcdata/src/cl2.quest.ts @@ -1,3 +1,6 @@ -export { parseStringifiedQuest } from './cl2.quest.parse.js'; +export { + parseStringifiedQuest, + updateChangesFromParsedQuest, +} from './cl2.quest.parse.js'; export { stringifyQuest } from './cl2.quest.stringify.js'; export type { QuestUpdateResult } from './cl2.quest.types.js'; diff --git a/packages/gcdata/src/cl2.quest.types.ts b/packages/gcdata/src/cl2.quest.types.ts index 7d3c7ce3..ade0530f 100644 --- a/packages/gcdata/src/cl2.quest.types.ts +++ b/packages/gcdata/src/cl2.quest.types.ts @@ -86,7 +86,6 @@ export interface QuestUpdateResult { hovers: (Range & { title?: string; description?: string })[]; edits: (Range & { newText: string })[]; completions: (Range & CompletionsData)[]; - saved: boolean; parsed: { name?: string; /** The moteId for the storyline */ diff --git a/packages/gcdata/src/util.ts b/packages/gcdata/src/util.ts index b6968424..e5a2c281 100644 --- a/packages/gcdata/src/util.ts +++ b/packages/gcdata/src/util.ts @@ -72,7 +72,7 @@ export function setValueAtPointer( for (let i = 0; i < pointer.length; i++) { if (i === pointer.length - 1) { current[pointer[i]] = value; - } else if (current[pointer[i]] === undefined) { + } else if ([undefined, null].includes(current[pointer[i]])) { current[pointer[i]] = {}; } else if (typeof current[pointer[i]] !== 'object') { throw new Error( @@ -134,6 +134,38 @@ export function capitalize(str: string) { return str[0].toUpperCase() + str.slice(1); } +/** + * Given some kind of data object, traverse it to generate all + * of the terminal pointers through the data. + * Optionally prefix each pointer with some string. + */ +export function computePointers( + data: any, + prefixWith?: string, + collection = new Set(), + __basePointer: string[] = [], +): Set { + const addToCollection = () => { + const pointer = [...__basePointer]; + collection.add(pointer.join('/')); + return collection; + }; + + __basePointer = prefixWith + ? [prefixWith, ...__basePointer] + : [...__basePointer]; + + if (typeof data === 'object') { + for (const key in data) { + const subdata = data[key]; + computePointers(subdata, undefined, collection, [...__basePointer, key]); + } + } else { + addToCollection(); + } + return collection; +} + /** * Get all Bschema-style data pointers defined by a schema. */