diff --git a/docs/Concepts.md b/docs/Concepts.md index 35e234a..970a765 100644 --- a/docs/Concepts.md +++ b/docs/Concepts.md @@ -328,7 +328,7 @@ Used for: Save slots store: -- `slotKey`: Unique identifier -- `date`: Unix timestamp +- `slotId`: Unique identifier +- `savedAt`: Unix timestamp - `image`: Screenshot (base64) - `state`: Serialized game state diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index 3366115..494c3a6 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -406,9 +406,21 @@ Seen-line semantics: ### Save System Actions -| Action | Payload | Description | -| ----------------- | --------------------------------- | ----------------- | -| `replaceSaveSlot` | `{ slotKey, date, image, state }` | Save game to slot | +| Action | Payload | Description | +| -------------- | --------------------------------- | -------------------------- | +| `saveSlot` | `{ slotId, thumbnailImage? }` | Save game to a slot | +| `loadSlot` | `{ slotId }` | Load game from a slot | + +Save/load design, requirements, and storage boundaries are documented in [SaveLoad.md](./SaveLoad.md). + +Notes: + +- `slotId` is the public action field; storage stringification is internal +- save/load UIs can bind `slotId` directly from layout templates such as `${slot.slotId}` +- if slot identity comes from event data, use `_event.*` bindings such as `slotId: "_event.slotId"` +- example save/load UI copy should stay terse; prefer short labels like `Save`, `Load`, `Page 1`, `Saved`, `Empty`, and `Image` +- `thumbnailImage` is integration-provided; the engine does not capture screenshots by itself +- if a save action appears inside a multi-action event payload, the host should prepare/augment the `actions` object and still call `handleActions(...)` once for the whole batch ### Effect Actions @@ -433,8 +445,9 @@ The system store exposes these selectors (called internally): | `selectIsLineViewed` | `{ sectionId, lineId }` | Boolean | | `selectIsResourceViewed` | `{ resourceId }` | Boolean | | `selectNextLineConfig` | - | Config object | -| `selectSaveSlots` | - | Save slots object | -| `selectSaveSlot` | `{ slotKey }` | Save slot data | +| `selectSaveSlotMap` | - | Save slots object map | +| `selectSaveSlot` | `{ slotId }` | Save slot data | +| `selectSaveSlotPage` | `{ slotsPerPage? }` | Paged save slot list for UI | ## Pending Effects diff --git a/docs/SaveLoad.md b/docs/SaveLoad.md new file mode 100644 index 0000000..a3e143d --- /dev/null +++ b/docs/SaveLoad.md @@ -0,0 +1,555 @@ +# Save/Load Design + +This document defines the intended product behavior, engine interfaces, and implementation boundaries for save/load in `route-engine`. + +It is a design and requirements document, not a guarantee that the current implementation already matches every rule below. + +## Purpose + +Save/load lets the player persist a playable story state into a slot and later restore that story state from the slot. + +In `route-engine`, save/load is separate from: + +- rollback +- dialogue history +- persistent global variables +- renderer/transient runtime state + +Save/load should restore the story to a coherent playable point, not resume every temporary UI or timer detail from the moment the save was made. + +## Product Summary + +The product model for save/load is: + +- save slots store story-local runtime state +- save slots restore a coherent playable reading position +- rollback timeline is part of saved story state and must survive save/load +- viewed/seen registry is saved and restored +- persistent global variables are not part of slot state +- transient runtime globals are not part of slot state +- loading a slot must reinitialize transient runtime globals instead of inheriting stale values from the pre-load session +- loading a missing or malformed slot must fail safely +- persistence to browser storage is a side effect handled outside the store + +## Terminology + +### Save Slot + +A save slot is a named/indexed container with: + +- slot metadata for UI +- saved story state for restoration + +### Saved Story State + +Saved story state is the subset of runtime state needed to resume the story coherently. + +For the current model, that means: + +- `contexts` +- `viewedRegistry` + +Rollback data lives inside context state and is therefore part of saved story state. + +### Persistent Global Variables + +Persistent global variables are variables with scope: + +- `global-device` +- `global-account` + +These are not story-local and should not be stored inside save slot state. + +They persist through their own storage path. + +### Transient Runtime State + +Transient runtime state is temporary engine/UI state that should be recreated or reset on load rather than serialized into a slot. + +Examples: + +- `autoMode` +- `skipMode` +- `dialogueUIHidden` +- `isDialogueHistoryShowing` +- `nextLineConfig` +- `layeredViews` +- `isLineCompleted` +- `pendingEffects` +- live timer callbacks and in-flight timing state + +## User-Facing Requirements + +### Saving + +When the player saves: + +- the current story position is captured +- the current story-local variables are captured +- the current rollback timeline/cursor is captured +- the viewed registry is captured +- the slot thumbnail/preview metadata is stored +- the slot becomes available immediately in save/load UI + +### Loading + +When the player loads: + +- the engine returns to the saved story position +- saved story-local variables are restored +- saved rollback ability is restored +- seen/viewed registry is restored from the slot +- transient runtime state is reinitialized to clean defaults +- the result is a coherent playable state, not a hybrid of pre-load and post-load runtime state + +## Product Decisions + +### What save slots must include + +Save slots must include: + +- current `contexts` +- `global.viewedRegistry` +- rollback timeline/cursor inside each saved context +- slot metadata: + - `slotId` + - `savedAt` + - `image` + +### What save slots must not include + +Save slots must not include: + +- `projectData` +- persistent/global variables +- current render/presentation snapshots +- transient runtime globals +- pending effects +- live timer state + +Rationale: + +- `projectData` is application input, not runtime save state +- persistent globals have their own lifetime and persistence rules +- transient runtime globals should not leak across load boundaries +- renderer state should be reconstructed from restored story state + +### Load must reinitialize transient runtime globals + +Loading a slot must explicitly reset transient globals to the same clean runtime baseline expected for a playable state. + +That includes resetting: + +- `autoMode` +- `skipMode` +- `dialogueUIHidden` +- `isDialogueHistoryShowing` +- `nextLineConfig` +- `layeredViews` +- `isLineCompleted` + +It also includes clearing runtime timers/effects that belong to the prior live session. + +This is a product decision, not an implementation detail. + +### Persistent globals stay outside save slots + +`global-device` and `global-account` variables are intentionally not saved into slots. + +Load should not roll them back or replace them from slot data. + +Rationale: + +- they are not local branch state +- they are meant to persist across saves, playthroughs, and sessions + +### Rollback must survive save/load + +Rollback is part of the player's current story state. + +Therefore: + +- save must serialize rollback timeline/cursor inside context state +- load must restore that rollback data +- older saves without rollback data may be normalized into a minimal rollback timeline anchored at the loaded pointer + +See [Rollback.md](./Rollback.md). + +### Missing or malformed saves must fail safely + +If a requested slot is missing: + +- load should leave state unchanged + +If slot data is malformed: + +- load must not partially corrupt the engine state +- load should either: + - leave state unchanged, or + - normalize the data into a minimal valid playable state + +Partial application into an invalid runtime shape is not acceptable. + +## Interfaces + +### Store Actions + +Current store actions are: + +- `saveSlot({ slotId, thumbnailImage?, savedAt? })` +- `loadSlot({ slotId })` + +Notes: + +- `thumbnailImage` is UI/host-provided preview data +- `slotId` is the public action field +- storage still uses a stringified object key internally, but that is not part of the authored API +- compatibility aliases still exist in code for `saveSaveSlot({ slot, thumbnailImage?, date? })` and `loadSaveSlot({ slot })` + +### Store Selectors + +Current save/load-related selectors are: + +- `selectSaveSlotMap()` +- `selectSaveSlot({ slotId })` +- `selectSaveSlotPage({ slotsPerPage? })` + +`selectSaveSlotPage` is a UI helper for paginated save/load screens. It flattens the current page into slot UI items based on the `loadPage` variable. + +### Effects + +The save/load path crosses the store boundary through effects: + +- `saveSlots` +- `saveGlobalDeviceVariables` +- `saveGlobalAccountVariables` + +Current behavior: + +- `saveSlot` mutates `state.global.saveSlots` +- then it emits a `saveSlots` effect +- the effect handler persists the full slot map to `localStorage` + +Load is different: + +- `loadSlot` only restores in-memory engine state from `state.global.saveSlots` +- it does not read `localStorage` itself + +### Dynamic Slot Selection + +The engine contract is based on action payload `slotId`. + +The store stringifies that internally for map lookup, but authored/integration payloads should target `slotId`. + +Current supported patterns: + +```yaml +# Static slot binding +click: + payload: + actions: + saveSlot: + slotId: 1 +``` + +```yaml +# Template-time slot binding from saveSlots selector data +click: + payload: + actions: + loadSlot: + slotId: ${slot.slotId} +``` + +```yaml +# Event-time slot binding through Route Graphics event data +click: + payload: + _event: + slotId: 3 + actions: + saveSlot: + slotId: "_event.slotId" +``` + +Important details: + +- `_event` is the only supported event-context key at the engine/template layer +- `"_event.*"` bindings are resolved before jempl interpolation +- unresolved `"_event.*"` bindings fail fast +- the Route Graphics bridge also accepts `event` and normalizes it into `_event` + +Current recommendation: + +- if the slot identity is known when the element is rendered, bind it directly in the payload +- use `_event` only when the event source itself determines the slot dynamically +- keep example UI copy terse; prefer short labels such as `Save`, `Load`, `Page 1`, `Saved`, `Empty`, and `Image` + +For save/load grids rendered from `saveSlots`, direct template binding is the clearer default: + +- `slotId: ${slot.slotId}` + +Open design note: + +- whether save/load UI should standardize on direct/template slot binding or formalize `_event`-driven slot routing as a first-class pattern is still an explicit product decision to revisit + +### Thumbnail Capture From Environment + +The core engine does not capture screenshots itself. + +`saveSlot` simply accepts `thumbnailImage` if the host/integration provides one and stores it as slot `image`. + +Current VT/browser harness behavior: + +1. intercept Route Graphics event payloads before action dispatch +2. detect `payload.actions.saveSlot` +3. call `routeGraphics.extractBase64("story")` +4. inject the result into `payload.actions.saveSlot.thumbnailImage` +5. also register the captured image as a Route Graphics asset under `saveThumbnailImage:${slotId}:${savedAt}` + +The engine-facing contract is only step 4. + +The asset registration step is harness-specific behavior and is not required by the core save/load store API unless a UI explicitly relies on that asset-id convention. + +Current recommendation: + +- keep thumbnail capture outside the store +- let the host/integration obtain the screenshot from the active renderer/environment +- pass the final image string into `saveSlot` + +Important constraint: + +- a single UI event may contain multiple authored actions +- in that case, the host should still dispatch one `handleActions(...)` call for the whole batch +- do not split save into a separate `handleAction("saveSlot", ...)` call just because it needs a screenshot + +Rationale: + +- authored action order should stay intact +- rollback action batching should stay intact +- save plus other authored actions should continue to behave as one logical interaction + +Current simple shape: + +```js +if (payload?.actions?.saveSlot) { + const thumbnailImage = await routeGraphics.extractBase64("story"); + payload.actions.saveSlot.thumbnailImage = thumbnailImage; +} +``` + +Preferred general integration shape: + +```js +async function prepareActionsForDispatch(actions, routeGraphics) { + if (!actions?.saveSlot) { + return actions; + } + + const nextActions = structuredClone(actions); + + if (!nextActions.saveSlot.thumbnailImage) { + nextActions.saveSlot.thumbnailImage = + await routeGraphics.extractBase64("story"); + } + + return nextActions; +} + +const nextActions = await prepareActionsForDispatch( + payload.actions, + routeGraphics, +); + +engine.handleActions( + nextActions, + payload._event ? { _event: payload._event } : undefined, +); +``` + +Why this is preferred: + +- no in-place mutation of the original event payload +- preserves one multi-action dispatch +- keeps screenshot capture in the host/integration layer +- avoids changing authored action semantics + +Dedicated helpers such as `saveToSlot(slot)` are still viable for UIs where a click means only one save action, but they should not replace the batched `handleActions` path for generic event payloads that may include multiple actions. + +### Host App Responsibilities + +The host app is responsible for: + +- hydrating `initialState.global.saveSlots` from durable storage before engine init +- hydrating persistent global variables before engine init +- providing thumbnail image payloads when a save action wants one +- mapping dynamic UI/event data into the action `slotId` field when save/load is triggered from generated UI +- executing storage effects emitted by the engine + +The system store itself does not own browser storage reads. + +## Data Contract + +The effective slot structure is: + +```yaml +slotId: 1 +savedAt: 1700000000000 +image: data:image/webp;base64,... +state: + viewedRegistry: + sections: + - ... + resources: + - ... + contexts: + - currentPointerMode: read + pointers: + read: + sectionId: section1 + lineId: "3" + history: "..." + historySequence: + - ... + configuration: "..." + views: + - ... + bgm: "..." + variables: "..." + rollback: + currentIndex: 2 + isRestoring: false + replayStartIndex: 0 + timeline: + - ... +``` + +Important constraints: + +- `state.contexts` is authoritative for story restoration +- `state.viewedRegistry` is authoritative for seen-state restoration +- runtime-only globals are not part of this slot payload + +## How It Works Today + +### Initialization + +At initialization: + +- `createInitialState` receives `payload.global.saveSlots` +- `createInitialState` also receives preloaded persistent global variables +- those become part of initial in-memory system state + +This means startup hydration is split: + +- slot map comes from the host app into store initialization +- persistent globals come from the host app into store initialization + +### Save Flow + +Current save flow: + +1. clone current `contexts` +2. strip legacy rollback-only compatibility fields from cloned contexts +3. clone `global.viewedRegistry` +4. write `{ slotId, savedAt, image, state }` into `state.global.saveSlots` +5. append `saveSlots` effect +6. append `render` effect + +The store writes to the in-memory slot map first. + +Persistence to `localStorage` happens later through the effect handler. + +### Load Flow + +Current load flow: + +1. look up `state.global.saveSlots[String(slotId)]` +2. if missing, leave state unchanged +3. clone `slotData.state.viewedRegistry` +4. clone `slotData.state.contexts` +5. normalize rollback state on loaded contexts +6. append `render` effect + +Current implementation note: + +- load restores `contexts` and `viewedRegistry` +- load does not currently rebuild all transient globals from a clean baseline + +That gap should be fixed to match the product rules above. + +## Relationship to Rollback + +Rollback state is part of context state and therefore part of slot state. + +Required behavior: + +- saving must preserve rollback timeline/cursor +- loading must preserve rollback ability from the loaded point +- old saves without rollback state may be upgraded to a minimal rollback state +- rollback restore start state is recomputed from project defaults, not from saved baseline snapshots + +## Validation and Compatibility Rules + +### Validation + +The save/load path should validate enough to guarantee a coherent playable state. + +At minimum: + +- `slotId` should be numeric in authored save/load actions +- `state.contexts` should be an array with at least one valid context +- each loaded context should have a valid read pointer +- `viewedRegistry` should be normalized to a safe shape + +### Compatibility + +Older save formats may exist. + +Compatibility rules should be explicit: + +- older saves without rollback state may be normalized +- legacy rollback-only compatibility fields should be ignored/stripped +- malformed save data should not be partially applied into live state + +If compatibility is intentionally broken in the future, that should be documented clearly. + +## Required Specs + +The save/load test surface should cover: + +- save writes slot metadata and state into `saveSlots` +- save emits `saveSlots` effect +- save overwrites an existing slot deterministically +- save preserves rollback timeline/cursor +- save/load works against live Immer drafts +- load from existing slot restores contexts and viewed registry +- load from missing slot leaves state unchanged +- load restores rollback timeline/cursor from slot data +- load initializes a minimal rollback timeline for older saves without rollback data +- load reinitializes transient runtime globals to defaults +- load clears prior auto/skip/next-line timers +- load does not replace persistent global variables from slot data +- load rejects or safely normalizes malformed slot payloads + +## Current Gaps To Improve + +Areas that should be improved next: + +- load should reset transient runtime globals instead of inheriting them from the pre-load session +- load should clear runtime timers from the pre-load session +- malformed slot data should be handled safely and explicitly +- action/effect schemas should match the actual save/load runtime interfaces +- dynamic slot-event and thumbnail-capture integration rules should be reflected more explicitly in schemas or host-layer helpers +- if screenshot capture remains preprocess-based, prefer cloning/augmenting authored actions before dispatch instead of mutating the original event payload in place +- compatibility aliases should eventually be removed after callers migrate to `saveSlot` / `loadSlot` + +## Non-Goals + +This document does not define: + +- cloud sync +- multi-device account sync +- save slot thumbnails generation strategy +- save slot UI layout design +- exact storage backend beyond current local effect semantics diff --git a/package.json b/package.json index ba726c7..0f376ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "0.4.0", + "version": "0.5.0", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", @@ -35,6 +35,7 @@ "test": "vitest run", "test:watch": "vitest", "prepare": "husky", + "prepack": "bun run build", "build": "bun run esbuild.js", "lint": "bun run prettier src -c", "lint:fix": "bun run prettier src -w", diff --git a/spec/createEffectsHandler.routeGraphicsEvents.test.js b/spec/createEffectsHandler.routeGraphicsEvents.test.js index a20b1f1..1c8e92e 100644 --- a/spec/createEffectsHandler.routeGraphicsEvents.test.js +++ b/spec/createEffectsHandler.routeGraphicsEvents.test.js @@ -124,4 +124,59 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { }, ); }); + + it("forwards preprocessPayload action changes into handleActions", async () => { + const engine = { + selectRenderState: vi.fn(() => ({ id: "render-1" })), + handleAction: vi.fn(), + handleActions: vi.fn(), + }; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics: { + render: vi.fn(), + }, + ticker: createTicker(), + }); + + const eventHandler = effectsHandler.createRouteGraphicsEventHandler({ + preprocessPayload: async (eventName, payload) => ({ + ...payload, + actions: { + ...payload.actions, + saveSlot: { + ...payload.actions.saveSlot, + thumbnailImage: "data:image/png;base64,updated", + savedAt: 1701234567890, + }, + }, + }), + }); + + await eventHandler("click", { + actions: { + saveSlot: { + slotId: 1, + }, + }, + _event: { + id: "slot_1_box", + }, + }); + + expect(engine.handleActions).toHaveBeenCalledWith( + { + saveSlot: { + slotId: 1, + thumbnailImage: "data:image/png;base64,updated", + savedAt: 1701234567890, + }, + }, + { + _event: { + id: "slot_1_box", + }, + }, + ); + }); }); diff --git a/spec/projectDataSchema.test.js b/spec/projectDataSchema.test.js index 6b22f9a..168a69f 100644 --- a/spec/projectDataSchema.test.js +++ b/spec/projectDataSchema.test.js @@ -11,6 +11,7 @@ const projectDataSchemaId = new URL( "projectData/projectData.yaml", schemaBaseUrl, ).href; +const systemActionsSchemaId = new URL("systemActions.yaml", schemaBaseUrl).href; const projectDataSchemaPaths = [ path.join(schemasRoot, "projectData"), path.join(schemasRoot, "presentationActions.yaml"), @@ -90,7 +91,7 @@ const loadSchemas = () => { }); }; -const createValidator = () => { +const createValidator = (schemaId) => { const ajv = new Ajv({ allErrors: true, strict: false, @@ -100,15 +101,16 @@ const createValidator = () => { ajv.addSchema(schema); } - const validate = ajv.getSchema(projectDataSchemaId); + const validate = ajv.getSchema(schemaId); if (!validate) { - throw new Error(`Project data schema "${projectDataSchemaId}" not found`); + throw new Error(`Schema "${schemaId}" not found`); } return validate; }; -const validateProjectData = createValidator(); +const validateProjectData = createValidator(projectDataSchemaId); +const validateSystemActions = createValidator(systemActionsSchemaId); const createMinimalProjectData = (overrides = {}) => ({ screen: { @@ -206,4 +208,24 @@ describe("projectData schema", () => { ]), ); }); + + it("accepts templated save/load slot ids in system actions", () => { + expect( + validateSystemActions({ + saveSlot: { + slotId: "${slot.slotId}", + }, + }), + ).toBe(true); + expect(validateSystemActions.errors).toBeNull(); + + expect( + validateSystemActions({ + loadSlot: { + slotId: "_event.slotId", + }, + }), + ).toBe(true); + expect(validateSystemActions.errors).toBeNull(); + }); }); diff --git a/spec/saveSlotUtils.test.js b/spec/saveSlotUtils.test.js new file mode 100644 index 0000000..266c41e --- /dev/null +++ b/spec/saveSlotUtils.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + createSaveThumbnailAssetId, + resolveSaveSlotId, +} from "../vt/static/saveSlotUtils.js"; + +describe("save slot VT helpers", () => { + it("keeps direct numeric slot ids unchanged", () => { + expect(resolveSaveSlotId(3)).toBe(3); + }); + + it("resolves _event slot ids before thumbnail asset naming", () => { + expect( + createSaveThumbnailAssetId("_event.slotId", 1701234567890, { + _event: { + slotId: 4, + }, + }), + ).toBe("saveThumbnailImage:4:1701234567890"); + }); + + it("falls back to the raw slot id binding when event data is missing", () => { + expect(createSaveThumbnailAssetId("_event.slotId", 1701234567890)).toBe( + "saveThumbnailImage:_event.slotId:1701234567890", + ); + }); +}); diff --git a/spec/system/actions/loadSaveSlot.spec.yaml b/spec/system/actions/loadSaveSlot.spec.yaml index 67c313e..2969c13 100644 --- a/spec/system/actions/loadSaveSlot.spec.yaml +++ b/spec/system/actions/loadSaveSlot.spec.yaml @@ -1,9 +1,9 @@ file: "../../../src/stores/system.store.js" -group: systemStore.loadSaveSlot -suites: [loadSaveSlot] +group: systemStore.loadSlot +suites: [loadSlot] --- -suite: loadSaveSlot -exportName: loadSaveSlot +suite: loadSlot +exportName: loadSlot --- case: load from existing slot in: @@ -11,8 +11,8 @@ in: global: saveSlots: "1": - slotKey: "1" - date: 1704110400 + slotId: 1 + savedAt: 1704110400 image: "save.png" state: projectData: { story: { initialSceneId: "loaded" } } @@ -21,13 +21,13 @@ in: - pointers: read: { sectionId: "sec2", lineId: "line2" } pendingEffects: [] - - slot: 1 + - slotId: 1 out: global: saveSlots: "1": - slotKey: "1" - date: 1704110400 + slotId: 1 + savedAt: 1704110400 image: "save.png" state: projectData: { story: { initialSceneId: "loaded" } } @@ -56,7 +56,7 @@ in: global: saveSlots: {} pendingEffects: [] - - slot: 99 + - slotId: 99 out: global: saveSlots: {} @@ -68,19 +68,19 @@ in: global: saveSlots: "2": - slotKey: "2" - date: 1704110500 + slotId: 2 + savedAt: 1704110500 image: "save2.png" state: level: 10 pendingEffects: [] - - slot: 2 + - slotId: 2 out: global: saveSlots: "2": - slotKey: "2" - date: 1704110500 + slotId: 2 + savedAt: 1704110500 image: "save2.png" state: level: 10 @@ -95,20 +95,20 @@ in: global: saveSlots: "1": - slotKey: "1" - date: 1704110400 + slotId: 1 + savedAt: 1704110400 image: "save.png" state: level: 5 pendingEffects: - name: "existingEffect" - - slot: 1 + - slotId: 1 out: global: saveSlots: "1": - slotKey: "1" - date: 1704110400 + slotId: 1 + savedAt: 1704110400 image: "save.png" state: level: 5 @@ -124,8 +124,8 @@ in: global: saveSlots: "1": - slotKey: "1" - date: 1704110400 + slotId: 1 + savedAt: 1704110400 image: "save.png" state: viewedRegistry: @@ -149,13 +149,13 @@ in: playerName: "Guest" score: 0 soundEnabled: false - - slot: 1 + - slotId: 1 out: global: saveSlots: "1": - slotKey: "1" - date: 1704110400 + slotId: 1 + savedAt: 1704110400 image: "save.png" state: viewedRegistry: @@ -195,8 +195,8 @@ in: global: saveSlots: "4": - slotKey: "4" - date: 1704110600 + slotId: 4 + savedAt: 1704110600 image: "save4.png" state: viewedRegistry: @@ -224,13 +224,13 @@ in: lineId: "line3" rollbackPolicy: "free" pendingEffects: [] - - slot: 4 + - slotId: 4 out: global: saveSlots: "4": - slotKey: "4" - date: 1704110600 + slotId: 4 + savedAt: 1704110600 image: "save4.png" state: viewedRegistry: diff --git a/spec/system/actions/saveSaveSlot.spec.yaml b/spec/system/actions/saveSaveSlot.spec.yaml index 0fd0c94..d726b76 100644 --- a/spec/system/actions/saveSaveSlot.spec.yaml +++ b/spec/system/actions/saveSaveSlot.spec.yaml @@ -1,9 +1,9 @@ file: "../../../src/stores/system.store.js" -group: systemStore.saveSaveSlot -suites: [saveSaveSlot] +group: systemStore.saveSlot +suites: [saveSlot] --- -suite: saveSaveSlot -exportName: saveSaveSlot +suite: saveSlot +exportName: saveSlot --- case: save to slot 1 in: @@ -16,14 +16,14 @@ in: contexts: - pointers: read: { sectionId: "sec1", lineId: "line1" } - - slot: 1 + - slotId: 1 thumbnailImage: null out: global: saveSlots: "1": - slotKey: "1" - date: 1700000000000 + slotId: 1 + savedAt: 1700000000000 image: null state: contexts: @@ -35,8 +35,8 @@ out: payload: saveSlots: "1": - slotKey: "1" - date: 1700000000000 + slotId: 1 + savedAt: 1700000000000 image: null state: contexts: @@ -61,14 +61,14 @@ in: contexts: - pointers: read: { sectionId: "sec1", lineId: "line1" } - - slot: 2 + - slotId: 2 thumbnailImage: null out: global: saveSlots: "2": - slotKey: "2" - date: 1700000000000 + slotId: 2 + savedAt: 1700000000000 image: null state: contexts: @@ -80,8 +80,8 @@ out: payload: saveSlots: "2": - slotKey: "2" - date: 1700000000000 + slotId: 2 + savedAt: 1700000000000 image: null state: contexts: @@ -101,8 +101,8 @@ in: global: saveSlots: "1": - slotKey: "1" - date: 1704110400 + slotId: 1 + savedAt: 1704110400 image: "old_image.png" state: level: 5 @@ -112,14 +112,14 @@ in: contexts: - pointers: read: { sectionId: "sec1", lineId: "line1" } - - slot: 1 + - slotId: 1 thumbnailImage: null out: global: saveSlots: "1": - slotKey: "1" - date: 1700000000000 + slotId: 1 + savedAt: 1700000000000 image: null state: contexts: @@ -131,8 +131,8 @@ out: payload: saveSlots: "1": - slotKey: "1" - date: 1700000000000 + slotId: 1 + savedAt: 1700000000000 image: null state: contexts: @@ -146,6 +146,52 @@ out: - pointers: read: { sectionId: "sec1", lineId: "line1" } --- +case: save with explicit timestamp override +in: + - state: + global: + saveSlots: {} + pendingEffects: [] + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + - slotId: 1 + thumbnailImage: "custom_image.png" + savedAt: 1701234567890 +out: + global: + saveSlots: + "1": + slotId: 1 + savedAt: 1701234567890 + image: "custom_image.png" + state: + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + viewedRegistry: __undefined__ + pendingEffects: + - name: "saveSlots" + payload: + saveSlots: + "1": + slotId: 1 + savedAt: 1701234567890 + image: "custom_image.png" + state: + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } + viewedRegistry: __undefined__ + - name: "render" + projectData: + story: { initialSceneId: "test" } + contexts: + - pointers: + read: { sectionId: "sec1", lineId: "line1" } +--- case: save to slot with existing pending effects in: - state: @@ -158,14 +204,14 @@ in: contexts: - pointers: read: { sectionId: "sec1", lineId: "line1" } - - slot: 1 + - slotId: 1 thumbnailImage: null out: global: saveSlots: "1": - slotKey: "1" - date: 1700000000000 + slotId: 1 + savedAt: 1700000000000 image: null state: contexts: @@ -178,8 +224,8 @@ out: payload: saveSlots: "1": - slotKey: "1" - date: 1700000000000 + slotId: 1 + savedAt: 1700000000000 image: null state: contexts: @@ -211,14 +257,14 @@ in: playerName: "Alice" score: 150 soundEnabled: true - - slot: 1 + - slotId: 1 thumbnailImage: null out: global: saveSlots: "1": - slotKey: "1" - date: 1700000000000 + slotId: 1 + savedAt: 1700000000000 image: null state: viewedRegistry: @@ -239,8 +285,8 @@ out: payload: saveSlots: "1": - slotKey: "1" - date: 1700000000000 + slotId: 1 + savedAt: 1700000000000 image: null state: viewedRegistry: @@ -291,14 +337,14 @@ in: - sectionId: "sec2" lineId: "line3" rollbackPolicy: "free" - - slot: 3 + - slotId: 3 thumbnailImage: null out: global: saveSlots: "3": - slotKey: "3" - date: 1700000000000 + slotId: 3 + savedAt: 1700000000000 image: null state: contexts: @@ -326,8 +372,8 @@ out: payload: saveSlots: "3": - slotKey: "3" - date: 1700000000000 + slotId: 3 + savedAt: 1700000000000 image: null state: contexts: diff --git a/spec/system/actions/updateProjectData.spec.yaml b/spec/system/actions/updateProjectData.spec.yaml index 8d1299f..ace096e 100644 --- a/spec/system/actions/updateProjectData.spec.yaml +++ b/spec/system/actions/updateProjectData.spec.yaml @@ -128,7 +128,8 @@ in: enabled: false saveSlots: slot1: - date: 1234567890 + slotId: "slot1" + savedAt: 1234567890 image: "base64image" state: {} pendingEffects: @@ -170,7 +171,8 @@ out: enabled: false saveSlots: slot1: - date: 1234567890 + slotId: "slot1" + savedAt: 1234567890 image: "base64image" state: {} pendingEffects: diff --git a/spec/system/selectors/selectCurrentPageSlots.spec.yaml b/spec/system/selectors/selectCurrentPageSlots.spec.yaml index bdd0d51..151e8c6 100644 --- a/spec/system/selectors/selectCurrentPageSlots.spec.yaml +++ b/spec/system/selectors/selectCurrentPageSlots.spec.yaml @@ -1,10 +1,10 @@ file: "../../../src/stores/system.store.js" group: systemStore selectors suites: - - selectCurrentPageSlots + - selectSaveSlotPage --- -suite: selectCurrentPageSlots -exportName: selectCurrentPageSlots +suite: selectSaveSlotPage +exportName: selectSaveSlotPage --- case: default 6 slots per page, page 1 in: @@ -17,12 +17,12 @@ in: - variables: {} out: saveSlots: - - slotNumber: 1 - - slotNumber: 2 - - slotNumber: 3 - - slotNumber: 4 - - slotNumber: 5 - - slotNumber: 6 + - slotId: 1 + - slotId: 2 + - slotId: 3 + - slotId: 4 + - slotId: 5 + - slotId: 6 --- case: default 6 slots per page, page 2 in: @@ -35,12 +35,12 @@ in: - variables: {} out: saveSlots: - - slotNumber: 7 - - slotNumber: 8 - - slotNumber: 9 - - slotNumber: 10 - - slotNumber: 11 - - slotNumber: 12 + - slotId: 7 + - slotId: 8 + - slotId: 9 + - slotId: 10 + - slotId: 11 + - slotId: 12 --- case: default 6 slots per page, page 3 in: @@ -53,12 +53,12 @@ in: - variables: {} out: saveSlots: - - slotNumber: 13 - - slotNumber: 14 - - slotNumber: 15 - - slotNumber: 16 - - slotNumber: 17 - - slotNumber: 18 + - slotId: 13 + - slotId: 14 + - slotId: 15 + - slotId: 16 + - slotId: 17 + - slotId: 18 --- case: loadPage defaults to 1 when undefined in: @@ -70,12 +70,12 @@ in: - variables: {} out: saveSlots: - - slotNumber: 1 - - slotNumber: 2 - - slotNumber: 3 - - slotNumber: 4 - - slotNumber: 5 - - slotNumber: 6 + - slotId: 1 + - slotId: 2 + - slotId: 3 + - slotId: 4 + - slotId: 5 + - slotId: 6 --- case: loadPage from context variables in: @@ -88,12 +88,12 @@ in: loadPage: 2 out: saveSlots: - - slotNumber: 7 - - slotNumber: 8 - - slotNumber: 9 - - slotNumber: 10 - - slotNumber: 11 - - slotNumber: 12 + - slotId: 7 + - slotId: 8 + - slotId: 9 + - slotId: 10 + - slotId: 11 + - slotId: 12 --- case: context variables override global variables in: @@ -107,12 +107,12 @@ in: loadPage: 3 out: saveSlots: - - slotNumber: 13 - - slotNumber: 14 - - slotNumber: 15 - - slotNumber: 16 - - slotNumber: 17 - - slotNumber: 18 + - slotId: 13 + - slotId: 14 + - slotId: 15 + - slotId: 16 + - slotId: 17 + - slotId: 18 --- case: with saved slot data in: @@ -122,29 +122,29 @@ in: loadPage: 1 saveSlots: "1": - date: 1704556800000 + savedAt: 1704556800000 image: "data:image/png;base64,abc123" state: contexts: [] viewedRegistry: {} "3": - date: 1704643200000 + savedAt: 1704643200000 contexts: - variables: {} out: saveSlots: - - slotNumber: 1 - date: 1704556800000 + - slotId: 1 + savedAt: 1704556800000 image: "data:image/png;base64,abc123" state: contexts: [] viewedRegistry: {} - - slotNumber: 2 - - slotNumber: 3 - date: 1704643200000 - - slotNumber: 4 - - slotNumber: 5 - - slotNumber: 6 + - slotId: 2 + - slotId: 3 + savedAt: 1704643200000 + - slotId: 4 + - slotId: 5 + - slotId: 6 --- case: custom 12 slots per page in: @@ -158,18 +158,18 @@ in: - slotsPerPage: 12 out: saveSlots: - - slotNumber: 1 - - slotNumber: 2 - - slotNumber: 3 - - slotNumber: 4 - - slotNumber: 5 - - slotNumber: 6 - - slotNumber: 7 - - slotNumber: 8 - - slotNumber: 9 - - slotNumber: 10 - - slotNumber: 11 - - slotNumber: 12 + - slotId: 1 + - slotId: 2 + - slotId: 3 + - slotId: 4 + - slotId: 5 + - slotId: 6 + - slotId: 7 + - slotId: 8 + - slotId: 9 + - slotId: 10 + - slotId: 11 + - slotId: 12 --- case: custom 4 slots per page in: @@ -183,10 +183,10 @@ in: - slotsPerPage: 4 out: saveSlots: - - slotNumber: 1 - - slotNumber: 2 - - slotNumber: 3 - - slotNumber: 4 + - slotId: 1 + - slotId: 2 + - slotId: 3 + - slotId: 4 --- case: custom 10 slots per page, page 2 in: @@ -200,16 +200,16 @@ in: - slotsPerPage: 10 out: saveSlots: - - slotNumber: 11 - - slotNumber: 12 - - slotNumber: 13 - - slotNumber: 14 - - slotNumber: 15 - - slotNumber: 16 - - slotNumber: 17 - - slotNumber: 18 - - slotNumber: 19 - - slotNumber: 20 + - slotId: 11 + - slotId: 12 + - slotId: 13 + - slotId: 14 + - slotId: 15 + - slotId: 16 + - slotId: 17 + - slotId: 18 + - slotId: 19 + - slotId: 20 --- case: undefined saveSlots handled gracefully in: @@ -221,9 +221,9 @@ in: - variables: {} out: saveSlots: - - slotNumber: 1 - - slotNumber: 2 - - slotNumber: 3 - - slotNumber: 4 - - slotNumber: 5 - - slotNumber: 6 + - slotId: 1 + - slotId: 2 + - slotId: 3 + - slotId: 4 + - slotId: 5 + - slotId: 6 diff --git a/spec/system/selectors/selectSaveSlot.spec.yaml b/spec/system/selectors/selectSaveSlot.spec.yaml index 2d26de4..2ca437b 100644 --- a/spec/system/selectors/selectSaveSlot.spec.yaml +++ b/spec/system/selectors/selectSaveSlot.spec.yaml @@ -14,11 +14,11 @@ in: global: saveSlots: "1": - date: "2024-01-01T12:00:00Z" + savedAt: "2024-01-01T12:00:00Z" image: "base64..." state: test: "value" - - slotKey: "999" + - slotId: "999" out: __undefined__ --- case: save slot found @@ -27,64 +27,64 @@ in: global: saveSlots: "1": - date: "2024-01-01T12:00:00Z" + savedAt: "2024-01-01T12:00:00Z" image: "save1.png" state: level: 5 score: 1000 "2": - date: "2024-01-02T15:30:00Z" + savedAt: "2024-01-02T15:30:00Z" image: "save2.png" state: level: 10 score: 2500 - - slotKey: "2" + - slotId: "2" out: - date: "2024-01-02T15:30:00Z" + savedAt: "2024-01-02T15:30:00Z" image: "save2.png" state: level: 10 score: 2500 --- -case: save slot found with numeric slotKey +case: save slot found with numeric slotId in: - state: global: saveSlots: "3": - date: "2024-03-15T20:45:00Z" + savedAt: "2024-03-15T20:45:00Z" image: "save3.png" state: completed: true playtime: 1200 - - slotKey: "3" + - slotId: "3" out: - date: "2024-03-15T20:45:00Z" + savedAt: "2024-03-15T20:45:00Z" image: "save3.png" state: completed: true playtime: 1200 --- -case: save slot found with string slotKey +case: save slot found with string slotId in: - state: global: saveSlots: "autosave": - date: "2024-01-01T12:00:00Z" + savedAt: "2024-01-01T12:00:00Z" image: "auto.png" state: auto: true lastAction: "dialogue" "quicksave": - date: "2024-01-01T12:05:00Z" + savedAt: "2024-01-01T12:05:00Z" image: "quick.png" state: quick: true lastAction: "menu" - - slotKey: "autosave" + - slotId: "autosave" out: - date: "2024-01-01T12:00:00Z" + savedAt: "2024-01-01T12:00:00Z" image: "auto.png" state: auto: true @@ -95,5 +95,5 @@ in: - state: global: saveSlots: {} - - slotKey: "1" + - slotId: "1" out: __undefined__ \ No newline at end of file diff --git a/spec/system/selectors/selectSaveSlots.spec.yaml b/spec/system/selectors/selectSaveSlots.spec.yaml index 97ff6c6..4ec611d 100644 --- a/spec/system/selectors/selectSaveSlots.spec.yaml +++ b/spec/system/selectors/selectSaveSlots.spec.yaml @@ -2,11 +2,11 @@ file: "../../../src/stores/system.store.js" group: systemStore selectors suites: [ - selectSaveSlots, + selectSaveSlotMap, ] --- -suite: selectSaveSlots -exportName: selectSaveSlots +suite: selectSaveSlotMap +exportName: selectSaveSlotMap --- case: empty save slots in: @@ -21,33 +21,33 @@ in: global: saveSlots: "1": - date: "2024-01-01T12:00:00Z" + savedAt: "2024-01-01T12:00:00Z" image: "base64..." state: test: "value1" "2": - date: "2024-01-02T15:30:00Z" + savedAt: "2024-01-02T15:30:00Z" image: "base64..." state: test: "value2" "autosave": - date: "2024-01-03T09:15:00Z" + savedAt: "2024-01-03T09:15:00Z" image: "base64..." state: test: "autosave_value" out: "1": - date: "2024-01-01T12:00:00Z" + savedAt: "2024-01-01T12:00:00Z" image: "base64..." state: test: "value1" "2": - date: "2024-01-02T15:30:00Z" + savedAt: "2024-01-02T15:30:00Z" image: "base64..." state: test: "value2" "autosave": - date: "2024-01-03T09:15:00Z" + savedAt: "2024-01-03T09:15:00Z" image: "base64..." state: test: "autosave_value" @@ -58,13 +58,13 @@ in: global: saveSlots: "5": - date: "2024-12-25T00:00:00Z" + savedAt: "2024-12-25T00:00:00Z" image: "holiday_save.png" state: progress: 75 out: "5": - date: "2024-12-25T00:00:00Z" + savedAt: "2024-12-25T00:00:00Z" image: "holiday_save.png" state: progress: 75 \ No newline at end of file diff --git a/spec/systemStore.rollbackDraftSafety.test.js b/spec/systemStore.rollbackDraftSafety.test.js index 778e9ee..b297da1 100644 --- a/spec/systemStore.rollbackDraftSafety.test.js +++ b/spec/systemStore.rollbackDraftSafety.test.js @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { produce } from "immer"; import { - loadSaveSlot, + loadSlot, rollbackByOffset, - saveSaveSlot, + saveSlot, sectionTransition, updateVariable, } from "../src/stores/system.store.js"; @@ -131,7 +131,7 @@ const createEventDrivenRollbackProjectData = () => ({ }); describe("system.store rollback/save draft safety", () => { - it("saveSaveSlot does not throw when cloning live draft state", () => { + it("saveSlot does not throw when cloning live draft state", () => { vi.spyOn(Date, "now").mockReturnValue(1700000000000); const baseState = { @@ -174,7 +174,7 @@ describe("system.store rollback/save draft safety", () => { }; const nextState = produce(baseState, (draft) => { - saveSaveSlot({ state: draft }, { slot: 1, thumbnailImage: null }); + saveSlot({ state: draft }, { slotId: 1, thumbnailImage: null }); }); expect( @@ -199,7 +199,7 @@ describe("system.store rollback/save draft safety", () => { vi.restoreAllMocks(); }); - it("loadSaveSlot does not throw when restoring slot state from a live draft", () => { + it("loadSlot does not throw when restoring slot state from a live draft", () => { const savedRollback = { currentIndex: 2, isRestoring: false, @@ -230,8 +230,8 @@ describe("system.store rollback/save draft safety", () => { global: { saveSlots: { 1: { - slotKey: "1", - date: 1700000000000, + slotId: 1, + savedAt: 1700000000000, image: null, state: { viewedRegistry: { @@ -271,7 +271,7 @@ describe("system.store rollback/save draft safety", () => { }; const nextState = produce(baseState, (draft) => { - loadSaveSlot({ state: draft }, { slot: 1 }); + loadSlot({ state: draft }, { slotId: 1 }); }); expect(nextState.contexts[0].rollback).toEqual({ diff --git a/src/RouteEngine.js b/src/RouteEngine.js index 7767fad..ae9c48c 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -50,8 +50,16 @@ export default function createRouteEngine(options) { return _systemStore.selectSystemState(); }; - const selectSaveSlots = () => { - return _systemStore.selectSaveSlots(); + const selectSaveSlotMap = () => { + return _systemStore.selectSaveSlotMap(); + }; + + const selectSaveSlot = (payload) => { + return _systemStore.selectSaveSlot(payload); + }; + + const selectSaveSlotPage = (payload) => { + return _systemStore.selectSaveSlotPage(payload); }; const selectSkipMode = () => { @@ -120,7 +128,10 @@ export default function createRouteEngine(options) { selectPresentationChanges, selectSectionLineChanges, selectSystemState, - selectSaveSlots, + selectSaveSlotMap, + selectSaveSlot, + selectSaveSlotPage, + selectSaveSlots: selectSaveSlotMap, handleLineActions, }; } diff --git a/src/schemas/systemActions.yaml b/src/schemas/systemActions.yaml index 609d45b..33e56f7 100644 --- a/src/schemas/systemActions.yaml +++ b/src/schemas/systemActions.yaml @@ -167,26 +167,58 @@ properties: ############ # Save and Load ############ - # TODO: finalize naming - saveSaveSlot: + saveSlot: type: object description: Save current story state. See the side effect for all the data it stores properties: - slot: + slotId: + type: [number, string] + description: Save slot index to save to, or a template/event binding that resolves to one + minimum: 0 + thumbnailImage: + type: [string, "null"] + description: Optional thumbnail image as a data URL or external image source + savedAt: type: number - description: Save slot index to save to + description: Optional save timestamp override. Mainly useful for deterministic integrations and tests. + required: [slotId] + additionalProperties: false + + loadSlot: + type: object + description: Load game state from a save slot + properties: + slotId: + type: [number, string] + description: Save slot index to load from, or a template/event binding that resolves to one + minimum: 0 + required: [slotId] + additionalProperties: false + + saveSaveSlot: + type: object + description: Compatibility alias for saveSlot + properties: + slot: + type: [number, string] + description: Save slot index to save to, or a template/event binding that resolves to one minimum: 0 + thumbnailImage: + type: [string, "null"] + description: Optional thumbnail image as a data URL or external image source + date: + type: number + description: Optional save timestamp override. Mainly useful for deterministic integrations and tests. required: [slot] additionalProperties: false - # TODO: finalize naming loadSaveSlot: type: object - description: Load game state from a save slot + description: Compatibility alias for loadSlot properties: slot: - type: number - description: Save slot index to load from + type: [number, string] + description: Save slot index to load from, or a template/event binding that resolves to one minimum: 0 required: [slot] additionalProperties: false diff --git a/src/schemas/systemState/effects.yaml b/src/schemas/systemState/effects.yaml index 96cc605..0518ffe 100644 --- a/src/schemas/systemState/effects.yaml +++ b/src/schemas/systemState/effects.yaml @@ -15,38 +15,24 @@ oneOf: required: [type] additionalProperties: false - - title: Save VN data effect - description: Saves visual novel data to localStorage saveSlots + - title: Save slots effect + description: Persists the full save slot map type: object properties: name: - const: saveVnData - options: + const: saveSlots + payload: type: object properties: - saveData: + saveSlots: type: object - description: Save game data - properties: - date: - type: integer - description: Save timestamp - screenshot: - type: string - description: Screenshot in base64 format - state: - type: object - description: Game state including sectionId, lineId, history, and settings - additionalProperties: true - slotIndex: - type: integer - description: Save slot index - minimum: 0 + description: Save game slots keyed by stringified slot id + additionalProperties: true priority: type: integer description: Priority of the effect (lower numbers execute first) default: 0 - required: [name] + required: [name, payload] additionalProperties: false - title: Save variables effect diff --git a/src/schemas/systemState/systemState.yaml b/src/schemas/systemState/systemState.yaml index 83c9313..37c3bfc 100644 --- a/src/schemas/systemState/systemState.yaml +++ b/src/schemas/systemState/systemState.yaml @@ -47,23 +47,23 @@ properties: saveSlots: type: object - description: Save game slots indexed by slotKey + description: Save game slots indexed by slot id properties: - "^.+$": # slot key (string like "1", "autosave", etc.) + "^.+$": type: object properties: - slotKey: - type: string - date: + slotId: + type: [number, string] + savedAt: type: integer description: Unix timestamp image: - type: string + type: [string, "null"] description: Base64 screenshot (null if none) state: type: object description: Full system state - required: [slotKey, date, state] + required: [slotId, savedAt, state] default: {} # TODO: don't remember why we need this for diff --git a/src/stores/system.store.js b/src/stores/system.store.js index 8d393a7..1526984 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -50,6 +50,46 @@ const cloneStateValue = (value) => { return structuredClone(source); }; +const toSlotStorageKey = (slotId) => String(slotId); + +const normalizeStoredSlotId = (slotId) => { + if (typeof slotId === "number") { + return slotId; + } + + if (typeof slotId !== "string") { + return slotId; + } + + const numericSlotId = Number(slotId); + return Number.isFinite(numericSlotId) ? numericSlotId : slotId; +}; + +const normalizeStoredSaveSlot = (storageKey, saveSlot = {}) => { + const normalizedSaveSlot = { + ...saveSlot, + slotId: normalizeStoredSlotId( + saveSlot.slotId ?? saveSlot.slotKey ?? storageKey, + ), + savedAt: + typeof saveSlot.savedAt === "number" ? saveSlot.savedAt : saveSlot.date, + }; + + delete normalizedSaveSlot.slotKey; + delete normalizedSaveSlot.date; + + return normalizedSaveSlot; +}; + +const normalizeStoredSaveSlots = (saveSlots = {}) => { + return Object.fromEntries( + Object.entries(saveSlots).map(([storageKey, saveSlot]) => [ + storageKey, + normalizeStoredSaveSlot(storageKey, saveSlot), + ]), + ); +}; + const rollbackActionBatchStack = []; const createRollbackCheckpoint = ({ sectionId, lineId, rollbackPolicy }) => ({ @@ -476,7 +516,7 @@ export const createInitialState = (payload) => { auto: { ...DEFAULT_NEXT_LINE_CONFIG.auto }, applyMode: DEFAULT_NEXT_LINE_CONFIG.applyMode, }, - saveSlots, + saveSlots: normalizeStoredSaveSlots(saveSlots), layeredViews: [], variables: globalVariables, }, @@ -644,13 +684,14 @@ export const selectSystemState = ({ state }) => { return structuredClone(state); }; -export const selectSaveSlots = ({ state }) => { +export const selectSaveSlotMap = ({ state }) => { return state.global.saveSlots; }; export const selectSaveSlot = ({ state }, payload) => { - const { slotKey } = payload; - return state.global.saveSlots[slotKey]; + const slotId = payload?.slotId ?? payload?.slot ?? payload?.slotKey; + const storageKey = toSlotStorageKey(slotId); + return state.global.saveSlots[storageKey]; }; /** @@ -883,20 +924,20 @@ export const selectPreviousPresentationState = ({ state }) => { * layout properties (width, gap, direction). * * Each slot object contains: - * - slotNumber: The unique slot identifier (1, 2, 3, ...) - * - date: Timestamp when the save was created (if saved) + * - slotId: The unique slot identifier (1, 2, 3, ...) + * - savedAt: Timestamp when the save was created (if saved) * - image: Base64 thumbnail image (if saved) * - state: Saved game state data (if saved) * * @example * // Default 6 slots per page * // Page 1: slots 1-6, Page 2: slots 7-12, etc. - * const { saveSlots } = selectCurrentPageSlots({ state }); + * const { saveSlots } = selectSaveSlotPage({ state }); * // Returns: [slot1, slot2, slot3, slot4, slot5, slot6] * * @example * // Custom 12 slots per page - * const { saveSlots } = selectCurrentPageSlots({ state }, { slotsPerPage: 12 }); + * const { saveSlots } = selectSaveSlotPage({ state }, { slotsPerPage: 12 }); * // Returns: [slot1, slot2, ..., slot12] * * @example @@ -904,33 +945,30 @@ export const selectPreviousPresentationState = ({ state }) => { * { * saveSlots: [ * { - * slotNumber: 1, - * date: 1704556800000, + * slotId: 1, + * savedAt: 1704556800000, * image: "data:image/png;base64,iVBORw0KGgoAAAANS...", * state: { contexts: [...], viewedRegistry: {...} } * }, - * { slotNumber: 2 }, // Empty slot (not saved) + * { slotId: 2 }, // Empty slot (not saved) * { - * slotNumber: 3, - * date: 1704643200000, + * slotId: 3, + * savedAt: 1704643200000, * image: "data:image/png;base64,iVBORw0KGgoAAAANS...", * state: { contexts: [...], viewedRegistry: {...} } * }, - * { slotNumber: 4 }, // Empty slot - * { slotNumber: 5 }, // Empty slot + * { slotId: 4 }, // Empty slot + * { slotId: 5 }, // Empty slot * { - * slotNumber: 6, - * date: 1704729600000, + * slotId: 6, + * savedAt: 1704729600000, * image: "data:image/png;base64,iVBORw0KGgoAAAANS...", * state: { contexts: [...], viewedRegistry: {...} } * } * ] * } */ -export const selectCurrentPageSlots = ( - { state }, - { slotsPerPage = 6 } = {}, -) => { +export const selectSaveSlotPage = ({ state }, { slotsPerPage = 6 } = {}) => { const allVariables = { ...state.global.variables, ...state.contexts[state.contexts.length - 1].variables, @@ -941,18 +979,23 @@ export const selectCurrentPageSlots = ( const slots = []; for (let i = 0; i < slotsPerPage; i++) { - const slotNumber = startSlot + i; + const slotId = startSlot + i; const slotData = - (state.global.saveSlots && state.global.saveSlots[slotNumber]) || {}; + (state.global.saveSlots && + state.global.saveSlots[toSlotStorageKey(slotId)]) || + {}; slots.push({ - slotNumber, ...slotData, + slotId, }); } return { saveSlots: slots }; }; +export const selectSaveSlots = selectSaveSlotMap; +export const selectCurrentPageSlots = selectSaveSlotPage; + const shouldSettleCurrentLinePresentation = (state) => { const lastContext = state.contexts?.[state.contexts.length - 1]; const rollback = lastContext?.rollback; @@ -979,7 +1022,7 @@ export const selectRenderState = ({ state }) => { ...state.contexts[state.contexts.length - 1].variables, }; - const { saveSlots } = selectCurrentPageSlots({ state }); + const { saveSlots } = selectSaveSlotPage({ state }); const settleCurrentLinePresentation = shouldSettleCurrentLinePresentation(state); @@ -1367,9 +1410,12 @@ export const setNextLineConfig = ({ state }, payload) => { * @param {string} payload.thumbnailImage - Base64 thumbnail image * @returns {Object} Updated state object */ -export const saveSaveSlot = ({ state }, payload) => { - const { slot, thumbnailImage } = payload; - const slotKey = String(slot); +export const saveSlot = ({ state }, payload) => { + const slotId = payload?.slotId ?? payload?.slot; + const { thumbnailImage } = payload; + const savedAt = + typeof payload?.savedAt === "number" ? payload.savedAt : payload?.date; + const storageKey = toSlotStorageKey(slotId); const contexts = cloneStateValue(state.contexts); contexts?.forEach((context) => { removeLegacyRollbackBaseline(context.rollback); @@ -1381,13 +1427,13 @@ export const saveSaveSlot = ({ state }, payload) => { }; const saveData = { - slotKey, - date: Date.now(), + slotId: normalizeStoredSlotId(slotId), + savedAt: typeof savedAt === "number" ? savedAt : Date.now(), image: thumbnailImage, state: currentState, }; - state.global.saveSlots[slotKey] = saveData; + state.global.saveSlots[storageKey] = saveData; state.global.pendingEffects.push( { @@ -1405,13 +1451,13 @@ export const saveSaveSlot = ({ state }, payload) => { * Loads game state from a save slot * @param {Object} state - Current state object * @param {Object} payload - Action payload - * @param {number} payload.slot - Save slot number + * @param {number} payload.slotId - Save slot number * @returns {Object} Updated state object */ -export const loadSaveSlot = ({ state }, payload) => { - const { slot } = payload; - const slotKey = String(slot); - const slotData = state.global.saveSlots[slotKey]; +export const loadSlot = ({ state }, payload) => { + const slotId = payload?.slotId ?? payload?.slot; + const storageKey = toSlotStorageKey(slotId); + const slotData = state.global.saveSlots[storageKey]; if (slotData) { state.global.viewedRegistry = cloneStateValue( slotData.state.viewedRegistry, @@ -1425,6 +1471,9 @@ export const loadSaveSlot = ({ state }, payload) => { return state; }; +export const saveSaveSlot = saveSlot; +export const loadSaveSlot = loadSlot; + /** * Updates the entire projectData with new data * @param {Object} state - Current state object @@ -2283,6 +2332,7 @@ export const createSystemStore = (initialState) => { selectIsResourceViewed, selectNextLineConfig, selectSystemState, + selectSaveSlotMap, selectSaveSlots, selectSaveSlot, selectCurrentPointer, @@ -2291,6 +2341,7 @@ export const createSystemStore = (initialState) => { selectPresentationState, selectPresentationChanges, selectSectionLineChanges, + selectSaveSlotPage, selectCurrentPageSlots, selectRenderState, selectLayeredViews, @@ -2316,6 +2367,8 @@ export const createSystemStore = (initialState) => { addViewedLine, addViewedResource, setNextLineConfig, + saveSlot, + loadSlot, saveSaveSlot, loadSaveSlot, updateProjectData, diff --git a/vt/reference/save/dynamic-slot-selector-test-01.webp b/vt/reference/save/dynamic-slot-selector-test-01.webp index 00f9b70..5ee9b2d 100644 --- a/vt/reference/save/dynamic-slot-selector-test-01.webp +++ b/vt/reference/save/dynamic-slot-selector-test-01.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:04975a1d20661cd154d2dd94848ad0f8340f1c5ab31f923753c4629511dbd047 -size 9912 +oid sha256:a113770e0861bb9b754c38db33b0209f136670e7ae5e3ea64572f8bac205db71 +size 2492 diff --git a/vt/reference/save/multi-slot-test-01.webp b/vt/reference/save/multi-slot-test-01.webp deleted file mode 100644 index b87e2c1..0000000 --- a/vt/reference/save/multi-slot-test-01.webp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4414cd32d95ea716fe6cc1bc366923df923fa5269934434675f03acac2aa064d -size 20070 diff --git a/vt/specs/save/basic.yaml b/vt/specs/save/basic.yaml index 88de1ab..4dfe07d 100644 --- a/vt/specs/save/basic.yaml +++ b/vt/specs/save/basic.yaml @@ -135,8 +135,8 @@ resources: click: payload: actions: - saveSaveSlot: - slot: 1 + saveSlot: + slotId: 1 colorId: layoutColor3 - id: save-slot-1-text type: text @@ -155,8 +155,8 @@ resources: click: payload: actions: - saveSaveSlot: - slot: 2 + saveSlot: + slotId: 2 colorId: layoutColor3 - id: save-slot-2-text type: text @@ -175,8 +175,8 @@ resources: click: payload: actions: - saveSaveSlot: - slot: 3 + saveSlot: + slotId: 3 colorId: layoutColor3 - id: save-slot-3-text type: text @@ -243,8 +243,8 @@ resources: click: payload: actions: - loadSaveSlot: - slot: 1 + loadSlot: + slotId: 1 colorId: layoutColor3 - id: load-slot-1-text type: text @@ -263,8 +263,8 @@ resources: click: payload: actions: - loadSaveSlot: - slot: 2 + loadSlot: + slotId: 2 colorId: layoutColor3 - id: load-slot-2-text type: text @@ -283,8 +283,8 @@ resources: click: payload: actions: - loadSaveSlot: - slot: 3 + loadSlot: + slotId: 3 colorId: layoutColor3 - id: load-slot-3-text type: text diff --git a/vt/specs/save/dynamic-slot-selector-test.yaml b/vt/specs/save/dynamic-slot-selector-test.yaml index 62e3b06..270f1e9 100644 --- a/vt/specs/save/dynamic-slot-selector-test.yaml +++ b/vt/specs/save/dynamic-slot-selector-test.yaml @@ -6,131 +6,290 @@ screen: height: 1080 backgroundColor: "#000000" resources: + images: + savePlaceholder: + fileId: 94lkj289 + width: 280 + height: 148 variables: loadPage: type: number scope: global-device default: 1 - controls: - base1: + previewFrame: + type: string + scope: context + default: "01 / 20" + previewTitle: + type: string + scope: context + default: "Rain" + previewDetail: + type: string + scope: context + default: "Station window." + characters: + narrator: + name: "" + layouts: + readingLayout: + name: Story Screen elements: - - id: clickArea + - id: background type: rect width: 1920 height: 1080 + x: 0 + y: 0 click: payload: actions: - contextNextLine: {} + nextLine: {} colorId: layoutColor1 - layouts: - dynamicSlotsTestUI: - name: Dynamic Slots Grid Test + - id: scene-card + type: rect + width: 1080 + height: 260 + x: 120 + y: 120 + click: + payload: + actions: + nextLine: {} + colorId: layoutColor2 + - id: scene-title + type: text + content: ${variables.previewFrame} + x: 170 + y: 165 + textStyleId: textStyle1 + - id: scene-subtitle + type: text + content: ${variables.previewTitle} + x: 172 + y: 228 + textStyleId: textStyle10 + - id: scene-detail + type: text + content: ${variables.previewDetail} + x: 174 + y: 286 + textStyleId: textStyle2 + readingDialogueLayout: + name: Reading Dialogue + mode: adv elements: + - id: save-button + type: rect + x: 1670 + y: 40 + width: 160 + height: 64 + click: + payload: + actions: + pushLayeredView: + resourceId: dynamicSaveMenuLayout + resourceType: layout + colorId: layoutColor3 + - id: save-button-text + type: text + x: 1720 + y: 58 + content: Save + click: + payload: + actions: + pushLayeredView: + resourceId: dynamicSaveMenuLayout + resourceType: layout + textStyleId: textStyle3 + - id: dialogue-box + type: rect + x: 100 + y: 836 + width: 1720 + height: 164 + click: + payload: + actions: + nextLine: {} + colorId: layoutColor5 + - id: dialogue-text + type: text + x: 150 + y: 872 + content: ${dialogue.content[0].text} + textStyleId: textStyle11 + dynamicSaveMenuLayout: + name: Dynamic Save Menu + elements: + - id: save-menu-overlay + type: rect + width: 1920 + height: 1080 + x: 0 + y: 0 + opacity: 0.78 + colorId: layoutColor4 + - id: save-menu-bg + type: rect + width: 1360 + height: 760 + x: 280 + y: 160 + colorId: layoutColor5 - id: title type: text - content: Dynamic Save Slots - Selector Test - x: 960 - y: 50 - anchorX: 0.5 - textStyleId: textStyle1 + content: Slots + x: 380 + y: 220 + textStyleId: textStyle4 - id: subtitle type: text content: Page ${variables.loadPage} - x: 960 - y: 100 - anchorX: 0.5 - textStyleId: textStyle2 - - id: pageNav - type: container - x: 960 - y: 180 - anchorX: 0.5 - children: - - id: btnPrevPage - type: text - content: ← Previous Page - x: -150 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle4 - click: - payload: - actions: - updateVariable: - id: uv1 - operations: - - variableId: loadPage - op: decrement - textStyleId: textStyle3 - - id: btnNextPage - type: text - content: Next Page → - x: 150 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle4 - click: - payload: - actions: - updateVariable: - id: uv2 - operations: - - variableId: loadPage - op: increment - textStyleId: textStyle3 + x: 380 + y: 280 + textStyleId: textStyle5 + - id: close-btn + type: rect + width: 140 + height: 56 + x: 1460 + y: 210 + click: + payload: + actions: + clearLayeredViews: {} + colorId: layoutColor3 + - id: close-btn-text + type: text + content: Close + x: 1502 + y: 226 + click: + payload: + actions: + clearLayeredViews: {} + textStyleId: textStyle3 + - id: btnPrevPage + type: text + content: ← Previous Page + x: 400 + y: 350 + hover: + textStyleId: textStyle7 + click: + payload: + actions: + updateVariable: + id: prevPage + operations: + - variableId: loadPage + op: decrement + textStyleId: textStyle6 + - id: btnNextPage + type: text + content: Next Page → + x: 1230 + y: 350 + hover: + textStyleId: textStyle7 + click: + payload: + actions: + updateVariable: + id: nextPage + operations: + - variableId: loadPage + op: increment + textStyleId: textStyle6 - id: slotsGrid type: container direction: horizontal - gap: 30 - width: 720 - x: 960 - y: 300 - anchorX: 0.5 + gap: 28 + width: 1080 + x: 360 + y: 420 children: - $for slot in saveSlots: - id: slot_${slot.slotNumber}_container + id: slot_${slot.slotId}_container type: container - direction: vertical - gap: 10 + width: 320 + height: 250 children: - - id: slot_${slot.slotNumber}_box + - id: slot_${slot.slotId}_box type: rect - width: 200 - height: 150 - hover: - colorId: layoutColor2 + x: 0 + y: 0 + width: 320 + height: 250 click: payload: + _event: + slotId: ${slot.slotId} actions: - saveSaveSlot: - slot: ${slot.slotNumber} + saveSlot: + slotId: "_event.slotId" colorId: layoutColor2 - - id: slot_${slot.slotNumber}_label + - id: slot_${slot.slotId}_title type: text - content: Slot ${slot.slotNumber} - x: 100 - y: 60 - anchorX: 0.5 - anchorY: 0.5 - textStyleId: textStyle5 - - id: slot_${slot.slotNumber}_status + content: Slot ${slot.slotId} + x: 20 + y: 18 + click: + payload: + _event: + slotId: ${slot.slotId} + actions: + saveSlot: + slotId: "_event.slotId" + textStyleId: textStyle8 + - id: slot_${slot.slotId}_preview_empty + $when: "!slot.image" + type: sprite + imageId: savePlaceholder + x: 20 + y: 58 + width: 280 + height: 148 + click: + payload: + _event: + slotId: ${slot.slotId} + actions: + saveSlot: + slotId: "_event.slotId" + - id: slot_${slot.slotId}_preview_saved_${slot.savedAt} + $when: slot.image + type: sprite + imageId: "saveThumbnailImage:${slot.slotId}:${slot.savedAt}" + x: 20 + y: 58 + width: 280 + height: 148 + click: + payload: + _event: + slotId: ${slot.slotId} + actions: + saveSlot: + slotId: "_event.slotId" + - id: slot_${slot.slotId}_status type: text - content: $if{slot.date:Saved:Empty} - x: 100 - y: 90 - anchorX: 0.5 - anchorY: 0.5 - textStyleId: textStyle6 - - id: instructions - type: text - content: Use Previous/Next to change pages. Click slots to save. - x: 960 - y: 650 - anchorX: 0.5 - textStyleId: textStyle7 + x: 20 + y: 216 + content: + $if slot.image: Saved + $else: Empty + click: + payload: + _event: + slotId: ${slot.slotId} + actions: + saveSlot: + slotId: "_event.slotId" + textStyleId: textStyle9 fonts: fontDefault: fileId: Arial @@ -138,65 +297,101 @@ resources: color1: hex: "#FFFFFF" color2: - hex: "#737373" + hex: "#C8C8C8" color3: - hex: "#D9D9D9" + hex: "#B78A3D" color4: - hex: "#A6A6A6" + hex: "#787878" + color5: + hex: "#A3A3A3" layoutColor1: - hex: "#000000" + hex: "#111111" layoutColor2: - hex: "#4D4D4D" + hex: "#2A2A2A" + layoutColor3: + hex: "#4A4A4A" + layoutColor4: + hex: "#050505" + layoutColor5: + hex: "#1B1B1B" textStyles: textStyle1: fontId: fontDefault colorId: color1 - fontSize: 42 + fontSize: 50 fontWeight: bold fontStyle: normal lineHeight: 1.2 textStyle2: fontId: fontDefault colorId: color2 - fontSize: 32 - fontWeight: bold + fontSize: 26 + fontWeight: "400" fontStyle: normal - lineHeight: 1.2 + lineHeight: 1.3 textStyle3: fontId: fontDefault colorId: color1 fontSize: 24 - fontWeight: "400" + fontWeight: bold fontStyle: normal lineHeight: 1.2 textStyle4: fontId: fontDefault - colorId: color2 - fontSize: 24 - fontWeight: "400" + colorId: color1 + fontSize: 46 + fontWeight: bold fontStyle: normal lineHeight: 1.2 textStyle5: fontId: fontDefault - colorId: color3 - fontSize: 20 + colorId: color2 + fontSize: 30 fontWeight: "400" fontStyle: normal lineHeight: 1.2 textStyle6: fontId: fontDefault - colorId: color4 - fontSize: 16 - fontWeight: "400" + colorId: color1 + fontSize: 26 + fontWeight: bold fontStyle: normal lineHeight: 1.2 textStyle7: fontId: fontDefault - colorId: color4 - fontSize: 20 + colorId: color3 + fontSize: 26 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textStyle8: + fontId: fontDefault + colorId: color1 + fontSize: 28 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textStyle9: + fontId: fontDefault + colorId: color2 + fontSize: 22 fontWeight: "400" fontStyle: normal lineHeight: 1.2 + textStyle10: + fontId: fontDefault + colorId: color1 + fontSize: 36 + fontWeight: bold + fontStyle: normal + lineHeight: 1.2 + textStyle11: + fontId: fontDefault + colorId: color1 + fontSize: 28 + fontWeight: "400" + fontStyle: normal + lineHeight: 1.3 story: initialSceneId: main scenes: @@ -209,7 +404,441 @@ story: lines: - id: line1 actions: - control: - resourceId: base1 - layout: - resourceId: dynamicSlotsTestUI + background: + resourceId: readingLayout + updateVariable: + id: line1Preview + operations: + - variableId: previewFrame + op: set + value: "01 / 20" + - variableId: previewTitle + op: set + value: "Rain" + - variableId: previewDetail + op: set + value: "Cold glass. Wet platform." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 01. Rain." + - id: line2 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line2Preview + operations: + - variableId: previewFrame + op: set + value: "02 / 20" + - variableId: previewTitle + op: set + value: "Orange Tram" + - variableId: previewDetail + op: set + value: "Warm light cuts across the room." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 02. A bright orange tram glides past the window and throws warm color across the room." + - id: line3 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line3Preview + operations: + - variableId: previewFrame + op: set + value: "03 / 20" + - variableId: previewTitle + op: set + value: "Lamp Flicker" + - variableId: previewDetail + op: set + value: "Yellow pulse. Short shadow." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 03. Lamp flicker." + - id: line4 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line4Preview + operations: + - variableId: previewFrame + op: set + value: "04 / 20" + - variableId: previewTitle + op: set + value: "Silver Key" + - variableId: previewDetail + op: set + value: "A single key points straight at the menu." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 04. Someone left a silver key beside the notebook, angled exactly toward the save button." + - id: line5 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line5Preview + operations: + - variableId: previewFrame + op: set + value: "05 / 20" + - variableId: previewTitle + op: set + value: "Red Warning" + - variableId: previewDetail + op: set + value: "Panel glow. Sharp contrast." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 05. Red warning." + - id: line6 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line6Preview + operations: + - variableId: previewFrame + op: set + value: "06 / 20" + - variableId: previewTitle + op: set + value: "Tea Steam" + - variableId: previewDetail + op: set + value: "Steam drifts upward and softens the city lights." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 06. Steam curls off the tea cup and briefly fogs the glass in front of the city." + - id: line7 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line7Preview + operations: + - variableId: previewFrame + op: set + value: "07 / 20" + - variableId: previewTitle + op: set + value: "Route Map" + - variableId: previewDetail + op: set + value: "Blue line. Left corner." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 07. Route map." + - id: line8 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line8Preview + operations: + - variableId: previewFrame + op: set + value: "08 / 20" + - variableId: previewTitle + op: set + value: "Platform Eight" + - variableId: previewDetail + op: set + value: "Intercom static repeats the platform call." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 08. The intercom crackles, then repeats a clipped message about platform eight." + - id: line9 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line9Preview + operations: + - variableId: previewFrame + op: set + value: "09 / 20" + - variableId: previewTitle + op: set + value: "Neon Streaks" + - variableId: previewDetail + op: set + value: "Pink line. Wet street." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 09. Neon streaks." + - id: line10 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line10Preview + operations: + - variableId: previewFrame + op: set + value: "10 / 20" + - variableId: previewTitle + op: set + value: "Archive Drawer" + - variableId: previewDetail + value: "Half-open drawer, stamped envelopes, hard shadow." + op: set + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 10. The archive drawer jams halfway open, revealing a stack of stamped envelopes." + - id: line11 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line11Preview + operations: + - variableId: previewFrame + op: set + value: "11 / 20" + - variableId: previewTitle + op: set + value: "Violet Scarf" + - variableId: previewDetail + op: set + value: "Soft fabric. Quiet corner." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 11. Violet scarf." + - id: line12 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line12Preview + operations: + - variableId: previewFrame + op: set + value: "12 / 20" + - variableId: previewTitle + op: set + value: "Skipped Minutes" + - variableId: previewDetail + op: set + value: "The clock jumps forward as if part of the scene was cut." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 12. The clock skips from 11:58 to 12:04 as if six minutes were cut from the scene." + - id: line13 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line13Preview + operations: + - variableId: previewFrame + op: set + value: "13 / 20" + - variableId: previewTitle + op: set + value: "Blueprint" + - variableId: previewDetail + op: set + value: "One folded sheet slips free." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 13. Blueprint slips free." + - id: line14 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line14Preview + operations: + - variableId: previewFrame + op: set + value: "14 / 20" + - variableId: previewTitle + op: set + value: "Low Hum" + - variableId: previewDetail + op: set + value: "Every page turn gets louder when the air goes quiet." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 14. The air conditioner hum drops low enough that every page turn sounds louder." + - id: line15 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line15Preview + operations: + - variableId: previewFrame + op: set + value: "15 / 20" + - variableId: previewTitle + op: set + value: "Drone Lights" + - variableId: previewDetail + op: set + value: "Three white dots. Far ceiling." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 15. Drone lights." + - id: line16 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line16Preview + operations: + - variableId: previewFrame + op: set + value: "16 / 20" + - variableId: previewTitle + op: set + value: "Speaker Blink" + - variableId: previewDetail + op: set + value: "One small icon flashes, then the whole room goes still." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 16. The speaker icon blinks once on the console, then the room falls completely still." + - id: line17 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line17Preview + operations: + - variableId: previewFrame + op: set + value: "17 / 20" + - variableId: previewTitle + op: set + value: "Turn Left" + - variableId: previewDetail + op: set + value: "Painted arrow. Plain wall." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 17. Turn left." + - id: line18 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line18Preview + operations: + - variableId: previewFrame + op: set + value: "18 / 20" + - variableId: previewTitle + op: set + value: "Projector Dust" + - variableId: previewDetail + op: set + value: "Particles drift through the beam and turn the corner hazy." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 18. Dust lifts in the projector beam and makes the whole corner look underwater." + - id: line19 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line19Preview + operations: + - variableId: previewFrame + op: set + value: "19 / 20" + - variableId: previewTitle + op: set + value: "Exit Sign" + - variableId: previewDetail + op: set + value: "Green edge. Dark hall." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 19. Exit sign." + - id: line20 + actions: + background: + resourceId: readingLayout + updateVariable: + id: line20Preview + operations: + - variableId: previewFrame + op: set + value: "20 / 20" + - variableId: previewTitle + op: set + value: "Final Checkpoint" + - variableId: previewDetail + op: set + value: "Quiet room. Ready slot. Last frame." + dialogue: + ui: + resourceId: readingDialogueLayout + characterId: narrator + content: + - text: "Line 20. Final checkpoint. The room is quiet, the save slot is ready, and the frame should look different." diff --git a/vt/specs/save/multi-slot-test.yaml b/vt/specs/save/multi-slot-test.yaml deleted file mode 100644 index 54c5a13..0000000 --- a/vt/specs/save/multi-slot-test.yaml +++ /dev/null @@ -1,554 +0,0 @@ ---- -title: Multi-Slot Save Test ---- -screen: - width: 1920 - height: 1080 - backgroundColor: "#000000" -resources: - variables: - playerName: - type: string - scope: context - default: New Player - playerLevel: - type: number - scope: context - default: 1 - playerGold: - type: number - scope: context - default: 0 - questCompleted: - type: boolean - scope: context - default: false - controls: - base1: - elements: - - id: clickArea - type: rect - width: 1920 - height: 1080 - click: - payload: - actions: - contextNextLine: {} - colorId: layoutColor1 - layouts: - multiSlotTestUI: - name: Multi-Slot Save Test - elements: - - id: title - type: text - content: Multi-Slot Save Test - Runtime Variables - x: 960 - y: 50 - anchorX: 0.5 - textStyleId: textStyle1 - - id: subtitle - type: text - content: Each save slot stores different context variables - x: 960 - y: 100 - anchorX: 0.5 - textStyleId: textStyle2 - - id: currentStateLabel - type: text - content: CURRENT STATE - x: 960 - y: 180 - anchorX: 0.5 - textStyleId: textStyle3 - - id: nameDisplay - type: text - content: "Name: ${variables.playerName}" - x: 300 - y: 240 - textStyleId: textStyle4 - - id: levelDisplay - type: text - content: "Level: ${variables.playerLevel}" - x: 700 - y: 240 - textStyleId: textStyle4 - - id: goldDisplay - type: text - content: "Gold: ${variables.playerGold}" - x: 1000 - y: 240 - textStyleId: textStyle4 - - id: questDisplay - type: text - content: "Quest Done: ${variables.questCompleted}" - x: 1400 - y: 240 - textStyleId: textStyle4 - - id: modifyLabel - type: text - content: MODIFY CURRENT STATE - x: 960 - y: 320 - anchorX: 0.5 - textStyleId: textStyle3 - - id: nameButtons - type: container - x: 300 - y: 380 - children: - - id: nameLabel - type: text - content: "Set Name:" - x: 0 - y: 0 - textStyleId: textStyle5 - - id: btnAlice - type: text - content: Alice - x: 120 - y: 0 - hover: - textStyleId: textStyle7 - click: - payload: - actions: - updateVariable: - id: uv1 - operations: - - variableId: playerName - op: set - value: Alice - textStyleId: textStyle6 - - id: btnBob - type: text - content: Bob - x: 200 - y: 0 - hover: - textStyleId: textStyle7 - click: - payload: - actions: - updateVariable: - id: uv2 - operations: - - variableId: playerName - op: set - value: Bob - textStyleId: textStyle6 - - id: btnCharlie - type: text - content: Charlie - x: 270 - y: 0 - hover: - textStyleId: textStyle7 - click: - payload: - actions: - updateVariable: - id: uv3 - operations: - - variableId: playerName - op: set - value: Charlie - textStyleId: textStyle6 - - id: levelButtons - type: container - x: 700 - y: 380 - children: - - id: levelLabel - type: text - content: "Level:" - x: 0 - y: 0 - textStyleId: textStyle5 - - id: btnLevelUp - type: text - content: "+1" - x: 80 - y: 0 - hover: - textStyleId: textStyle8 - click: - payload: - actions: - updateVariable: - id: uv4 - operations: - - variableId: playerLevel - op: increment - textStyleId: textStyle7 - - id: btnLevelDown - type: text - content: "-1" - x: 130 - y: 0 - hover: - textStyleId: textStyle8 - click: - payload: - actions: - updateVariable: - id: uv5 - operations: - - variableId: playerLevel - op: decrement - textStyleId: textStyle7 - - id: goldButtons - type: container - x: 1000 - y: 380 - children: - - id: goldLabel - type: text - content: "Gold:" - x: 0 - y: 0 - textStyleId: textStyle5 - - id: btnGoldAdd - type: text - content: "+100" - x: 70 - y: 0 - hover: - textStyleId: textStyle9 - click: - payload: - actions: - updateVariable: - id: uv6 - operations: - - variableId: playerGold - op: increment - value: 100 - textStyleId: textStyle7 - - id: btnGoldReset - type: text - content: Reset - x: 140 - y: 0 - hover: - textStyleId: textStyle9 - click: - payload: - actions: - updateVariable: - id: uv7 - operations: - - variableId: playerGold - op: set - value: 0 - textStyleId: textStyle9 - - id: questButton - type: container - x: 1400 - y: 380 - children: - - id: questLabel - type: text - content: "Quest:" - x: 0 - y: 0 - textStyleId: textStyle5 - - id: btnQuestToggle - type: text - content: Toggle - x: 80 - y: 0 - hover: - textStyleId: textStyle8 - click: - payload: - actions: - updateVariable: - id: uv8 - operations: - - variableId: questCompleted - op: toggle - textStyleId: textStyle7 - - id: saveLabel - type: text - content: SAVE TO SLOT - x: 960 - y: 480 - anchorX: 0.5 - textStyleId: textStyle3 - - id: saveSlotButtons - type: container - x: 960 - y: 540 - anchorX: 0.5 - children: - - id: btnSaveSlot1 - type: text - content: Save Slot 1 - x: -300 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - saveSaveSlot: - slot: 1 - textStyleId: textStyle10 - - id: btnSaveSlot2 - type: text - content: Save Slot 2 - x: -100 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - saveSaveSlot: - slot: 2 - textStyleId: textStyle10 - - id: btnSaveSlot3 - type: text - content: Save Slot 3 - x: 100 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - saveSaveSlot: - slot: 3 - textStyleId: textStyle10 - - id: btnSaveSlot4 - type: text - content: Save Slot 4 - x: 300 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - saveSaveSlot: - slot: 4 - textStyleId: textStyle10 - - id: loadLabel - type: text - content: LOAD FROM SLOT - x: 960 - y: 640 - anchorX: 0.5 - textStyleId: textStyle3 - - id: loadSlotButtons - type: container - x: 960 - y: 700 - anchorX: 0.5 - children: - - id: btnLoadSlot1 - type: text - content: Load Slot 1 - x: -300 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - loadSaveSlot: - slot: 1 - textStyleId: textStyle10 - - id: btnLoadSlot2 - type: text - content: Load Slot 2 - x: -100 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - loadSaveSlot: - slot: 2 - textStyleId: textStyle10 - - id: btnLoadSlot3 - type: text - content: Load Slot 3 - x: 100 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - loadSaveSlot: - slot: 3 - textStyleId: textStyle10 - - id: btnLoadSlot4 - type: text - content: Load Slot 4 - x: 300 - y: 0 - anchorX: 0.5 - hover: - textStyleId: textStyle11 - click: - payload: - actions: - loadSaveSlot: - slot: 4 - textStyleId: textStyle10 - - id: instructions - type: text - content: |- - 1. Modify the current state (name, level, gold, quest) - 2. Save to a slot - 3. Modify state again differently - 4. Save to another slot - 5. Load different slots to see each has its own context variables! - x: 960 - y: 850 - anchorX: 0.5 - textStyleId: textStyle12 - - id: devToolsHint - type: text - content: "Open DevTools Console and type: - JSON.parse(localStorage.getItem('saveSlots'))" - x: 960 - y: 1000 - anchorX: 0.5 - textStyleId: textStyle13 - fonts: - fontDefault: - fileId: Arial - colors: - color1: - hex: "#FFFFFF" - color2: - hex: "#A6A6A6" - color3: - hex: "#737373" - color4: - hex: "#D9D9D9" - color5: - hex: "#4D4D4D" - layoutColor1: - hex: "#000000" - textStyles: - textStyle1: - fontId: fontDefault - colorId: color1 - fontSize: 42 - fontWeight: bold - fontStyle: normal - lineHeight: 1.2 - textStyle2: - fontId: fontDefault - colorId: color2 - fontSize: 24 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle3: - fontId: fontDefault - colorId: color3 - fontSize: 28 - fontWeight: bold - fontStyle: normal - lineHeight: 1.2 - textStyle4: - fontId: fontDefault - colorId: color4 - fontSize: 26 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle5: - fontId: fontDefault - colorId: color2 - fontSize: 22 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle6: - fontId: fontDefault - colorId: color1 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle7: - fontId: fontDefault - colorId: color3 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle8: - fontId: fontDefault - colorId: color5 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle9: - fontId: fontDefault - colorId: color2 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle10: - fontId: fontDefault - colorId: color1 - fontSize: 24 - fontWeight: bold - fontStyle: normal - lineHeight: 1.2 - textStyle11: - fontId: fontDefault - colorId: color3 - fontSize: 24 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - textStyle12: - fontId: fontDefault - colorId: color2 - fontSize: 20 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - align: center - textStyle13: - fontId: fontDefault - colorId: color5 - fontSize: 18 - fontWeight: "400" - fontStyle: normal - lineHeight: 1.2 - align: center -story: - initialSceneId: main - scenes: - main: - name: Multi-Slot Save Scene - initialSectionId: section1 - sections: - section1: - name: Multi-Slot Test Section - lines: - - id: line1 - actions: - control: - resourceId: base1 - layout: - resourceId: multiSlotTestUI diff --git a/vt/static/main.js b/vt/static/main.js index 7aab199..fb5dd2c 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -1,6 +1,7 @@ import { parse } from "https://cdn.jsdelivr.net/npm/yaml@2.7.1/+esm"; import createRouteEngine, { createEffectsHandler } from "./RouteEngine.js"; import { Ticker } from "https://cdn.jsdelivr.net/npm/pixi.js@8.0.0/+esm"; +import { createSaveThumbnailAssetId } from "./saveSlotUtils.js"; import createRouteGraphics, { createAssetBufferManager, @@ -14,8 +15,8 @@ import createRouteGraphics, { soundPlugin, videoPlugin, particlesPlugin, - animatedSpritePlugin -} from "./RouteGraphics.js" + animatedSpritePlugin, +} from "./RouteGraphics.js"; const projectData = parse(window.yamlContent); @@ -23,11 +24,11 @@ const init = async () => { const screenWidth = projectData?.screen?.width ?? 1920; const screenHeight = projectData?.screen?.height ?? 1080; const assets = { - "lakjf3lka": { + lakjf3lka: { url: "/public/bg/door.png", type: "image/png", }, - "dmni32": { + dmni32: { url: "/public/bg/forest.png", type: "image/png", }, @@ -35,31 +36,31 @@ const init = async () => { url: "/public/bg/moon.png", type: "image/png", }, - "la3lka": { + la3lka: { url: "/public/circle-blue.png", type: "image/png", }, - "a32kf3": { + a32kf3: { url: "/public/circle-green.png", type: "image/png", }, - "x342fga": { + x342fga: { url: "/public/circle-green-small.png", type: "image/png", }, - "char_sprite_1": { + char_sprite_1: { url: "/public/characters/sprite-1-1.png", type: "image/png", }, - "char_sprite_2": { + char_sprite_2: { url: "/public/characters/sprite-1-2.png", type: "image/png", }, - "char_sprite_3": { + char_sprite_3: { url: "/public/characters/sprite-2-1.png", type: "image/png", }, - "char_sprite_4": { + char_sprite_4: { url: "/public/characters/sprite-2-2.png", type: "image/png", }, @@ -75,11 +76,11 @@ const init = async () => { url: "/public/bgm-1.mp3", type: "audio/mpeg", }, - "xk393": { + xk393: { url: "/public/bgm-2.mp3", type: "audio/mpeg", }, - "xj323": { + xj323: { url: "/public/sfx-1.mp3", type: "audio/mpeg", }, @@ -87,37 +88,37 @@ const init = async () => { url: "/public/sfx-2.wav", type: "audio/wav", }, - "vertical_hover_bar": { + vertical_hover_bar: { url: "/public/vertical_hover_bar.png", - type: "image/png" + type: "image/png", }, - "vertical_hover_thumb": { + vertical_hover_thumb: { url: "/public/vertical_hover_thumb.png", - type: "image/png" + type: "image/png", }, - "vertical_idle_bar": { + vertical_idle_bar: { url: "/public/vertical_idle_bar.png", - type: "image/png" + type: "image/png", }, - "vertical_idle_thumb": { + vertical_idle_thumb: { url: "/public/vertical_idle_thumb.png", - type: "image/png" + type: "image/png", }, - "horizontal_hover_bar": { + horizontal_hover_bar: { url: "/public/horizontal_hover_bar.png", - type: "image/png" + type: "image/png", }, - "horizontal_hover_thumb": { + horizontal_hover_thumb: { url: "/public/horizontal_hover_thumb.png", - type: "image/png" + type: "image/png", }, - "horizontal_idle_bar": { + horizontal_idle_bar: { url: "/public/horizontal_idle_bar.png", - type: "image/png" + type: "image/png", }, - "horizontal_idle_thumb": { + horizontal_idle_thumb: { url: "/public/horizontal_idle_thumb.png", - type: "image/png" + type: "image/png", }, "fighter-spritesheet": { url: "/public/fighter.png", @@ -131,11 +132,11 @@ const init = async () => { if (!window?.RTGL_VT_DEBUG) { Object.assign(assets, { - "video_sample": { + video_sample: { url: "/public/video_sample.mp4", - type: "video/mp4" - } - }) + type: "video/mp4", + }, + }); } const assetBufferManager = createAssetBufferManager(); @@ -143,7 +144,6 @@ const init = async () => { const assetBufferMap = assetBufferManager.getBufferMap(); const routeGraphics = createRouteGraphics(); - window.takeVtScreenshotBase64 = async (label) => { if (label) { return await routeGraphics.extractBase64(label); @@ -162,14 +162,10 @@ const init = async () => { textRevealingPlugin, videoPlugin, particlesPlugin, - animatedSpritePlugin - ], - animations: [ - tweenPlugin + animatedSpritePlugin, ], - audio: [ - soundPlugin - ] + animations: [tweenPlugin], + audio: [soundPlugin], }; // Create dedicated ticker for auto mode @@ -201,16 +197,41 @@ const init = async () => { const routeGraphicsEventHandler = effectsHandler.createRouteGraphicsEventHandler({ preprocessPayload: async (eventName, payload) => { - if (payload?.actions?.saveSaveSlot) { - const url = await routeGraphics.extractBase64("story"); + const saveAction = payload?.actions?.saveSlot; + if (saveAction) { + const saveTimestamp = Date.now(); + let url; + + try { + // Capture only the story container so the save menu itself does not + // become the slot thumbnail. + url = await routeGraphics.extractBase64("story"); + } catch { + url = routeGraphics.canvas.toDataURL("image/png"); + } const assets = { - [`saveThumbnailImage:${payload.actions.saveSaveSlot.slot}`]: { + [createSaveThumbnailAssetId( + saveAction.slotId, + saveTimestamp, + payload, + )]: { buffer: base64ToArrayBuffer(url), type: "image/png", }, }; await routeGraphics.loadAssets(assets); - payload.actions.saveSaveSlot.thumbnailImage = url; + + return { + ...payload, + actions: { + ...payload.actions, + saveSlot: { + ...saveAction, + thumbnailImage: url, + savedAt: saveTimestamp, + }, + }, + }; } return payload; @@ -220,17 +241,19 @@ const init = async () => { }, }); + window.__vtHandleRouteGraphicsEvent = routeGraphicsEventHandler; + await routeGraphics.init({ width: screenWidth, height: screenHeight, plugins, eventHandler: routeGraphicsEventHandler, onFirstRender: () => { - window.dispatchEvent(new CustomEvent('vt:ready')); + window.dispatchEvent(new CustomEvent("vt:ready")); }, debug: window?.RTGL_VT_DEBUG ?? false, }); - await routeGraphics.loadAssets(assetBufferMap) + await routeGraphics.loadAssets(assetBufferMap); const canvasHost = document.getElementById("canvas"); canvasHost.appendChild(routeGraphics.canvas); @@ -240,25 +263,28 @@ const init = async () => { engine = createRouteEngine({ handlePendingEffects: effectsHandler }); const saveSlots = JSON.parse(localStorage.getItem("saveSlots")) || {}; - const globalDeviceVariables = JSON.parse(localStorage.getItem("globalDeviceVariables")) || {}; - const globalAccountVariables = JSON.parse(localStorage.getItem("globalAccountVariables")) || {}; + const globalDeviceVariables = + JSON.parse(localStorage.getItem("globalDeviceVariables")) || {}; + const globalAccountVariables = + JSON.parse(localStorage.getItem("globalAccountVariables")) || {}; engine.init({ initialState: { global: { saveSlots, - variables: { ...globalDeviceVariables, ...globalAccountVariables } + variables: { ...globalDeviceVariables, ...globalAccountVariables }, }, - projectData + projectData, }, }); + window.__vtEngine = engine; + window.addEventListener("vt:nextLine", () => { engine.handleActions({ nextLine: {}, }); }); - }; await init(); diff --git a/vt/static/saveSlotUtils.js b/vt/static/saveSlotUtils.js new file mode 100644 index 0000000..8cceb99 --- /dev/null +++ b/vt/static/saveSlotUtils.js @@ -0,0 +1,25 @@ +export const resolveSaveSlotId = (slotId, eventPayload = {}) => { + if (typeof slotId !== "string" || !slotId.startsWith("_event.")) { + return slotId; + } + + const eventData = eventPayload?._event ?? eventPayload?.event; + if (!eventData) { + return slotId; + } + + const resolvedSlotId = slotId + .slice("_event.".length) + .split(".") + .reduce((currentValue, segment) => currentValue?.[segment], eventData); + + return resolvedSlotId ?? slotId; +}; + +export const createSaveThumbnailAssetId = ( + slotId, + savedAt, + eventPayload = {}, +) => { + return `saveThumbnailImage:${resolveSaveSlotId(slotId, eventPayload)}:${savedAt}`; +};