4.2 KiB
Research: Persist Encounter
R1: Serialization Format for Encounter State
Decision: JSON via JSON.stringify / JSON.parse
Rationale: The Encounter type is a plain data structure (no classes, no functions, no circular references). JSON serialization is native to browsers, zero-dependency, and produces human-readable output for debugging. The branded CombatantId type serializes as a plain string and can be rehydrated with the combatantId() constructor.
Alternatives considered:
- Structured clone / IndexedDB: Overkill for a single small object. Adds async complexity for no benefit.
- Custom binary format: Unnecessary complexity for small payloads.
R2: Validation Strategy on Load
Decision: Validate deserialized data through the existing createEncounter domain function before accepting it.
Rationale: createEncounter already enforces all encounter invariants (at least one combatant, valid activeIndex, valid roundNumber). Passing deserialized data through it ensures that corrupt or tampered data is rejected by the same rules that govern encounter creation. Additional structural checks (is it an object? does it have the right shape?) are needed before calling createEncounter since JSON.parse can return any type.
Alternatives considered:
- Schema validation library (Zod, AJV): Adds a dependency for a single validation point. The domain function plus a lightweight shape check is sufficient.
- No validation (trust localStorage): Fragile; any manual edit or version mismatch would crash the app.
R3: Persistence Trigger
Decision: Persist encounter state on every state change via a useEffect that watches the encounter value.
Rationale: The encounter state changes infrequently (user actions only), so writing on every change has negligible performance impact. This is simpler than debouncing or batching, and guarantees the latest state is always saved.
Alternatives considered:
- Debounced writes: Unnecessary complexity; encounter changes are user-driven and infrequent.
- Manual save button: Poor UX; users expect auto-save in modern apps.
beforeunloadevent only: Unreliable; may not fire on mobile or crash scenarios.
R4: localStorage Key and Namespace
Decision: Use a single key "initiative:encounter" with a namespaced prefix.
Rationale: Namespacing avoids collisions with other apps on the same origin. A single key is sufficient for the MVP (one encounter at a time).
Alternatives considered:
- Multiple keys (one per field): Unnecessarily complex; atomic read/write of the full encounter is simpler and avoids partial state issues.
- Versioned key (e.g.,
initiative:encounter:v1): The validation layer already handles schema mismatches by falling back to the demo encounter. An explicit version field inside the stored data could be added later if needed but is unnecessary for MVP.
R5: ID Counter Persistence
Decision: Derive the next ID counter from existing combatant IDs on load rather than persisting it separately.
Rationale: The useEncounter hook currently uses a useRef(0) counter with c-{N} format IDs. After a reload, we can scan existing combatant IDs, extract the highest numeric suffix, and start the counter from there. This avoids persisting a separate counter value and keeps the storage format simple.
Alternatives considered:
- Persist counter as a separate field: Works but adds coupling between the storage format and the hook's internal ID generation strategy.
- Use UUID for new IDs: Would work but changes the ID format; unnecessary for MVP single-user scope.
R6: Error Handling for Storage Operations
Decision: Wrap all localStorage operations in try/catch. On write failure (quota exceeded, storage unavailable), silently continue. On read failure, fall back to demo encounter.
Rationale: The app must never crash due to storage issues (FR-004). Silent failure on write means the user's current session is unaffected. Fallback on read means the app always starts in a usable state.
Alternatives considered:
- Show a warning toast on write failure: Could be added later but is not in the spec scope. Silent failure is the simplest correct behavior for MVP.
- Retry logic: Unnecessary; if localStorage is unavailable, retrying won't help.