Skip to content

Commit

Permalink
partial: Drafted saving of Clues changes
Browse files Browse the repository at this point in the history
  • Loading branch information
adam-coster committed Nov 8, 2023
1 parent 77f99b9 commit 511815d
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 38 deletions.
49 changes: 38 additions & 11 deletions packages/gcdata/src/GameChanger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type SchemaId,
} from './types.js';
import {
computePointers,
resolvePointer,
resolvePointerInSchema,
setValueAtPointer,
Expand Down Expand Up @@ -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,
);
Expand All @@ -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 =
Expand All @@ -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,
});
}
Expand Down Expand Up @@ -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;
Expand Down
82 changes: 59 additions & 23 deletions packages/gcdata/src/cl2.quest.parse.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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> {
): QuestUpdateResult {
const motes = getMoteLists(packed.working);
const momentStyles = getMomentStyleNames(packed.working);
// Remove 'Dialogue' and 'Emote' from the list of moment styles
Expand Down Expand Up @@ -57,7 +56,6 @@ export async function parseStringifiedQuest(
hovers: [],
edits: [],
completions: [],
saved: false,
parsed: {
clues: [],
quest_end_moments: [],
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/gcdata/src/cl2.quest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
Expand Down
5 changes: 4 additions & 1 deletion packages/gcdata/src/cl2.quest.ts
Original file line number Diff line number Diff line change
@@ -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';
1 change: 0 additions & 1 deletion packages/gcdata/src/cl2.quest.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
34 changes: 33 additions & 1 deletion packages/gcdata/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<string>(),
__basePointer: string[] = [],
): Set<string> {
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.
*/
Expand Down

0 comments on commit 511815d

Please sign in to comment.