Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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`
13 changes: 8 additions & 5 deletions docs_build/dev/reports/codex_changed_files.txt
Original file line number Diff line number Diff line change
@@ -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
317 changes: 280 additions & 37 deletions docs_build/dev/reports/codex_review.diff

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/dev-runtime/persistence/mock-db-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 } = {}) {
Expand Down Expand Up @@ -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(),
Expand All @@ -1519,6 +1658,7 @@ export function createGameJourneyMockRepository(options = {}) {
getSystemUser: () => getMockDbSystemUser(),
getActiveGame,
openGame,
bootstrapGameJourneyForGame,
clearActiveGame: () => gameWorkspaceRepository.clearTestData(),
listNoteTypes: () => clone(tables.game_journey_note_types),
addNoteType,
Expand Down
7 changes: 7 additions & 0 deletions src/dev-runtime/server/local-api-router.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 16 additions & 3 deletions tests/playwright/tools/IdeaBoardTableNotes.spec.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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`;
Expand Down Expand Up @@ -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();
}
Expand Down
Loading