From cdb1a2e8daae7d283b8785c8f5cba18a4f6ff323 Mon Sep 17 00:00:00 2001 From: DavidQ Date: Mon, 22 Jun 2026 14:27:18 -0400 Subject: [PATCH] PR_26174_ALFA_003-game-hub-journey-bootstrap --- ...ub-journey-bootstrap-branch-validation.txt | 6 + ...rney-bootstrap-manual-validation-notes.txt | 6 + ...ourney-bootstrap-requirement-checklist.txt | 8 + ...-hub-journey-bootstrap-validation-lane.txt | 8 + ...174_ALFA_003-game-hub-journey-bootstrap.md | 16 + .../dev/reports/codex_changed_files.txt | 13 +- docs_build/dev/reports/codex_review.diff | 317 ++++++++++++++++-- src/dev-runtime/persistence/mock-db-store.js | 2 +- .../game-journey-mock-repository.js | 144 +++++++- src/dev-runtime/server/local-api-router.mjs | 7 + .../tools/IdeaBoardTableNotes.spec.mjs | 19 +- 11 files changed, 498 insertions(+), 48 deletions(-) create mode 100644 docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-branch-validation.txt create mode 100644 docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-manual-validation-notes.txt create mode 100644 docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-requirement-checklist.txt create mode 100644 docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-validation-lane.txt create mode 100644 docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap.md diff --git a/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-branch-validation.txt b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-branch-validation.txt new file mode 100644 index 000000000..67ea5a805 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-branch-validation.txt @@ -0,0 +1,6 @@ +Branch Validation: PASS + +PASS - Current branch: pr/26174-ALFA-003-game-hub-journey-bootstrap. +PASS - Stack base: pr/26174-ALFA-002-game-hub-project-intake-display. +PASS - Changes are scoped to Game Journey bootstrap service/repository wiring, mock persistence schema, impacted tests, and required reports. +PASS - No merge to main performed. diff --git a/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-manual-validation-notes.txt b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-manual-validation-notes.txt new file mode 100644 index 000000000..53ff83bc7 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-manual-validation-notes.txt @@ -0,0 +1,6 @@ +Manual Validation Notes: PASS + +PASS - Confirmed createGame response includes journeyBootstrap bucket records with noteKey and itemKey. +PASS - Confirmed Game Journey displays all required starter bucket notes for the created Game Hub project. +PASS - Confirmed source idea Journey item remains present after bootstrap. +PASS - Confirmed bootstrap records are produced by the repository/Local API layer, not browser arrays. diff --git a/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-requirement-checklist.txt b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-requirement-checklist.txt new file mode 100644 index 000000000..de1090b04 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-requirement-checklist.txt @@ -0,0 +1,8 @@ +Requirement Checklist: PASS + +PASS - Journey bootstrap happens through API/service contract. +PASS - Server/API owns authoritative journey record keys. +PASS - Buckets are created in exact required order: Idea, Design, Graphics, Audio, Objects, Worlds, Interface, Controls, Rules, Progression, Play Test, Publish, Share. +PASS - Initial progress placeholders are created for starter buckets. +PASS - No browser-owned journey arrays were added. +PASS - No silent fallbacks were added; bootstrap contract failure throws through Local API. diff --git a/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-validation-lane.txt b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-validation-lane.txt new file mode 100644 index 000000000..9498dc353 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-validation-lane.txt @@ -0,0 +1,8 @@ +Validation Lane: PASS + +Targeted Playwright impacted lane: +PASS - npx playwright test tests/playwright/tools/IdeaBoardTableNotes.spec.mjs + +Notes: +- Full workspace smoke was not run; targeted impacted Playwright validation was used per request. +- User-facing terminology remains Game Hub / Game Journey. diff --git a/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap.md b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap.md new file mode 100644 index 000000000..41f809771 --- /dev/null +++ b/docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap.md @@ -0,0 +1,16 @@ +# PR_26174_ALFA_003-game-hub-journey-bootstrap + +## Purpose + +Create starter Game Journey records when an Idea Board idea becomes a Game Hub project. + +## Summary + +- Added Local API/service contract bootstrap after Game Hub project creation. +- Added server-owned Game Journey starter buckets in the required order with authoritative note and item keys. +- Added placeholder Journey items for each starter bucket and persisted bucket order in the mock database schema. +- Extended impacted Playwright coverage to verify bootstrap response keys, bucket order, Journey display, and Local API persistence. + +## Validation + +PASS - `npx playwright test tests/playwright/tools/IdeaBoardTableNotes.spec.mjs` diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index d9eda44fb..80fd7d5bb 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,8 +1,11 @@ +src/dev-runtime/persistence/mock-db-store.js +src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js +src/dev-runtime/server/local-api-router.mjs tests/playwright/tools/IdeaBoardTableNotes.spec.mjs docs_build/dev/reports/codex_review.diff docs_build/dev/reports/codex_changed_files.txt -docs_build/dev/reports/PR_26174_ALFA_002-game-hub-project-intake-display.md -docs_build/dev/reports/PR_26174_ALFA_002-game-hub-project-intake-display-branch-validation.txt -docs_build/dev/reports/PR_26174_ALFA_002-game-hub-project-intake-display-requirement-checklist.txt -docs_build/dev/reports/PR_26174_ALFA_002-game-hub-project-intake-display-validation-lane.txt -docs_build/dev/reports/PR_26174_ALFA_002-game-hub-project-intake-display-manual-validation-notes.txt +docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap.md +docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-branch-validation.txt +docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-requirement-checklist.txt +docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-validation-lane.txt +docs_build/dev/reports/PR_26174_ALFA_003-game-hub-journey-bootstrap-manual-validation-notes.txt diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index 60f52bb14..5d9a716ac 100644 --- a/docs_build/dev/reports/codex_review.diff +++ b/docs_build/dev/reports/codex_review.diff @@ -1,51 +1,294 @@ +diff --git a/src/dev-runtime/persistence/mock-db-store.js b/src/dev-runtime/persistence/mock-db-store.js +index c98c915b5..e96fb833f 100644 +--- a/src/dev-runtime/persistence/mock-db-store.js ++++ b/src/dev-runtime/persistence/mock-db-store.js +@@ -125,7 +125,7 @@ const MOCK_DB_TABLE_SCHEMAS = Object.freeze({ + input_custom_action_records: Object.freeze(["key", "id", "gameId", "label", "recordOrder", "createdAt", "updatedAt", "createdBy", "updatedBy"]), + game_journey_note_types: Object.freeze(["key", "typeSlug", "name", "seeded", "userExtensible", "createdAt", "updatedAt", "createdBy", "updatedBy"]), + game_journey_completion_metrics: Object.freeze(["key", "bucketKey", "bucketOrder", "bucketName", "friendlyDescription", "requiredForMvp", "canSkip", "plannedCount", "completedCount", "active", "status", "createdAt", "updatedAt", "createdBy", "updatedBy"]), +- game_journey_notes: Object.freeze(["key", "slug", "gameKey", "ownerKey", "name", "typeKey", "createdAt", "updatedAt", "createdBy", "updatedBy"]), ++ game_journey_notes: Object.freeze(["key", "slug", "gameKey", "ownerKey", "name", "typeKey", "bucketOrder", "createdAt", "updatedAt", "createdBy", "updatedBy"]), + game_journey_templates: Object.freeze(["key", "templateSlug", "originalMeaning", "systemGuidance", "linkedToolContexts", "version", "isActive", "createdAt", "updatedAt", "createdBy", "updatedBy"]), + game_journey_items: Object.freeze(["key", "gameKey", "noteKey", "status", "title", "userDetails", "templateKey", "linkedRecordType", "linkedRecordId", "indent", "order", "createdAt", "updatedAt", "createdBy", "updatedBy"]), + game_journey_activity: Object.freeze(["key", "gameKey", "noteKey", "message", "createdAt", "updatedAt", "createdBy", "updatedBy"]), +diff --git a/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js b/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js +index 905b1dba6..a2c831ed1 100644 +--- a/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js ++++ b/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js +@@ -101,6 +101,23 @@ const GENERATED_ULID_SEQUENCE = Object.freeze({ + const RECOMMENDED_TARGET_LINKED_RECORD_TYPE = "recommended-target"; + const RECOMMENDED_TARGET_NOTE_KEY = GAME_JOURNEY_KEYS.notes.designPass; + const SOURCE_IDEA_LINKED_RECORD_TYPE = "source-idea-note"; ++const JOURNEY_BOOTSTRAP_LINKED_RECORD_TYPE = "journey-bootstrap-bucket"; ++ ++export const GAME_JOURNEY_BOOTSTRAP_BUCKETS = Object.freeze([ ++ "Idea", ++ "Design", ++ "Graphics", ++ "Audio", ++ "Objects", ++ "Worlds", ++ "Interface", ++ "Controls", ++ "Rules", ++ "Progression", ++ "Play Test", ++ "Publish", ++ "Share", ++]); + + export const GAME_JOURNEY_STATUSES = [ + { +@@ -708,6 +725,106 @@ export function createGameJourneyMockRepository(options = {}) { + return idea ? `Source Idea: ${idea}` : "Source Idea"; + } + ++ function noteTypeKeyForBootstrapBucket(bucketName) { ++ const slug = slugSegment(bucketName, "task"); ++ const matchingType = tables.game_journey_note_types.find((type) => type.typeSlug === slug); ++ return matchingType?.key || GAME_JOURNEY_KEYS.noteTypes.task; ++ } ++ ++ function ensureJourneyBootstrapBuckets(activeGame) { ++ if (!activeGame) { ++ return { ++ buckets: [], ++ createdItems: 0, ++ createdNotes: 0, ++ }; ++ } ++ ++ const ownerKey = safeCurrentUserKey(); ++ const timestampValue = new Date().toISOString(); ++ let createdNotes = 0; ++ let createdItems = 0; ++ const bucketSummaries = []; ++ GAME_JOURNEY_BOOTSTRAP_BUCKETS.forEach((bucketName, index) => { ++ const bucketOrder = index + 1; ++ const bucketSlug = slugSegment(bucketName, "bucket"); ++ const noteSlug = `journey-bucket-${slugSegment(activeGame.id || activeGame.key)}-${String(bucketOrder).padStart(2, "0")}-${bucketSlug}`; ++ let note = tables.game_journey_notes.find( ++ (candidate) => candidate.gameKey === activeGame.key && candidate.slug === noteSlug, ++ ); ++ ++ if (!note) { ++ note = { ++ key: makeUlid(nextNoteNumber), ++ slug: noteSlug, ++ gameKey: activeGame.key, ++ ownerKey, ++ name: bucketName, ++ typeKey: noteTypeKeyForBootstrapBucket(bucketName), ++ bucketOrder, ++ createdAt: timestampValue, ++ updatedAt: timestampValue, ++ createdBy: ownerKey, ++ updatedBy: ownerKey, ++ }; ++ nextNoteNumber += 1; ++ tables.game_journey_notes.push(note); ++ createdNotes += 1; ++ } ++ ++ const linkedRecordId = `${slugSegment(activeGame.id || activeGame.key)}:${String(bucketOrder).padStart(2, "0")}:${bucketSlug}`; ++ let item = getItemsForNote(note.key).find( ++ (candidate) => ++ candidate.linkedRecordType === JOURNEY_BOOTSTRAP_LINKED_RECORD_TYPE && ++ candidate.linkedRecordId === linkedRecordId, ++ ); ++ ++ if (!item) { ++ item = { ++ key: makeUlid(nextItemNumber), ++ gameKey: activeGame.key, ++ noteKey: note.key, ++ status: "not-started", ++ title: `${bucketName} progress placeholder`, ++ userDetails: "", ++ createdBy: ownerKey, ++ updatedBy: ownerKey, ++ templateKey: "", ++ linkedRecordType: JOURNEY_BOOTSTRAP_LINKED_RECORD_TYPE, ++ linkedRecordId, ++ indent: 0, ++ order: 1, ++ createdAt: timestampValue, ++ updatedAt: timestampValue, ++ }; ++ nextItemNumber += 1; ++ tables.game_journey_items.push(item); ++ createdItems += 1; ++ } ++ ++ bucketSummaries.push({ ++ bucketName, ++ itemKey: item.key, ++ noteKey: note.key, ++ order: bucketOrder, ++ }); ++ }); ++ ++ if (createdNotes || createdItems) { ++ const firstBucket = bucketSummaries[0]; ++ selectedNoteKey = firstBucket?.noteKey || selectedNoteKey; ++ selectedItemKey = firstBucket?.itemKey || selectedItemKey; ++ addActivity(activeGame.key, firstBucket?.noteKey || "", `Created ${GAME_JOURNEY_BOOTSTRAP_BUCKETS.length} Game Journey starter buckets.`, ownerKey); ++ persistTables(); ++ } ++ ++ return { ++ buckets: bucketSummaries, ++ createdItems, ++ createdNotes, ++ }; ++ } ++ + function ensureSourceIdeaJourneyItems(activeGame) { + const sourceIdea = activeGame?.sourceIdea && typeof activeGame.sourceIdea === "object" + ? activeGame.sourceIdea +@@ -1039,7 +1156,11 @@ export function createGameJourneyMockRepository(options = {}) { + .filter(currentUserCanSeeNote) + .filter((note) => noteMatchesFilter(note, filterId)) + .map((note) => hydrateNote(note, filterId)) +- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); ++ .sort((left, right) => { ++ const leftOrder = Number.isFinite(Number(left.bucketOrder)) ? Number(left.bucketOrder) : Number.POSITIVE_INFINITY; ++ const rightOrder = Number.isFinite(Number(right.bucketOrder)) ? Number(right.bucketOrder) : Number.POSITIVE_INFINITY; ++ return leftOrder - rightOrder || right.updatedAt.localeCompare(left.updatedAt); ++ }); + } + + function addNote({ name, typeKey } = {}) { +@@ -1500,11 +1621,29 @@ export function createGameJourneyMockRepository(options = {}) { + gameId === GAME_JOURNEY_KEYS.game ? GAME_JOURNEY_ROUTE_GAME_ALIAS : gameId; + const openedGame = gameWorkspaceRepository.openGame(workspaceGameId); + if (openedGame) { +- ensureSourceIdeaJourneyItems(getActiveGame()); ++ bootstrapGameJourneyForGame(getActiveGame()); + } + return openedGame; + } + ++ function bootstrapGameJourneyForGame(game = getActiveGame()) { ++ const activeGame = game?.key ? game : game ? { ...game, key: journeyGameKey(game) } : getActiveGame(); ++ if (!activeGame) { ++ return { ++ buckets: [], ++ createdItems: 0, ++ createdNotes: 0, ++ sourceIdeaItems: [], ++ }; ++ } ++ const bucketResult = ensureJourneyBootstrapBuckets(activeGame); ++ const sourceIdeaItems = ensureSourceIdeaJourneyItems(activeGame); ++ return { ++ ...bucketResult, ++ sourceIdeaItems, ++ }; ++ } ++ + return { + getTables: async () => clone({ + game_journey_completion_metrics: await completionMetricsStore.listMetrics(), +@@ -1519,6 +1658,7 @@ export function createGameJourneyMockRepository(options = {}) { + getSystemUser: () => getMockDbSystemUser(), + getActiveGame, + openGame, ++ bootstrapGameJourneyForGame, + clearActiveGame: () => gameWorkspaceRepository.clearTestData(), + listNoteTypes: () => clone(tables.game_journey_note_types), + addNoteType, +diff --git a/src/dev-runtime/server/local-api-router.mjs b/src/dev-runtime/server/local-api-router.mjs +index 314a35d03..577375844 100644 +--- a/src/dev-runtime/server/local-api-router.mjs ++++ b/src/dev-runtime/server/local-api-router.mjs +@@ -5306,6 +5306,13 @@ LIMIT 1; + } + const result = await method(...args); + assertRepositoryMethodResult(repositoryId, methodName, result); ++ if (repository === this.gameWorkspaceRepository && methodName === "createGame") { ++ const journeyBootstrap = this.gameJourneyRepository.bootstrapGameJourneyForGame(result); ++ if (!journeyBootstrap || !Array.isArray(journeyBootstrap.buckets)) { ++ throw repositoryMethodError("Game Journey bootstrap did not return starter bucket records. Restore the Local API/service contract."); ++ } ++ result.journeyBootstrap = journeyBootstrap; ++ } + const methodPersistsThroughToolStore = + repository === this.gameJourneyRepository && GAME_JOURNEY_TOOL_STORE_METHODS.has(methodName); + if (repositoryMethodRequiresPersistence(methodName) && !methodPersistsThroughToolStore) { diff --git a/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs b/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs -index b2872b432..1372df8be 100644 +index 1372df8be..5d0ccfaff 100644 --- a/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs +++ b/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs -@@ -136,6 +136,7 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { +@@ -1,4 +1,5 @@ + import { expect, test } from "@playwright/test"; ++import { GAME_JOURNEY_BOOTSTRAP_BUCKETS } from "../../../src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js"; + import { MOCK_DB_KEYS } from "../../../src/dev-runtime/persistence/mock-db-store.js"; + import { isBrowserExtensionNoise } from "../../helpers/browserExtensionNoise.mjs"; + import { createGameJourneyCompletionMetricsPostgresClientStub } from "../../helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs"; +@@ -136,10 +137,15 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { const consoleErrors = []; const mutatingApiRequests = []; const createGamePayloads = []; -+ const gameHubRepositoryRequests = []; ++ const createGameResponsePromises = []; + const gameHubRepositoryRequests = []; page.on("response", (response) => { if (response.status() >= 400) failedRequests.push(`${response.status()} ${response.url()}`); -@@ -156,6 +157,9 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { - if (requestUrl.includes("/api/toolbox/game-hub/repositories/") && requestUrl.includes("/methods/createGame")) { - createGamePayloads.push(request.postDataJSON()); - } -+ if (requestUrl.includes("/api/toolbox/game-hub/repositories/")) { -+ gameHubRepositoryRequests.push(`${request.method()} ${requestUrl}`); ++ const responseUrl = response.url(); ++ if (responseUrl.includes("/api/toolbox/game-hub/repositories/") && responseUrl.includes("/methods/createGame")) { ++ createGameResponsePromises.push(response.json()); + } }); - - try { -@@ -372,9 +376,19 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { - await page.waitForURL(/\/toolbox\/game-hub\/index\.html\?game=lantern-reef-\d+$/); - await expect(page.getByRole("heading", { level: 1, name: "Game Hub" })).toBeVisible(); - await expect(page.locator("[data-active-game-name]")).toHaveText("Lantern Reef"); -+ await expect(page.locator("[data-game-list]")).toContainText("Lantern Reef"); -+ await expect(page.locator("[data-source-idea-display]")).toHaveText("Lantern Reef"); -+ await expect(page.locator("[data-source-idea-pitch]")).toHaveText("Guide light through a reef that rearranges at dusk."); -+ await expect(page.locator("[data-source-idea-notes]")).toContainText("Use dusk tide changes as the first Game Hub planning note."); -+ await expect(page.locator("[data-source-idea-section] :is(input, textarea, select, button)")).toHaveCount(0); -+ await expect(page.getByRole("button", { name: "Delete Open Game" })).toHaveCount(0); -+ await page.reload({ waitUntil: "networkidle" }); -+ await expect(page.locator("[data-active-game-name]")).toHaveText("Lantern Reef"); -+ await expect(page.locator("[data-game-list]")).toContainText("Lantern Reef"); - await expect(page.locator("[data-source-idea-display]")).toHaveText("Lantern Reef"); - await expect(page.locator("[data-source-idea-pitch]")).toHaveText("Guide light through a reef that rearranges at dusk."); - await expect(page.locator("[data-source-idea-notes]")).toContainText("Use dusk tide changes as the first Game Hub planning note."); -+ await expect(page.locator("[data-source-idea-section] :is(input, textarea, select, button)")).toHaveCount(0); - await expect(page.getByRole("button", { name: "Delete Open Game" })).toHaveCount(0); - await expect(page.locator("main")).not.toContainText(/\bproject records\b|\bAPI\b|\bDB\b|\bmock\b|\bseed\b|\bdebug\b|\binternal\b/i); + page.on("requestfailed", (request) => failedRequests.push(`FAILED ${request.url()}`)); + page.on("pageerror", (error) => { +@@ -363,6 +369,10 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { + }, + status: "Planning", + }); ++ const [createGameResponse] = await Promise.all(createGameResponsePromises); ++ const createdProject = createGameResponse?.data?.result; ++ expect(createdProject?.journeyBootstrap?.buckets.map((bucket) => bucket.bucketName)).toEqual(GAME_JOURNEY_BOOTSTRAP_BUCKETS); ++ expect(createdProject?.journeyBootstrap?.buckets.every((bucket) => bucket.noteKey && bucket.itemKey)).toBe(true); + await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='archive']").click(); + await expect(page.locator("[data-idea-board-idea-row='lantern-reef']")).toHaveCount(0); + await page.locator("[data-idea-board-status-filter-option][value='Archived']").check(); +@@ -394,8 +404,14 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { await page.getByRole("link", { name: "Open Game Journey" }).click(); -@@ -386,6 +400,8 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { + await page.waitForURL(/\/toolbox\/game-journey\/index\.html\?game=lantern-reef-\d+$/); + await expect(page.locator("[data-journey-active-game]")).toHaveText("Active game: Lantern Reef."); ++ const journeyNoteNames = await page.locator("[data-journey-summary-body] [data-journey-note-button]").evaluateAll((buttons) => ( ++ buttons.map((button) => button.textContent.trim()) ++ )); ++ const journeyBucketNames = journeyNoteNames.filter((name) => GAME_JOURNEY_BOOTSTRAP_BUCKETS.includes(name)); ++ expect(journeyBucketNames).toEqual(GAME_JOURNEY_BOOTSTRAP_BUCKETS); + await expect(page.locator("[data-journey-summary-body]")).toContainText("Source Idea: Lantern Reef"); + await expect(page.locator("[data-journey-summary-body]")).toContainText("10000011"); ++ await expect(page.locator("[data-journey-recent-activity]")).toContainText("Created 13 Game Journey starter buckets."); + await expect(page.locator("[data-journey-recent-activity]")).toContainText("Created 1 Game Journey item from Source Idea."); expect(mutatingApiRequests.some((request) => request.includes("/api/toolbox/game-hub/repositories"))).toBe(true); - expect(mutatingApiRequests.some((request) => request.includes("/methods/createGame"))).toBe(true); -+ expect(gameHubRepositoryRequests.some((request) => request.includes("/methods/openGame"))).toBe(true); -+ expect(gameHubRepositoryRequests.some((request) => request.includes("/methods/listGames"))).toBe(true); - expect(failedRequests).toEqual([]); - expect(pageErrors).toEqual([]); - expect(consoleErrors).toEqual([]); +@@ -417,7 +433,6 @@ test("Idea Board guest Create Project redirects to sign in without creating a pr + const server = await startRepoServer(); + const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; + const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; +- const previousMetricsDbPath = process.env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH; + const previousSupabaseEnv = { + GAMEFOUNDRY_DATABASE_URL: process.env.GAMEFOUNDRY_DATABASE_URL, + GAMEFOUNDRY_SUPABASE_ANON_KEY: process.env.GAMEFOUNDRY_SUPABASE_ANON_KEY, +@@ -427,7 +442,6 @@ test("Idea Board guest Create Project redirects to sign in without creating a pr + process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; + process.env.GAMEFOUNDRY_SITE_URL = server.baseUrl; + process.env.GAMEFOUNDRY_DATABASE_URL = "postgres://idea-board:test@127.0.0.1:5432/idea_board"; +- process.env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH = `tmp/test-results/idea-board-${process.pid}-${Date.now()}.sqlite`; + process.env.GAMEFOUNDRY_SUPABASE_ANON_KEY = "idea-board-anon-key"; + process.env.GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY = "idea-board-service-role-key"; + process.env.GAMEFOUNDRY_SUPABASE_URL = `${server.baseUrl}/fake-supabase`; +@@ -479,7 +493,6 @@ test("Idea Board guest Create Project redirects to sign in without creating a pr + } finally { + restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); + restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); +- restoreEnvValue("GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH", previousMetricsDbPath); + Object.entries(previousSupabaseEnv).forEach(([key, value]) => restoreEnvValue(key, value)); + await server.close(); + } diff --git a/src/dev-runtime/persistence/mock-db-store.js b/src/dev-runtime/persistence/mock-db-store.js index c98c915b5..e96fb833f 100644 --- a/src/dev-runtime/persistence/mock-db-store.js +++ b/src/dev-runtime/persistence/mock-db-store.js @@ -125,7 +125,7 @@ const MOCK_DB_TABLE_SCHEMAS = Object.freeze({ input_custom_action_records: Object.freeze(["key", "id", "gameId", "label", "recordOrder", "createdAt", "updatedAt", "createdBy", "updatedBy"]), game_journey_note_types: Object.freeze(["key", "typeSlug", "name", "seeded", "userExtensible", "createdAt", "updatedAt", "createdBy", "updatedBy"]), game_journey_completion_metrics: Object.freeze(["key", "bucketKey", "bucketOrder", "bucketName", "friendlyDescription", "requiredForMvp", "canSkip", "plannedCount", "completedCount", "active", "status", "createdAt", "updatedAt", "createdBy", "updatedBy"]), - game_journey_notes: Object.freeze(["key", "slug", "gameKey", "ownerKey", "name", "typeKey", "createdAt", "updatedAt", "createdBy", "updatedBy"]), + game_journey_notes: Object.freeze(["key", "slug", "gameKey", "ownerKey", "name", "typeKey", "bucketOrder", "createdAt", "updatedAt", "createdBy", "updatedBy"]), game_journey_templates: Object.freeze(["key", "templateSlug", "originalMeaning", "systemGuidance", "linkedToolContexts", "version", "isActive", "createdAt", "updatedAt", "createdBy", "updatedBy"]), game_journey_items: Object.freeze(["key", "gameKey", "noteKey", "status", "title", "userDetails", "templateKey", "linkedRecordType", "linkedRecordId", "indent", "order", "createdAt", "updatedAt", "createdBy", "updatedBy"]), game_journey_activity: Object.freeze(["key", "gameKey", "noteKey", "message", "createdAt", "updatedAt", "createdBy", "updatedBy"]), diff --git a/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js b/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js index 905b1dba6..a2c831ed1 100644 --- a/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js +++ b/src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js @@ -101,6 +101,23 @@ const GENERATED_ULID_SEQUENCE = Object.freeze({ const RECOMMENDED_TARGET_LINKED_RECORD_TYPE = "recommended-target"; const RECOMMENDED_TARGET_NOTE_KEY = GAME_JOURNEY_KEYS.notes.designPass; const SOURCE_IDEA_LINKED_RECORD_TYPE = "source-idea-note"; +const JOURNEY_BOOTSTRAP_LINKED_RECORD_TYPE = "journey-bootstrap-bucket"; + +export const GAME_JOURNEY_BOOTSTRAP_BUCKETS = Object.freeze([ + "Idea", + "Design", + "Graphics", + "Audio", + "Objects", + "Worlds", + "Interface", + "Controls", + "Rules", + "Progression", + "Play Test", + "Publish", + "Share", +]); export const GAME_JOURNEY_STATUSES = [ { @@ -708,6 +725,106 @@ export function createGameJourneyMockRepository(options = {}) { return idea ? `Source Idea: ${idea}` : "Source Idea"; } + function noteTypeKeyForBootstrapBucket(bucketName) { + const slug = slugSegment(bucketName, "task"); + const matchingType = tables.game_journey_note_types.find((type) => type.typeSlug === slug); + return matchingType?.key || GAME_JOURNEY_KEYS.noteTypes.task; + } + + function ensureJourneyBootstrapBuckets(activeGame) { + if (!activeGame) { + return { + buckets: [], + createdItems: 0, + createdNotes: 0, + }; + } + + const ownerKey = safeCurrentUserKey(); + const timestampValue = new Date().toISOString(); + let createdNotes = 0; + let createdItems = 0; + const bucketSummaries = []; + GAME_JOURNEY_BOOTSTRAP_BUCKETS.forEach((bucketName, index) => { + const bucketOrder = index + 1; + const bucketSlug = slugSegment(bucketName, "bucket"); + const noteSlug = `journey-bucket-${slugSegment(activeGame.id || activeGame.key)}-${String(bucketOrder).padStart(2, "0")}-${bucketSlug}`; + let note = tables.game_journey_notes.find( + (candidate) => candidate.gameKey === activeGame.key && candidate.slug === noteSlug, + ); + + if (!note) { + note = { + key: makeUlid(nextNoteNumber), + slug: noteSlug, + gameKey: activeGame.key, + ownerKey, + name: bucketName, + typeKey: noteTypeKeyForBootstrapBucket(bucketName), + bucketOrder, + createdAt: timestampValue, + updatedAt: timestampValue, + createdBy: ownerKey, + updatedBy: ownerKey, + }; + nextNoteNumber += 1; + tables.game_journey_notes.push(note); + createdNotes += 1; + } + + const linkedRecordId = `${slugSegment(activeGame.id || activeGame.key)}:${String(bucketOrder).padStart(2, "0")}:${bucketSlug}`; + let item = getItemsForNote(note.key).find( + (candidate) => + candidate.linkedRecordType === JOURNEY_BOOTSTRAP_LINKED_RECORD_TYPE && + candidate.linkedRecordId === linkedRecordId, + ); + + if (!item) { + item = { + key: makeUlid(nextItemNumber), + gameKey: activeGame.key, + noteKey: note.key, + status: "not-started", + title: `${bucketName} progress placeholder`, + userDetails: "", + createdBy: ownerKey, + updatedBy: ownerKey, + templateKey: "", + linkedRecordType: JOURNEY_BOOTSTRAP_LINKED_RECORD_TYPE, + linkedRecordId, + indent: 0, + order: 1, + createdAt: timestampValue, + updatedAt: timestampValue, + }; + nextItemNumber += 1; + tables.game_journey_items.push(item); + createdItems += 1; + } + + bucketSummaries.push({ + bucketName, + itemKey: item.key, + noteKey: note.key, + order: bucketOrder, + }); + }); + + if (createdNotes || createdItems) { + const firstBucket = bucketSummaries[0]; + selectedNoteKey = firstBucket?.noteKey || selectedNoteKey; + selectedItemKey = firstBucket?.itemKey || selectedItemKey; + addActivity(activeGame.key, firstBucket?.noteKey || "", `Created ${GAME_JOURNEY_BOOTSTRAP_BUCKETS.length} Game Journey starter buckets.`, ownerKey); + persistTables(); + } + + return { + buckets: bucketSummaries, + createdItems, + createdNotes, + }; + } + function ensureSourceIdeaJourneyItems(activeGame) { const sourceIdea = activeGame?.sourceIdea && typeof activeGame.sourceIdea === "object" ? activeGame.sourceIdea @@ -1039,7 +1156,11 @@ export function createGameJourneyMockRepository(options = {}) { .filter(currentUserCanSeeNote) .filter((note) => noteMatchesFilter(note, filterId)) .map((note) => hydrateNote(note, filterId)) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + .sort((left, right) => { + const leftOrder = Number.isFinite(Number(left.bucketOrder)) ? Number(left.bucketOrder) : Number.POSITIVE_INFINITY; + const rightOrder = Number.isFinite(Number(right.bucketOrder)) ? Number(right.bucketOrder) : Number.POSITIVE_INFINITY; + return leftOrder - rightOrder || right.updatedAt.localeCompare(left.updatedAt); + }); } function addNote({ name, typeKey } = {}) { @@ -1500,11 +1621,29 @@ export function createGameJourneyMockRepository(options = {}) { gameId === GAME_JOURNEY_KEYS.game ? GAME_JOURNEY_ROUTE_GAME_ALIAS : gameId; const openedGame = gameWorkspaceRepository.openGame(workspaceGameId); if (openedGame) { - ensureSourceIdeaJourneyItems(getActiveGame()); + bootstrapGameJourneyForGame(getActiveGame()); } return openedGame; } + function bootstrapGameJourneyForGame(game = getActiveGame()) { + const activeGame = game?.key ? game : game ? { ...game, key: journeyGameKey(game) } : getActiveGame(); + if (!activeGame) { + return { + buckets: [], + createdItems: 0, + createdNotes: 0, + sourceIdeaItems: [], + }; + } + const bucketResult = ensureJourneyBootstrapBuckets(activeGame); + const sourceIdeaItems = ensureSourceIdeaJourneyItems(activeGame); + return { + ...bucketResult, + sourceIdeaItems, + }; + } + return { getTables: async () => clone({ game_journey_completion_metrics: await completionMetricsStore.listMetrics(), @@ -1519,6 +1658,7 @@ export function createGameJourneyMockRepository(options = {}) { getSystemUser: () => getMockDbSystemUser(), getActiveGame, openGame, + bootstrapGameJourneyForGame, clearActiveGame: () => gameWorkspaceRepository.clearTestData(), listNoteTypes: () => clone(tables.game_journey_note_types), addNoteType, diff --git a/src/dev-runtime/server/local-api-router.mjs b/src/dev-runtime/server/local-api-router.mjs index 314a35d03..577375844 100644 --- a/src/dev-runtime/server/local-api-router.mjs +++ b/src/dev-runtime/server/local-api-router.mjs @@ -5306,6 +5306,13 @@ LIMIT 1; } const result = await method(...args); assertRepositoryMethodResult(repositoryId, methodName, result); + if (repository === this.gameWorkspaceRepository && methodName === "createGame") { + const journeyBootstrap = this.gameJourneyRepository.bootstrapGameJourneyForGame(result); + if (!journeyBootstrap || !Array.isArray(journeyBootstrap.buckets)) { + throw repositoryMethodError("Game Journey bootstrap did not return starter bucket records. Restore the Local API/service contract."); + } + result.journeyBootstrap = journeyBootstrap; + } const methodPersistsThroughToolStore = repository === this.gameJourneyRepository && GAME_JOURNEY_TOOL_STORE_METHODS.has(methodName); if (repositoryMethodRequiresPersistence(methodName) && !methodPersistsThroughToolStore) { diff --git a/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs b/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs index 1372df8be..5d0ccfaff 100644 --- a/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs +++ b/tests/playwright/tools/IdeaBoardTableNotes.spec.mjs @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { GAME_JOURNEY_BOOTSTRAP_BUCKETS } from "../../../src/dev-runtime/persistence/tool-repositories/game-journey-mock-repository.js"; import { MOCK_DB_KEYS } from "../../../src/dev-runtime/persistence/mock-db-store.js"; import { isBrowserExtensionNoise } from "../../helpers/browserExtensionNoise.mjs"; import { createGameJourneyCompletionMetricsPostgresClientStub } from "../../helpers/gameJourneyCompletionMetricsPostgresClientStub.mjs"; @@ -136,10 +137,15 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { const consoleErrors = []; const mutatingApiRequests = []; const createGamePayloads = []; + const createGameResponsePromises = []; const gameHubRepositoryRequests = []; page.on("response", (response) => { if (response.status() >= 400) failedRequests.push(`${response.status()} ${response.url()}`); + const responseUrl = response.url(); + if (responseUrl.includes("/api/toolbox/game-hub/repositories/") && responseUrl.includes("/methods/createGame")) { + createGameResponsePromises.push(response.json()); + } }); page.on("requestfailed", (request) => failedRequests.push(`FAILED ${request.url()}`)); page.on("pageerror", (error) => { @@ -363,6 +369,10 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { }, status: "Planning", }); + const [createGameResponse] = await Promise.all(createGameResponsePromises); + const createdProject = createGameResponse?.data?.result; + expect(createdProject?.journeyBootstrap?.buckets.map((bucket) => bucket.bucketName)).toEqual(GAME_JOURNEY_BOOTSTRAP_BUCKETS); + expect(createdProject?.journeyBootstrap?.buckets.every((bucket) => bucket.noteKey && bucket.itemKey)).toBe(true); await page.locator("[data-idea-board-idea-row='lantern-reef'] [data-idea-board-idea-action='archive']").click(); await expect(page.locator("[data-idea-board-idea-row='lantern-reef']")).toHaveCount(0); await page.locator("[data-idea-board-status-filter-option][value='Archived']").check(); @@ -394,8 +404,14 @@ test("Idea Board uses accordion table ideas and notes", async ({ page }) => { await page.getByRole("link", { name: "Open Game Journey" }).click(); await page.waitForURL(/\/toolbox\/game-journey\/index\.html\?game=lantern-reef-\d+$/); await expect(page.locator("[data-journey-active-game]")).toHaveText("Active game: Lantern Reef."); + const journeyNoteNames = await page.locator("[data-journey-summary-body] [data-journey-note-button]").evaluateAll((buttons) => ( + buttons.map((button) => button.textContent.trim()) + )); + const journeyBucketNames = journeyNoteNames.filter((name) => GAME_JOURNEY_BOOTSTRAP_BUCKETS.includes(name)); + expect(journeyBucketNames).toEqual(GAME_JOURNEY_BOOTSTRAP_BUCKETS); await expect(page.locator("[data-journey-summary-body]")).toContainText("Source Idea: Lantern Reef"); await expect(page.locator("[data-journey-summary-body]")).toContainText("10000011"); + await expect(page.locator("[data-journey-recent-activity]")).toContainText("Created 13 Game Journey starter buckets."); await expect(page.locator("[data-journey-recent-activity]")).toContainText("Created 1 Game Journey item from Source Idea."); expect(mutatingApiRequests.some((request) => request.includes("/api/toolbox/game-hub/repositories"))).toBe(true); @@ -417,7 +433,6 @@ test("Idea Board guest Create Project redirects to sign in without creating a pr const server = await startRepoServer(); const previousApiUrl = process.env.GAMEFOUNDRY_API_URL; const previousSiteUrl = process.env.GAMEFOUNDRY_SITE_URL; - const previousMetricsDbPath = process.env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH; const previousSupabaseEnv = { GAMEFOUNDRY_DATABASE_URL: process.env.GAMEFOUNDRY_DATABASE_URL, GAMEFOUNDRY_SUPABASE_ANON_KEY: process.env.GAMEFOUNDRY_SUPABASE_ANON_KEY, @@ -427,7 +442,6 @@ test("Idea Board guest Create Project redirects to sign in without creating a pr process.env.GAMEFOUNDRY_API_URL = `${server.baseUrl}/api`; process.env.GAMEFOUNDRY_SITE_URL = server.baseUrl; process.env.GAMEFOUNDRY_DATABASE_URL = "postgres://idea-board:test@127.0.0.1:5432/idea_board"; - process.env.GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH = `tmp/test-results/idea-board-${process.pid}-${Date.now()}.sqlite`; process.env.GAMEFOUNDRY_SUPABASE_ANON_KEY = "idea-board-anon-key"; process.env.GAMEFOUNDRY_SUPABASE_SERVICE_ROLE_KEY = "idea-board-service-role-key"; process.env.GAMEFOUNDRY_SUPABASE_URL = `${server.baseUrl}/fake-supabase`; @@ -479,7 +493,6 @@ test("Idea Board guest Create Project redirects to sign in without creating a pr } finally { restoreEnvValue("GAMEFOUNDRY_API_URL", previousApiUrl); restoreEnvValue("GAMEFOUNDRY_SITE_URL", previousSiteUrl); - restoreEnvValue("GAMEFOUNDRY_GAME_JOURNEY_METRICS_DB_PATH", previousMetricsDbPath); Object.entries(previousSupabaseEnv).forEach(([key, value]) => restoreEnvValue(key, value)); await server.close(); }