auu
This commit is contained in:
parent
d995726462
commit
0a343ecbc5
326 changed files with 15584 additions and 0 deletions
BIN
docs/bend-field-performance-solutions.docx
Normal file
BIN
docs/bend-field-performance-solutions.docx
Normal file
Binary file not shown.
91
docs/client-guide.md
Normal file
91
docs/client-guide.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Bend Field: Player Guide
|
||||
|
||||
## Goal
|
||||
|
||||
Complete each puzzle by drawing lines between gates until every playable cell is covered.
|
||||
|
||||
A valid solution must satisfy all of these rules:
|
||||
|
||||
- Every open gate belongs to exactly one completed line.
|
||||
- Lines do not overlap, except where a crossing cell explicitly permits it.
|
||||
- Every playable cell is covered.
|
||||
- A numbered cell must lie on a line whose total number of bends equals that number.
|
||||
- Obstacles are blocked cells and must remain empty.
|
||||
|
||||
## Drawing and reconnecting lines
|
||||
|
||||
- Left-drag from an open gate to start a line.
|
||||
- Drag a pickup at the open end of a line to continue drawing.
|
||||
- Drag a pickup near another pickup to join the two lines.
|
||||
- Drag a pickup near a compatible gate to connect it.
|
||||
- A short straight gap of up to one empty cell is completed automatically when snapping to a pickup or gate.
|
||||
- Drag the gate end of an unfinished line to detach it; the isolated line can then be moved from either pickup.
|
||||
- Drag backward over the active line to erase its latest cells.
|
||||
- Use **Reset** to clear the current unsolved puzzle.
|
||||
|
||||
Snapping never crosses an occupied cell or obstacle and never invents a route around a corner.
|
||||
|
||||
## Field navigation
|
||||
|
||||
- Left-drag an already solved puzzle or undiscovered field space to pan.
|
||||
- Right-drag anywhere on the field to pan.
|
||||
- Use the mouse wheel or trackpad gesture to zoom.
|
||||
- At distant zoom levels, the field uses the same borderless cell-and-long-line rendering as the minimap.
|
||||
- Shops are opened from a visible board, not from the distant overview.
|
||||
|
||||
## Puzzle elements
|
||||
|
||||
- **Gate:** A line endpoint on the outer edge of a puzzle.
|
||||
- **Pickup:** The draggable open end of a line.
|
||||
- **Number:** The required total bend count for its line.
|
||||
- **Obstacle:** A blocked cell marked with a cross.
|
||||
- **Warp:** Moves a line directly to its paired warp cell.
|
||||
- **Key and door:** A line must collect its key before passing through the matching door.
|
||||
- **Crossing:** Allows one horizontal and one vertical line to cross.
|
||||
|
||||
Large puzzles contain obstacles somewhat more often, but obstacles never exceed 20% of all puzzle cells.
|
||||
|
||||
## Score and difficulty
|
||||
|
||||
Scores are based on puzzle level, connected-line thickness, and puzzle size.
|
||||
|
||||
Long connected lines become thicker and award more points. Higher-level puzzles receive a strong difficulty multiplier, and multi-section puzzles receive an additional size multiplier. As a result, a hard, large puzzle awards substantially more than a small, easy puzzle. Score Lens items can show a projected reward before completion.
|
||||
|
||||
## Shops and items
|
||||
|
||||
A solved line may reveal a shop. Each shop offers a deterministic selection of cursor designs and one utility item.
|
||||
|
||||
- **Score Lens:** Shows a projected reward for an unsolved puzzle. Its base price is 200,000 gems.
|
||||
- **Emoji or flag cursor:** Changes the pointer appearance.
|
||||
|
||||
Difficulty-adjustment field items are not part of the current game. Item prices and purchases are validated by the shared-world server when online.
|
||||
|
||||
## Time Attack
|
||||
|
||||
Choose a time limit and solve as many puzzles as possible before the clock expires. The clock is emphasized during the first five seconds, and the active reward multiplier is applied when each puzzle is solved.
|
||||
|
||||
## Shared world and saving
|
||||
|
||||
When the game is opened through `server.js`, every player explores the same generated field.
|
||||
|
||||
- Cleared boards and newly generated boards are shared.
|
||||
- The first accepted solver's name is shown on the cleared board.
|
||||
- Recent clears appear above the minimap with coordinates and level.
|
||||
- Unfinished lines and camera state remain local. Player score, purchases, name, and equipped cursor belong to the individual player rather than the shared world.
|
||||
|
||||
Progress is saved automatically. Most synchronization runs in the background, but a completed solution is verified by the server before shared expansion is finalized. If expansion keeps trying for at least eight seconds and still leaves an unresolved new-board frontier, the solver receives a one-time level-6-equivalent bonus. Use the gear button to change the player name and presentation settings; use the shared-status button to restore an existing synchronization code.
|
||||
|
||||
|
||||
## Shared-world play
|
||||
|
||||
- Other nearby players appear as named cursors. Their positions are shown as small dots on the minimap.
|
||||
- Grabbing or operating an unfinished board requests exclusive control of that board.
|
||||
- A board occupied by another player shows their name and cannot be played until released.
|
||||
- Control expires after five minutes without board operation. A disconnected player follows the same timeout.
|
||||
- Starting play on another board releases the previous board.
|
||||
- The first accepted solver name remains displayed on a cleared board.
|
||||
|
||||
|
||||
## Settings
|
||||
|
||||
Open the gear button to change the player name, enable lightweight rendering, or turn sound effects on and off. Lightweight rendering disables selected background, glow, and particle effects without changing puzzle rules.
|
||||
676
docs/field-save-load-design.md
Normal file
676
docs/field-save-load-design.md
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
# Bend Field: Efficient Field Save and Load Design
|
||||
|
||||
## Purpose
|
||||
|
||||
This document proposes a scalable persistence system for a complete Bend Field
|
||||
world. It covers two related but separate problems:
|
||||
|
||||
1. Loading a large local field quickly enough that the player can begin using it
|
||||
before every puzzle has been read.
|
||||
2. Exporting and restoring the complete field without creating several
|
||||
whole-field copies in memory.
|
||||
|
||||
The recommended design preserves the existing incremental autosave behavior,
|
||||
introduces an index-first local repository, and adds a streamed portable archive.
|
||||
|
||||
## Current system assessment
|
||||
|
||||
IndexedDB is already the durable local source of truth. Normal gameplay saves are
|
||||
incremental: only dirty board metadata, dirty board states, global data,
|
||||
tombstones, recovery information, and cloud-outbox changes are committed. This
|
||||
part should be retained.
|
||||
|
||||
The main scaling problems are elsewhere:
|
||||
|
||||
- Startup calls `getAll()` for all metadata and state rows, constructs a complete
|
||||
snapshot, and normalizes every puzzle before gameplay begins.
|
||||
- `hydrateMeta()` does not currently load anything from storage. It only checks
|
||||
that an already loaded puzzle exists.
|
||||
- The compact localStorage mirror is useful for small-field recovery but is
|
||||
intentionally limited to about 4.5 MiB and cannot represent a very large
|
||||
field.
|
||||
- JSON export constructs a complete snapshot, pretty-printed JSON string, and
|
||||
Blob in memory.
|
||||
- The current export payload can contain cloud credentials, cloud transport
|
||||
state, session identity, and recovery data. These are local persistence
|
||||
details and must not be portable.
|
||||
- JSON import reads and parses the complete file in memory, then clears and
|
||||
repopulates the active stores.
|
||||
- The current metadata and state stores are keyed only by board ID. A second
|
||||
complete field cannot be staged beside the active field, so an atomic pointer
|
||||
switch is not possible with the present keys.
|
||||
- The original cloud implementation used a complete snapshot for pulls and one
|
||||
whole-world JSON file on the server.
|
||||
|
||||
The local repository and portable backup were implemented first. The follow-up
|
||||
cloud implementation now batches pushes, pages pulls, returns recent revision
|
||||
deltas, and stores board revisions in separate crash-safe files.
|
||||
|
||||
## Design goals
|
||||
|
||||
- Preserve the existing low-cost dirty-row autosave path.
|
||||
- Make the origin board or current viewport usable before a full field scan
|
||||
completes.
|
||||
- Avoid loading every puzzle definition and path collection during startup.
|
||||
- Keep archive memory use independent of total field size.
|
||||
- Make restore strict, cancelable, and all-or-nothing.
|
||||
- Never place cloud credentials, recovery journals, or session identity in a
|
||||
portable backup.
|
||||
- Make reset, import, migration, and rollback use the same safe epoch-switching
|
||||
primitive.
|
||||
- Keep old JSON saves importable through explicit compatibility rules.
|
||||
- Preserve multi-tab protection and reject stale writers after a field switch.
|
||||
|
||||
## Version boundaries
|
||||
|
||||
The following versions serve different purposes and must not share a counter:
|
||||
|
||||
- **Archive version** describes the portable `.bfsave` wire format.
|
||||
- **Save schema** describes gameplay data and its migrations.
|
||||
- **IndexedDB layout version** describes object stores and indexes.
|
||||
- **World generation** identifies compatible generated-world rules.
|
||||
- **Application version** identifies the build that produced an archive.
|
||||
|
||||
The proposed portable archive is version 2. The proposed IndexedDB layout is
|
||||
version 6. Upgrading either one must not implicitly change the other.
|
||||
|
||||
The existing physical database name should be reused for the version-6
|
||||
migration. Future database names should not be derived from the save-schema
|
||||
number.
|
||||
|
||||
## Local repository design
|
||||
|
||||
### Object stores
|
||||
|
||||
The version-6 upgrade creates the following stores while retaining the current
|
||||
version-5 stores for migration:
|
||||
|
||||
- `control`, keyed by a singleton key.
|
||||
- `worlds`, keyed by `epoch`.
|
||||
- `boardIndex`, `boardPuzzles`, `boardStates`, and `tombstonesV2`, keyed by
|
||||
`[epoch, id]`.
|
||||
- `recoveryV2` and `outboxV2`, keyed by `[epoch, key]`.
|
||||
|
||||
Every epoch-scoped store has an `epoch` index so an abandoned field can be
|
||||
deleted in bounded batches.
|
||||
|
||||
Conceptual records:
|
||||
|
||||
```ts
|
||||
interface WorldControl {
|
||||
key: "active";
|
||||
activeFormat: 1 | 2;
|
||||
activeEpoch: string;
|
||||
previousEpoch?: string;
|
||||
activationId: string;
|
||||
activationVerified: boolean;
|
||||
switchedAt: number;
|
||||
}
|
||||
|
||||
interface WorldRecord {
|
||||
epoch: string;
|
||||
status: "staging" | "ready" | "active" | "rollback" | "garbage";
|
||||
schema: number;
|
||||
worldGeneration: string;
|
||||
global: PortableAndLocalGlobalState;
|
||||
boardCount: number;
|
||||
solvedCount: number;
|
||||
score: number;
|
||||
bounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||||
approximateBytes: number;
|
||||
createdAt: number;
|
||||
activatedAt?: number;
|
||||
source?: {
|
||||
kind: "migration" | "import" | "reset";
|
||||
archiveCrc32?: string;
|
||||
};
|
||||
progress?: {
|
||||
phase: string;
|
||||
lastKey?: IDBValidKey;
|
||||
rows: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface BoardIndexRecord {
|
||||
epoch: string;
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
chunks: [number, number][];
|
||||
level: number;
|
||||
targetLevel: number;
|
||||
seed: number;
|
||||
axis: string;
|
||||
entrySide: "N" | "S" | "W" | "E" | null;
|
||||
metaRev: number;
|
||||
stateRev: number;
|
||||
solved: boolean;
|
||||
expanded: boolean;
|
||||
scoreAwarded: number;
|
||||
hasProgress: boolean;
|
||||
specialFlags: {
|
||||
crossing: boolean;
|
||||
warp: boolean;
|
||||
lock: boolean;
|
||||
};
|
||||
shop: null | {
|
||||
cell: [number, number] | null;
|
||||
itemIds: string[];
|
||||
purchases: CompactPurchaseSummary[];
|
||||
};
|
||||
}
|
||||
|
||||
interface BoardPuzzleRecord {
|
||||
epoch: string;
|
||||
id: string;
|
||||
metaRev: number;
|
||||
revAuthor: string;
|
||||
generatorVersion: number;
|
||||
puzzle: StoredPuzzle;
|
||||
}
|
||||
|
||||
interface BoardStateRecord {
|
||||
epoch: string;
|
||||
id: string;
|
||||
stateRev: number;
|
||||
revAuthor: string;
|
||||
value: StoredBoardState;
|
||||
}
|
||||
```
|
||||
|
||||
`BoardIndexRecord` is the source for occupancy, distant rendering, solved
|
||||
totals, shop icons, and inventory summaries. `summarizeBoard(meta, state)` is
|
||||
the only function that creates it. Any transaction that changes a field used by
|
||||
the summary must update the index in the same transaction.
|
||||
|
||||
Puzzle records remain separate from state records so drawing a path does not
|
||||
rewrite the static puzzle definition.
|
||||
|
||||
### Startup and hydration
|
||||
|
||||
Startup proceeds in this order:
|
||||
|
||||
1. Open IndexedDB and read `WorldControl`, the active `WorldRecord`, recovery
|
||||
coverage, and the cloud outbox header.
|
||||
2. Read B0 and the saved camera/selected-board area first. If no camera anchor is
|
||||
available, use B0.
|
||||
3. Build initial occupancy and presentation from the first index page.
|
||||
4. Mark the application ready once B0 and the initial viewport details are
|
||||
hydrated.
|
||||
5. Continue scanning `boardIndex` in 512-record pages and progressively add
|
||||
occupancy and overview summaries.
|
||||
|
||||
Puzzle and state stores must never use an unbounded `getAll()` during startup.
|
||||
Page scans resume from the final compound key of the previous page.
|
||||
|
||||
The current hydration contract is replaced by:
|
||||
|
||||
```ts
|
||||
async function hydrateBoardDetails(
|
||||
epoch: string,
|
||||
id: string,
|
||||
expected?: { metaRev: number; stateRev: number }
|
||||
): Promise<HydratedBoard>
|
||||
```
|
||||
|
||||
It reads the index, puzzle, and state in one readonly transaction. Missing rows
|
||||
or revision mismatches are rejected rather than combined.
|
||||
|
||||
Details are hydrated for:
|
||||
|
||||
- Boards entering the interactive viewport and its prefetch margin.
|
||||
- The selected or keyboard-focused board.
|
||||
- Boards on both sides of an active gate connection.
|
||||
- Boards involved in pending expansion or repair work.
|
||||
- A shop before it opens or an inventory item before it is consumed.
|
||||
|
||||
The detail cache is an LRU limited to 128 boards or 32 MiB, whichever is reached
|
||||
first. Active pointer boards, dirty boards, time-attack boards, and boards still
|
||||
referenced by a renderer or editor are pinned. A dirty board must be committed
|
||||
before it can be evicted.
|
||||
|
||||
Full-field score, inventory, minimap, shop-marker, and distant-overview loops
|
||||
must use index summaries. They must not hydrate every board as a side effect.
|
||||
|
||||
### Commit and concurrency rules
|
||||
|
||||
All canonical field writes use one repository entry point:
|
||||
|
||||
```ts
|
||||
async function commitFieldDelta(delta: FieldDelta): Promise<CommitResult>
|
||||
```
|
||||
|
||||
The transaction:
|
||||
|
||||
1. Reads `WorldControl`.
|
||||
2. Verifies both the expected active epoch and active format.
|
||||
3. Applies puzzle, state, summary, global, recovery, tombstone, and outbox
|
||||
changes.
|
||||
4. Commits the updated revisions together.
|
||||
|
||||
An epoch or format mismatch aborts with `STALE_WORLD_EPOCH`.
|
||||
|
||||
The existing Web Lock and storage-lease fallback remain the cross-tab mutation
|
||||
guard. BroadcastChannel and storage events are only wake-up signals; receivers
|
||||
must re-read authoritative control and revision records from IndexedDB.
|
||||
|
||||
Compression, file writes, fetches, timers, worker replies, and rendering yields
|
||||
must not be awaited inside an IndexedDB transaction. Browsers may auto-commit a
|
||||
transaction when it has no pending IndexedDB request. Data should be parsed,
|
||||
normalized, summarized, and encoded before its bounded transaction is opened.
|
||||
|
||||
### Recovery
|
||||
|
||||
IndexedDB remains authoritative. LocalStorage retains only:
|
||||
|
||||
- The active-epoch hint.
|
||||
- A bounded dirty-row recovery journal.
|
||||
- Cross-tab wake-up data.
|
||||
|
||||
It no longer attempts to mirror the entire version-2 field. Recovery records
|
||||
remain epoch-scoped and cannot be applied after an import, reset, or rollback
|
||||
switches to another epoch.
|
||||
|
||||
## Portable archive
|
||||
|
||||
### Format
|
||||
|
||||
The default filename is:
|
||||
|
||||
```text
|
||||
bend-field-save-YYYY-MM-DD.bfsave
|
||||
```
|
||||
|
||||
The payload is UTF-8 NDJSON. It is wrapped in one gzip stream when
|
||||
`CompressionStream("gzip")` is available and remains uncompressed otherwise.
|
||||
The importer detects the gzip magic bytes and verifies that the decoded
|
||||
manifest declares the same compression mode.
|
||||
|
||||
Decoded records have this exact order:
|
||||
|
||||
```json
|
||||
{"type":"manifest","format":"bend-field-save","archiveVersion":2,"saveSchema":31,"gameplayVersion":3,"worldGeneration":"...","appVersion":"...","generatorVersion":1,"exportedAt":"...","boardCount":42,"estimatedRawBytes":123456,"encoding":"ndjson","compression":"gzip"}
|
||||
{"type":"global","value":{}}
|
||||
{"type":"board","id":"B0","meta":{},"state":{}}
|
||||
{"type":"board","id":"B1","meta":{},"state":{}}
|
||||
{"type":"end","boardCount":42,"rawBytes":123456,"crc32":"12ab34cd"}
|
||||
```
|
||||
|
||||
Board records are sorted by numeric board ID. Exactly one global record and one
|
||||
footer are required. No record is pretty-printed.
|
||||
|
||||
The footer CRC32 covers every decoded UTF-8 byte before the footer, including
|
||||
record newlines. Gzip validation, CRC32, deterministic ordering, byte counts,
|
||||
and board counts detect accidental corruption and truncation. They do not
|
||||
authenticate the archive's author.
|
||||
|
||||
Version 2 intentionally has no random-access index, encryption, signature, or
|
||||
per-block hash. Import consumes every record in order, so a custom container
|
||||
would add complexity without improving the current use case.
|
||||
|
||||
### Portable allowlist
|
||||
|
||||
Portable board metadata includes geometry, levels, seed, puzzle definition,
|
||||
generator version, and connection-related data. Portable board state includes
|
||||
paths, special progress, solved/expanded status, rewards, and shop purchases.
|
||||
|
||||
Portable global state includes gameplay version, bonus events,
|
||||
encountered mechanics, last-solve information, time-attack gameplay state, and
|
||||
the trusted clock floor.
|
||||
|
||||
The importer recomputes `nextId`, solved count, total score, bonus score, field
|
||||
bounds, board summaries, and storage-size estimates.
|
||||
|
||||
The following data is always excluded:
|
||||
|
||||
- `cloudProfile`, player ID, token, cloud revision, cloud pending data, and
|
||||
outbox rows.
|
||||
- Recovery envelopes, journals, coverage markers, and tombstones.
|
||||
- `worldEpoch`, `sessionId`, revisions' author identities, and transport
|
||||
revision state.
|
||||
- Origin URL.
|
||||
- Quarantine diagnostics, debug flags, performance data, and transient caches.
|
||||
- Cosmetic preferences that are not part of the field.
|
||||
|
||||
Portable records omit local revisions and authors. Import assigns a fresh epoch
|
||||
and fresh local revisions.
|
||||
|
||||
### Export pipeline
|
||||
|
||||
The export button obtains its destination synchronously while it still has user
|
||||
activation. Destination priority is:
|
||||
|
||||
1. `showSaveFilePicker()` and a direct `FileSystemWritableFileStream`.
|
||||
2. An OPFS temporary file whose resulting `File` is downloaded.
|
||||
3. An in-memory Blob only when the projected archive is at most 64 MiB.
|
||||
|
||||
All capabilities are feature-detected. Lack of gzip support selects identity
|
||||
encoding; it does not change the archive record format.
|
||||
|
||||
After a destination is available, export:
|
||||
|
||||
1. Flushes pending field writes and aborts if the flush fails.
|
||||
2. Acquires the exclusive world lock.
|
||||
3. Captures the active epoch, global record, board count, and size estimate.
|
||||
4. Reads board IDs in deterministic pages using short readonly transactions.
|
||||
5. Encodes records through a worker and writes them with stream backpressure.
|
||||
6. Writes the footer, closes the destination, and releases the lock.
|
||||
|
||||
Canonical database writes are queued while the lock is held. The archive
|
||||
therefore represents the committed field at export start. The UI may allow
|
||||
navigation but must pause puzzle edits, purchases, field placement, reset, and
|
||||
time-attack mutations until export finishes or is canceled.
|
||||
|
||||
Worker input uses approximately 1 MiB chunks with at most two chunks in flight.
|
||||
The worker performs JSON serialization, parsing, and CRC work. Native streams
|
||||
perform compression and decompression.
|
||||
|
||||
## Import and activation
|
||||
|
||||
### Inspection
|
||||
|
||||
`inspectFieldArchive(file, { signal })` reads enough of the file to display:
|
||||
|
||||
- Archive and save-schema versions.
|
||||
- Export date.
|
||||
- World generation.
|
||||
- Board count.
|
||||
- Estimated raw size.
|
||||
- Compression mode.
|
||||
|
||||
Inspection does not trust the declared values and does not activate anything.
|
||||
The complete stream is validated during staging.
|
||||
|
||||
### Limits
|
||||
|
||||
Version-2 import enforces:
|
||||
|
||||
- Compressed file size at most 1 GiB.
|
||||
- Decoded content at most 2 GiB.
|
||||
- A single decoded record at most 8 MiB.
|
||||
- Expansion ratio at most 100:1.
|
||||
- At most `MAX_BOARDS`, currently 200,000.
|
||||
- Existing board-coordinate, chunk-shape, puzzle-cell, and path-count limits.
|
||||
- Write batches of at most 256 boards or 4 MiB.
|
||||
|
||||
`navigator.storage.estimate()` provides an advisory preflight. Import requests
|
||||
approximately twice the estimated staged size plus 32 MiB of headroom because
|
||||
the old and new fields coexist during validation. The actual IndexedDB result
|
||||
remains authoritative: `QuotaExceededError` aborts staging without deleting the
|
||||
active field.
|
||||
|
||||
Large or version-2 restores require IndexedDB. There is no destructive
|
||||
localStorage-only fallback.
|
||||
|
||||
### Strict validation
|
||||
|
||||
Each record is treated as untrusted data. Version-2 restore fails closed for:
|
||||
|
||||
- Invalid archive identity, ordering, or unsupported version.
|
||||
- A schema/world-generation pair without an explicit migration.
|
||||
- Duplicate or noncanonical board IDs.
|
||||
- Missing or invalid B0.
|
||||
- Missing metadata or state.
|
||||
- Invalid puzzle bounds, gates, cells, paths, stores, or references.
|
||||
- Overlapping world chunks.
|
||||
- Board, byte, line, cell, path, or expansion-ratio limits.
|
||||
- A missing footer or incorrect board count, byte count, or CRC.
|
||||
|
||||
Unlike current tolerant snapshot normalization, a damaged version-2 archive
|
||||
never silently drops a board.
|
||||
|
||||
Validation maintains occupied chunks and derived totals while records are
|
||||
staged. After the final record, a database pass verifies staged counts,
|
||||
revisions, summary/detail agreement, B0 hydration, and global aggregates.
|
||||
|
||||
`importFieldArchive()` stops after producing a completely validated `ready`
|
||||
epoch. It never changes the active pointer; activation is a separate operation
|
||||
after the replacement confirmation.
|
||||
|
||||
### Staging and atomic activation
|
||||
|
||||
Import creates a new local epoch with `status: "staging"`. The epoch from the
|
||||
archive is never reused. Each validated batch is committed independently and
|
||||
updates resumable progress on its `WorldRecord`.
|
||||
|
||||
After complete validation, the epoch becomes `ready`. Activation then acquires
|
||||
the world lock and uses one short transaction to:
|
||||
|
||||
1. Re-read the expected active epoch and global revision.
|
||||
2. Move the current epoch to `rollback`.
|
||||
3. Mark the ready epoch `active`.
|
||||
4. Set `previousEpoch`.
|
||||
5. Flip `WorldControl.activeEpoch`.
|
||||
6. Mark the activation as unverified.
|
||||
|
||||
Only after this transaction commits may localStorage hints and cross-tab
|
||||
replacement messages be updated.
|
||||
|
||||
The application reloads into the new epoch. It verifies the header, first index
|
||||
page, B0 puzzle/state, and occupancy before enabling mutations. Failure at this
|
||||
point atomically restores `previousEpoch`.
|
||||
|
||||
After the new epoch completes its first durable gameplay checkpoint,
|
||||
`activationVerified` becomes true and the old epoch becomes garbage. At most
|
||||
one rollback epoch is retained, and it is not presented as save history.
|
||||
|
||||
Canceled, interrupted, corrupt, quota-failing, or worker-failing imports leave
|
||||
the active pointer unchanged. Abandoned staging epochs are marked for cleanup
|
||||
and removed after 24 hours.
|
||||
|
||||
Restored archives start local-only with no cloud profile or outbox. Reconnecting
|
||||
cloud sync requires an explicit player action and a separately designed paged
|
||||
full-sync protocol. The current 10,000-change push path must not be used to
|
||||
silently upload a very large restored world.
|
||||
|
||||
### Legacy JSON
|
||||
|
||||
Existing JSON envelopes and raw snapshots remain importable and retain the
|
||||
current 100 MiB file limit. They are sanitized before conversion and pass
|
||||
through the same epoch-staging and activation pipeline.
|
||||
|
||||
Compatibility is handled through explicit
|
||||
`migrateArchiveRecord(fromSchema, record)` registrations. An unknown schema or
|
||||
world generation is rejected instead of silently creating a fresh field.
|
||||
|
||||
Legacy normalization may repair or discard malformed data. When that occurs,
|
||||
the importer displays a lossy-import report with totals and the first affected
|
||||
board IDs. Activation requires separate confirmation of that report. New
|
||||
version-2 archives never use lossy repair.
|
||||
|
||||
## Migration from the current database
|
||||
|
||||
The version-change transaction creates stores and indexes only. It must not copy
|
||||
an entire field inside `onupgradeneeded`.
|
||||
|
||||
While `activeFormat` is 1:
|
||||
|
||||
1. Startup continues using the existing stores.
|
||||
2. One tab becomes migration leader through the existing world lock.
|
||||
3. A new version-2 staging epoch is created.
|
||||
4. Legacy rows are copied in resumable batches.
|
||||
5. Normal commits dual-write legacy and version-2 records in the same
|
||||
transaction.
|
||||
6. Backfill uses revision-conditional writes so it cannot overwrite newer
|
||||
dual-written data.
|
||||
7. The last legacy cursor key and copied counts are persisted after every
|
||||
batch.
|
||||
|
||||
After board, global, tombstone, recovery, and outbox counts and revisions
|
||||
reconcile, the persistence queue is flushed and `activeFormat` switches to 2 in
|
||||
one transaction. Dual-writing continues until the first version-2 activation
|
||||
verification checkpoint.
|
||||
|
||||
Quota failure, a blocked upgrade, or interruption leaves format 1 authoritative
|
||||
and retryable. The old stores remain for one application release and are
|
||||
removed only by a later IndexedDB version upgrade.
|
||||
|
||||
## Garbage collection
|
||||
|
||||
Garbage collection:
|
||||
|
||||
- Never deletes the active, previous, or recovery-pinned epoch.
|
||||
- Deletes no more than 500 rows per idle batch.
|
||||
- Persists the current store and key after every batch.
|
||||
- Resumes after reload or interruption.
|
||||
- Removes abandoned staging epochs older than 24 hours.
|
||||
- Removes a verified rollback epoch after the new field's first checkpoint.
|
||||
- Cleans failed OPFS export files.
|
||||
|
||||
## Interfaces
|
||||
|
||||
The repository and archive modules expose these conceptual operations:
|
||||
|
||||
```ts
|
||||
loadActiveWorldHeader(): Promise<{
|
||||
control: WorldControl;
|
||||
world: WorldRecord;
|
||||
}>;
|
||||
|
||||
scanBoardIndex(
|
||||
epoch: string,
|
||||
afterKey?: IDBValidKey,
|
||||
limit?: number
|
||||
): Promise<BoardIndexRecord[]>;
|
||||
|
||||
hydrateBoardDetails(
|
||||
epoch: string,
|
||||
id: string,
|
||||
expectedRevisions?: { metaRev: number; stateRev: number }
|
||||
): Promise<HydratedBoard>;
|
||||
|
||||
commitFieldDelta(delta: FieldDelta): Promise<CommitResult>;
|
||||
|
||||
inspectFieldArchive(
|
||||
file: File,
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<ArchiveSummary>;
|
||||
|
||||
exportFieldArchive(options: {
|
||||
writable: WritableStream<Uint8Array>;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: FieldArchiveProgress) => void;
|
||||
}): Promise<ExportSummary>;
|
||||
|
||||
importFieldArchive(options: {
|
||||
file: File;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: FieldArchiveProgress) => void;
|
||||
}): Promise<ImportSummary>;
|
||||
|
||||
activateStagedWorld(
|
||||
stagedEpoch: string,
|
||||
expectedActive: { epoch: string; globalRev: number }
|
||||
): Promise<void>;
|
||||
|
||||
rollbackUnverifiedActivation(expectedEpoch: string): Promise<void>;
|
||||
migrateLegacyWorld(): Promise<void>;
|
||||
collectWorldGarbage(): Promise<void>;
|
||||
```
|
||||
|
||||
Progress has the following stable shape:
|
||||
|
||||
```ts
|
||||
interface FieldArchiveProgress {
|
||||
phase:
|
||||
| "prepare"
|
||||
| "encode"
|
||||
| "write"
|
||||
| "validate"
|
||||
| "stage"
|
||||
| "activate"
|
||||
| "cleanup";
|
||||
boardsDone: number;
|
||||
boardsTotal: number;
|
||||
bytesRead: number;
|
||||
bytesWritten: number;
|
||||
}
|
||||
```
|
||||
|
||||
Progress is reported at least every 250 ms while work is advancing.
|
||||
Cancellation is acknowledged within one worker chunk or one database batch.
|
||||
|
||||
## Performance budgets
|
||||
|
||||
- Initial gameplay readiness requires only the field header, first index page,
|
||||
and initial viewport details.
|
||||
- Startup does not load every puzzle or state.
|
||||
- Archive import/export adds at most 32 MiB of JavaScript heap beyond the
|
||||
resident field summaries and browser stream buffers.
|
||||
- At most two approximately 1 MiB worker chunks are in flight.
|
||||
- No archive or migration task occupies the main thread for 50 ms or longer.
|
||||
- IndexedDB writes contain at most 256 boards or 4 MiB.
|
||||
- Index scans use at most 512 records per transaction.
|
||||
- Import and export UI remains cancelable throughout encode, write, validation,
|
||||
and staging.
|
||||
|
||||
## Verification
|
||||
|
||||
### Semantic and privacy tests
|
||||
|
||||
- Gzip and identity-encoded round trips preserve the complete portable gameplay
|
||||
field.
|
||||
- Derived totals after import equal totals recomputed from the original field.
|
||||
- Archive text and decoded records contain none of the excluded credential,
|
||||
recovery, epoch, session, debug, or transport fields.
|
||||
- Numeric board ordering and CRC output are deterministic for identical
|
||||
portable input.
|
||||
|
||||
### Invalid archive tests
|
||||
|
||||
- Corrupt gzip data.
|
||||
- Truncated records or missing footer.
|
||||
- Incorrect CRC, byte count, or board count.
|
||||
- Reordered, duplicated, or unknown records.
|
||||
- Duplicate IDs, missing B0, and overlapping geometry.
|
||||
- Malformed puzzles, paths, stores, and references.
|
||||
- Oversized file, decoded stream, line, board count, and expansion ratio.
|
||||
- Unsupported archive, save schema, or world-generation combination.
|
||||
|
||||
### Failure and concurrency tests
|
||||
|
||||
- Cancellation and injected failure before and after every staging batch.
|
||||
- Quota failure during preflight and during a write transaction.
|
||||
- Worker termination and destination-write failure.
|
||||
- Crash before activation, during pointer activation, and before verification.
|
||||
- Automatic rollback after failed B0 or first-index validation.
|
||||
- Stale-tab writes after import, reset, rollback, or migration cutover.
|
||||
- Cross-tab field replacement and blocked database upgrade.
|
||||
- Migration resume, dual-write conflict, and conditional backfill.
|
||||
- Garbage-collection resume and protection of active/rollback epochs.
|
||||
- Cloud remains disconnected after portable restore.
|
||||
|
||||
### Lazy-load tests
|
||||
|
||||
- B0 becomes interactive before the complete index scan finishes.
|
||||
- Details hydrate for viewport, gate adjacency, repair, inventory, and shop
|
||||
access.
|
||||
- Missing or mismatched revisions fail hydration.
|
||||
- Dirty, active, and referenced boards cannot be evicted.
|
||||
- Full-field summaries do not cause detail hydration.
|
||||
|
||||
### Browser and scale tests
|
||||
|
||||
- Direct file picker, OPFS, and bounded Blob output paths.
|
||||
- Gzip and identity compression paths.
|
||||
- Web Lock and storage-lease concurrency paths.
|
||||
- 10,000-board round trip and startup coverage in continuous integration.
|
||||
- 100,000-board and 200,000-board browser benchmarks as scheduled tests.
|
||||
- Long-task, heap, progress-frequency, cancellation-latency, transaction-size,
|
||||
and startup-readiness budgets.
|
||||
|
||||
Existing local `BEND_PERF` instrumentation should record only aggregate
|
||||
duration, byte, board, cancellation, quota, rollback, and cleanup metrics. No
|
||||
field contents, board IDs, player identity, or remote telemetry are added.
|
||||
|
||||
## Current implementation requirements
|
||||
|
||||
1. Add the version-6 stores, repository interfaces, epoch checks, and resumable
|
||||
migration without changing the active read path.
|
||||
2. Enable summary-first startup, real detail hydration, summary-based
|
||||
full-field operations, and the bounded LRU.
|
||||
3. Add the streamed `.bfsave` exporter and all output sinks.
|
||||
4. Add strict staged import, atomic activation, rollback, and legacy conversion.
|
||||
5. Route reset through the same epoch activation path.
|
||||
6. Garbage collection removes inactive epochs and obsolete stores only after verified activation.
|
||||
7. Cloud synchronization uses paged pulls, bounded push batches, recent-revision deltas, and per-board versioned server storage.
|
||||
117
docs/internal-system.md
Normal file
117
docs/internal-system.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Bend Field: Internal System Reference
|
||||
|
||||
## Runtime layout
|
||||
|
||||
The game is a browser application with no framework dependency.
|
||||
|
||||
- `index.html` defines the application shell, HUD, dialogs, and script loading order.
|
||||
- `style.css` owns the visual system, responsive layout, zoom presentation, and interaction styling.
|
||||
- `app.js` owns runtime state, rendering, input, generation orchestration, persistence, economy, and UI behavior.
|
||||
- `app-logic.js` contains deterministic shared logic used by both runtime code and tests.
|
||||
- `puzzle-core.js` contains deterministic puzzle generation, solving, and difficulty analysis.
|
||||
- `puzzle-worker.js` runs expensive generation and verification away from the main thread.
|
||||
- `server.js` serves static files and owns the shared-world HTTP API, solution validation, revisions, and clear events. `realtime-server.js` owns transient WebSocket presence, five-minute board-claim leases, and short-lived reactions. Player purchases are stored separately from the shared world and validated by `server.js`.
|
||||
|
||||
## World and puzzle data
|
||||
|
||||
The global data object stores:
|
||||
|
||||
- Board metadata in `data.metas`.
|
||||
- Mutable board state in `data.states`.
|
||||
- Local score, time-attack state, purchases, cursor selection, and unfinished routes.
|
||||
- Shared solved count, board definitions, cleared state, first-solver identity, expansion state,.
|
||||
- Persistence revisions, authors, tombstones, and shared-world synchronization state.
|
||||
|
||||
A board metadata record identifies its world position, occupied five-by-five chunks, level, seed, puzzle definition, sealed sides, and revision.
|
||||
|
||||
A board state record contains drawn paths, special-cell progress, solved/expanded state, reward data, shop state, and revision.
|
||||
|
||||
A path contains its start gate, optional end gate, ordered cells, color information, and detached status. A detached path has two interactive pickups and does not participate in cross-board thickness through its former gate.
|
||||
|
||||
## Input and connection pipeline
|
||||
|
||||
Pointer samples are coalesced and processed once per animation frame.
|
||||
|
||||
1. Screen coordinates are converted to board SVG coordinates without a live layout read.
|
||||
2. Traversed cells are resolved in pointer order.
|
||||
3. Occupancy, warp, crossing, key/door, and self-rewind rules validate each step.
|
||||
4. Pickup snapping searches other open endpoints.
|
||||
5. Pickup and gate targets may bridge at most one empty cell in a straight line.
|
||||
6. Every bridge cell is checked against puzzle bounds and live occupancy.
|
||||
7. A successful join is committed as one board command and pointer capture is released.
|
||||
|
||||
Gate hitboxes cover the inward gate area plus a small outward strip equal to 14% of one cell. The geometric snap selector remains shared by direct gate input, gate-cell input, and drag completion.
|
||||
|
||||
## Puzzle generation
|
||||
|
||||
World expansion is gate-driven. Solving a board opens unresolved gate frontiers, then generation:
|
||||
|
||||
1. Chooses region-appropriate connected chunk shapes.
|
||||
2. Collects required facing-gate connections.
|
||||
3. Generates and validates a complete puzzle candidate.
|
||||
4. Applies obstacles and scheduled special cells.
|
||||
5. Verifies connection requirements, interaction burden, difficulty, and uniqueness where required.
|
||||
6. Commits metadata and state only after all validation succeeds.
|
||||
|
||||
Enclosed one-chunk world holes are treated as terminal fills. Existing saves are scanned for both gate-backed and ungated enclosed holes. Ungated terminal puzzles are sealed on all four sides; gate-backed fills keep only required connection sides open.
|
||||
|
||||
## Obstacle policy
|
||||
|
||||
Obstacle placement operates by removing safe detours from generated solution paths.
|
||||
|
||||
- The baseline selection rate is 2%.
|
||||
- Puzzle area gradually adds up to four percentage points for large puzzles.
|
||||
- Adjacent candidates receive a clustering preference.
|
||||
- The final obstacle count is capped at `floor(totalCells × 0.20)`.
|
||||
- Detours larger than the remaining allowance are rejected.
|
||||
- Crossing conversion and fallback crossing templates use the same 20% cap.
|
||||
|
||||
After obstacle mutation, number clues and turn totals are rebuilt from the modified solution.
|
||||
|
||||
## Difficulty and rewards
|
||||
|
||||
The base score uses board level and rendered connected-line thickness. Two effort multipliers are then applied:
|
||||
|
||||
- A hard-puzzle multiplier begins above level 4 and increases through level 10.
|
||||
- A large-puzzle multiplier uses the number of occupied chunks and grows faster at high levels.
|
||||
|
||||
The result is passed through the deterministic board coefficient, active Score Lens multiplier, and time-attack modifier. `SCORE_VERSION` identifies the active reward formula for stored results.
|
||||
|
||||
## Rendering and performance
|
||||
|
||||
The field uses several levels of detail:
|
||||
|
||||
- Full interactive SVG boards near the active viewport.
|
||||
- Static board summaries outside the immediate interaction area.
|
||||
- A single canvas-based distant overview that reuses the minimap cell and long-line renderer.
|
||||
|
||||
Camera updates, board drag frames, the minimap, distant overview, presence cursors, reactions, and static noise are scheduled and instrumented independently. Difficulty-field overlays do not exist in the current runtime.
|
||||
|
||||
## Persistence
|
||||
|
||||
IndexedDB is the durable local source of truth. A compact local mirror supports recovery when IndexedDB startup or writes fail.
|
||||
|
||||
Persistence uses:
|
||||
|
||||
- Dirty metadata and state ID sets.
|
||||
- Chunked transactions.
|
||||
- Monotonic revisions and revision authors.
|
||||
- Deleted-board tombstones.
|
||||
- A shared-world outbox for retryable, server-validated mutations.
|
||||
|
||||
Unfinished routes are deliberately excluded from the shared outbox. Shared pulls replace board definitions and accepted clear state while preserving personal economy, cursor state, and compatible local unfinished routes.
|
||||
|
||||
The server stores one shared world and immutable versioned board shards. All mutations are serialized through one world queue. Clients poll revision deltas; successful clears are validated by the server before neighboring board batches are accepted.
|
||||
|
||||
The world-generation identifier is separate from the application version. Ordinary behavior and balance updates do not reset an existing field.
|
||||
|
||||
## Verification
|
||||
|
||||
`test/run-all.js` runs source, gameplay, economy, performance, storage, concurrency, generation, interaction, reset, special-cell, and server regressions.
|
||||
|
||||
`test/browser-performance-benchmark.js` verifies real Edge startup and measures pointer work, camera work, minimap conversion, level-of-detail processing, persistence, overview rendering, DOM size, and main-thread responsiveness.
|
||||
|
||||
|
||||
## Documentation policy
|
||||
|
||||
Documentation describes the current product and system only. Do not add changelog, release-note, update-history, migration-diary, or version-specific change-summary documents. When behavior changes, update the relevant current specification in place and remove obsolete historical notes.
|
||||
34
docs/shared-world-complete.md
Normal file
34
docs/shared-world-complete.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Shared world runtime
|
||||
|
||||
The runtime shares board definitions, solved states, first-solver identity, world expansion, and clear notifications.
|
||||
|
||||
Transient WebSocket state includes nearby cursors, viewport subscriptions, board claims, reactions, and clear notifications. Transient state is not written to the shared-world file.
|
||||
|
||||
Player-specific persistent state includes the player name, earned score ledger, purchases, and equipped cursor state on the client. Purchases are validated against the server-authoritative store inventory and price.
|
||||
|
||||
Difficulty-adjustment field items and shared field-placement APIs are not part of the current runtime.
|
||||
|
||||
## HTTP endpoints
|
||||
|
||||
- `POST /api/cloud/session`
|
||||
- `POST /api/cloud/profile`
|
||||
- `GET /api/cloud/status`
|
||||
- `GET /api/cloud/pull`
|
||||
- `POST /api/cloud/push`
|
||||
- `GET /api/player/state`
|
||||
- `POST /api/player/purchase`
|
||||
- `POST /api/player/generation-bonus`
|
||||
|
||||
## Realtime messages
|
||||
|
||||
- `viewport`
|
||||
- `cursor`
|
||||
- `cursor-hide`
|
||||
- `claim`
|
||||
- `release`
|
||||
- `reaction`
|
||||
- `board-cleared`
|
||||
|
||||
## Authority model
|
||||
|
||||
The server validates solved routes, reconstructs rewards and stores, records the first solver, grants bounded adjacent expansion, tracks each player's earned and spent score, and rejects arbitrary board insertion.
|
||||
Loading…
Add table
Add a link
Reference in a new issue