Skip to content

Commit ff084d3

Browse files
committed
PR_26171_015 Game Journey target persistence
1 parent 16ba3b8 commit ff084d3

4 files changed

Lines changed: 159 additions & 8 deletions

File tree

src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ const GENERATED_ULID_SEQUENCE = Object.freeze({
9898
note: 4001,
9999
diagnostic: 5001,
100100
});
101+
const RECOMMENDED_TARGET_LINKED_RECORD_TYPE = "recommended-target";
102+
const RECOMMENDED_TARGET_NOTE_KEY = GAME_JOURNEY_KEYS.notes.designPass;
101103

102104
export const GAME_JOURNEY_STATUSES = [
103105
{
@@ -619,6 +621,14 @@ export function createGameJourneyMockRepository(options = {}) {
619621
return currentSessionUser().userKey;
620622
}
621623

624+
function safeCurrentUserKey() {
625+
try {
626+
return currentUserKey() || systemUserKey();
627+
} catch {
628+
return systemUserKey();
629+
}
630+
}
631+
622632
function currentUserCanWrite() {
623633
return Boolean(currentUserKey());
624634
}
@@ -631,6 +641,10 @@ export function createGameJourneyMockRepository(options = {}) {
631641
return Boolean(item && item.createdBy === systemUserKey());
632642
}
633643

644+
function isRecommendedTargetItem(item) {
645+
return item?.linkedRecordType === RECOMMENDED_TARGET_LINKED_RECORD_TYPE;
646+
}
647+
634648
function currentUserCanSeeNote(note) {
635649
const sessionUser = currentSessionUser();
636650
return Boolean(sessionUser.userKey && (sessionUser.isAdmin || note.ownerKey === sessionUser.userKey));
@@ -745,10 +759,95 @@ export function createGameJourneyMockRepository(options = {}) {
745759

746760
function getItemsForNote(noteKey) {
747761
return tables.game_journey_items
748-
.filter((item) => item.noteKey === noteKey)
762+
.filter((item) => item.noteKey === noteKey && !isRecommendedTargetItem(item))
749763
.sort((left, right) => left.order - right.order);
750764
}
751765

766+
function normalizeTargetCount(value) {
767+
const parsed = Number(value);
768+
if (!Number.isFinite(parsed)) {
769+
return 0;
770+
}
771+
return Math.max(0, Math.trunc(parsed));
772+
}
773+
774+
function readTargetCount(item, fallbackCount) {
775+
if (!item?.userDetails) {
776+
return fallbackCount;
777+
}
778+
try {
779+
const payload = JSON.parse(item.userDetails);
780+
return normalizeTargetCount(payload.suggestedCount ?? fallbackCount);
781+
} catch {
782+
return fallbackCount;
783+
}
784+
}
785+
786+
function findRecommendedTargetItem(targetKey) {
787+
return tables.game_journey_items.find((item) =>
788+
item.gameKey === GAME_JOURNEY_KEYS.game &&
789+
item.linkedRecordType === RECOMMENDED_TARGET_LINKED_RECORD_TYPE &&
790+
item.linkedRecordId === targetKey,
791+
);
792+
}
793+
794+
function hydrateRecommendedTarget(target) {
795+
const item = findRecommendedTargetItem(target.key);
796+
return {
797+
...clone(target),
798+
suggestedCount: readTargetCount(item, target.suggestedCount),
799+
persisted: Boolean(item),
800+
recordKey: item?.key || "",
801+
updatedAt: item?.updatedAt || "",
802+
updatedBy: item?.updatedBy || "",
803+
};
804+
}
805+
806+
function listRecommendedTargets() {
807+
return GAME_JOURNEY_RECOMMENDED_TARGETS.map(hydrateRecommendedTarget);
808+
}
809+
810+
function updateRecommendedTarget(targetKey, suggestedCount) {
811+
const activeGame = requireActiveGame();
812+
const target = GAME_JOURNEY_RECOMMENDED_TARGETS.find((item) => item.key === targetKey);
813+
if (!activeGame || !target) {
814+
return null;
815+
}
816+
817+
const normalizedCount = normalizeTargetCount(suggestedCount);
818+
const timestampValue = new Date().toISOString();
819+
const userKey = safeCurrentUserKey();
820+
let item = findRecommendedTargetItem(target.key);
821+
if (!item) {
822+
item = {
823+
key: makeUlid(nextItemNumber),
824+
gameKey: activeGame.key,
825+
noteKey: RECOMMENDED_TARGET_NOTE_KEY,
826+
status: "not-started",
827+
title: `Recommended target: ${target.label}`,
828+
userDetails: "",
829+
createdBy: userKey,
830+
updatedBy: userKey,
831+
templateKey: "",
832+
linkedRecordType: RECOMMENDED_TARGET_LINKED_RECORD_TYPE,
833+
linkedRecordId: target.key,
834+
indent: 0,
835+
order: getItemsForNote(RECOMMENDED_TARGET_NOTE_KEY).length + 1,
836+
createdAt: timestampValue,
837+
updatedAt: timestampValue,
838+
};
839+
nextItemNumber += 1;
840+
tables.game_journey_items.push(item);
841+
}
842+
843+
item.userDetails = JSON.stringify({ suggestedCount: normalizedCount });
844+
item.updatedAt = timestampValue;
845+
item.updatedBy = userKey;
846+
addActivity(activeGame.key, RECOMMENDED_TARGET_NOTE_KEY, `Updated ${target.label} recommended target to ${normalizedCount}`, userKey);
847+
persistTables();
848+
return hydrateRecommendedTarget(target);
849+
}
850+
752851
function itemMatchesFilter(item, filterId) {
753852
if (filterId === "system") {
754853
return isSystemItem(item);
@@ -1238,6 +1337,8 @@ export function createGameJourneyMockRepository(options = {}) {
12381337
getCompletionMetricsSnapshot: () => completionMetricsStore.snapshot(),
12391338
listCompletionMetrics: () => completionMetricsStore.listMetrics(),
12401339
updateCompletionMetric: (bucketKey, updates) => completionMetricsStore.updateMetric(bucketKey, updates),
1340+
listRecommendedTargets,
1341+
updateRecommendedTarget,
12411342
getSessionUser: () => currentSessionUser(),
12421343
getSystemUser: () => getMockDbSystemUser(),
12431344
getActiveGame,

src/dev-runtime/server/local-api-router.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5206,7 +5206,9 @@ LIMIT 1;
52065206
}
52075207
const result = await method(...args);
52085208
assertRepositoryMethodResult(repositoryId, methodName, result);
5209-
if (repositoryMethodRequiresPersistence(methodName)) {
5209+
const methodPersistsThroughToolStore =
5210+
repository === this.gameJourneyRepository && methodName === "updateRecommendedTarget";
5211+
if (repositoryMethodRequiresPersistence(methodName) && !methodPersistsThroughToolStore) {
52105212
if (repository === this.gameWorkspaceRepository) {
52115213
await this.persistGameWorkspaceProviderState(`Persisting ${methodName} result`);
52125214
} else if (repository === this.assetRepository) {

tests/playwright/tools/GameJourneyTool.spec.mjs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ test("Game Journey exposes static tool ownership areas without automatic counts"
219219
});
220220

221221
test("Game Journey progress dashboard summarizes completion metrics", async ({ page }) => {
222+
const previousLocalDbPath = process.env.GAMEFOUNDRY_LOCAL_DB_PATH;
223+
const localDbPath = path.join(process.cwd(), "tmp", "local-db", `game-journey-targets-${process.pid}-${Date.now()}.sqlite`);
224+
process.env.GAMEFOUNDRY_LOCAL_DB_PATH = localDbPath;
222225
const server = await startRepoServer();
223226
const previousApiUrl = process.env.GAMEFOUNDRY_API_URL;
224227
const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL;
@@ -294,8 +297,26 @@ test("Game Journey progress dashboard summarizes completion metrics", async ({ p
294297
await expect(page.locator("[data-journey-recommended-target='heroes'] td").nth(1)).toHaveText("Objects");
295298
await expect(page.locator("[data-journey-target-input='heroes']")).toHaveValue("1");
296299
await page.locator("[data-journey-target-input='heroes']").fill("2");
297-
await expect(page.locator("[data-journey-target-status]")).toHaveText("Heroes suggested target set to 2.");
300+
await expect(page.locator("[data-journey-target-status]")).toHaveText("Saved Heroes suggested target at 2.");
298301
await expect(page.locator("[data-journey-target-input='heroes']")).toHaveValue("2");
302+
await page.reload({ waitUntil: "networkidle" });
303+
await expect(page.locator("[data-journey-target-input='heroes']")).toHaveValue("2");
304+
const repositoryData = await fetchApiData(server, "/api/toolbox/game-journey/repositories", {
305+
body: JSON.stringify({ options: {} }),
306+
method: "POST",
307+
});
308+
const tablesData = await fetchApiData(server, `/api/toolbox/game-journey/repositories/${repositoryData.repositoryId}/methods/getTables`, {
309+
body: JSON.stringify({ args: [] }),
310+
method: "POST",
311+
});
312+
const persistedTarget = (tablesData.result.game_journey_items || []).find((item) =>
313+
item.linkedRecordType === "recommended-target" && item.linkedRecordId === "heroes",
314+
);
315+
expect(persistedTarget).toMatchObject({
316+
noteKey: GAME_JOURNEY_KEYS.notes.designPass,
317+
title: "Recommended target: Heroes",
318+
});
319+
expect(JSON.parse(persistedTarget.userDetails)).toMatchObject({ suggestedCount: 2 });
299320
await expect(page.locator("style, [style], script:not([src])")).toHaveCount(0);
300321

301322
expect(failedRequests).toEqual([]);
@@ -304,6 +325,8 @@ test("Game Journey progress dashboard summarizes completion metrics", async ({ p
304325
} finally {
305326
await workspaceV2CoverageReporter.stop(page);
306327
await server.close();
328+
await fs.rm(localDbPath, { force: true });
329+
restoreEnvValue("GAMEFOUNDRY_LOCAL_DB_PATH", previousLocalDbPath);
307330
restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl);
308331
restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl);
309332
}

toolbox/game-journey/game-journey.js

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ const recommendedTargetValues = new Map(
8585
GAME_JOURNEY_RECOMMENDED_TARGETS.map((target) => [target.key, target.suggestedCount]),
8686
);
8787

88+
function currentRecommendedTargets() {
89+
const result = repository.listRecommendedTargets();
90+
if (!Array.isArray(result)) {
91+
return GAME_JOURNEY_RECOMMENDED_TARGETS;
92+
}
93+
result.forEach((target) => {
94+
recommendedTargetValues.set(target.key, normalizeTargetCount(target.suggestedCount));
95+
});
96+
return result;
97+
}
98+
8899
function refreshCompletionMetricsSnapshot() {
89100
try {
90101
completionMetricsSnapshot = readGameJourneyCompletionMetrics();
@@ -881,7 +892,8 @@ function renderRecommendedTargets() {
881892
return;
882893
}
883894
recommendedTargets.innerHTML = "";
884-
if (!GAME_JOURNEY_RECOMMENDED_TARGETS.length) {
895+
const targets = currentRecommendedTargets();
896+
if (!targets.length) {
885897
recommendedTargets.append(createElement("p", { text: "No recommended planning targets are available." }));
886898
return;
887899
}
@@ -898,7 +910,7 @@ function renderRecommendedTargets() {
898910
});
899911
head.append(headRow);
900912
const body = createElement("tbody");
901-
GAME_JOURNEY_RECOMMENDED_TARGETS.forEach((target) => {
913+
targets.forEach((target) => {
902914
const row = createElement("tr");
903915
row.dataset.journeyRecommendedTarget = target.key;
904916
const labelCell = createElement("td", { text: target.label });
@@ -1368,10 +1380,23 @@ recommendedTargets?.addEventListener("input", (event) => {
13681380
return;
13691381
}
13701382
const value = normalizeTargetCount(input.value);
1371-
recommendedTargetValues.set(target.key, value);
1372-
input.value = String(value);
1383+
if (redirectGuestWriteAction(recommendedTargetStatus)) {
1384+
input.value = String(recommendedTargetValues.get(target.key) ?? target.suggestedCount);
1385+
return;
1386+
}
1387+
const updated = repository.updateRecommendedTarget(target.key, value);
1388+
if (!updated || updated.error) {
1389+
if (recommendedTargetStatus) {
1390+
recommendedTargetStatus.textContent = "Open a game before saving recommended targets.";
1391+
}
1392+
input.value = String(recommendedTargetValues.get(target.key) ?? target.suggestedCount);
1393+
return;
1394+
}
1395+
const savedValue = normalizeTargetCount(updated.suggestedCount);
1396+
recommendedTargetValues.set(target.key, savedValue);
1397+
input.value = String(savedValue);
13731398
if (recommendedTargetStatus) {
1374-
recommendedTargetStatus.textContent = `${target.label} suggested target set to ${value}.`;
1399+
recommendedTargetStatus.textContent = `Saved ${target.label} suggested target at ${savedValue}.`;
13751400
}
13761401
});
13771402

0 commit comments

Comments
 (0)