Step 14.9: local notifications
CI / check (push) Successful in 7m46s
Details
CI / check (push) Successful in 7m46s
Details
The engine derives the notification schedule from the save after every saved change (arrivals, rest ending, upgrades; same-minute events combined) and replaces the platform's schedule. Capacitor LocalNotifications on iOS/Android, a no-op on the web. Permission is asked after the deal; Settings has an On/Off toggle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f77f786408
commit
f53ed1657b
|
|
@ -1398,3 +1398,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
|
|
||||||
- **"Start your company" keeps its command name** (`createGuest`): the start screen, its tests and the route catalog stay unchanged; the command now builds the save on the device instead of an account and a database row.
|
- **"Start your company" keeps its command name** (`createGuest`): the start screen, its tests and the route catalog stay unchanged; the command now builds the save on the device instead of an account and a database row.
|
||||||
- **The test pins the whole starting state** with fixed ids and a fixed seed, so any drift from CR-001's start shows up as a diff, not as a vague mismatch.
|
- **The test pins the whole starting state** with fixed ids and a fixed seed, so any drift from CR-001's start shows up as a diff, not as a vague mismatch.
|
||||||
|
|
||||||
|
## Step 14.9
|
||||||
|
|
||||||
|
- **The schedule is derived, not tracked**: after every saved change the engine works out every future event from the save and replaces the platform's whole schedule. Nothing remembers notification ids, so a moved arrival, a sold ship or a new company can't leave a stale notification behind. Ids are just 1..n per schedule.
|
||||||
|
- **A combined notification fires at its minute's last event**, so "2 updates" never announces something that hasn't happened yet.
|
||||||
|
- **Permission is asked by the engine client right after `acceptDeal` succeeds** (decision 0.5.91: not at first launch). The tutorial's loaner runs don't need notifications; owning the business is when waiting starts to matter. Without permission the native scheduler just schedules nothing.
|
||||||
|
- **The toggle is a device preference** (default on), read by the engine through a callback; changing it resyncs at once. The section shows on the web too, labelled "iOS and Android apps only", so the setting carries over if the same preferences ever matter there.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@
|
||||||
"build:e2e": "node scripts/map-art.mjs --if-available && vite build --mode e2e"
|
"build:e2e": "node scripts/map-art.mjs --if-available && vite build --mode e2e"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/app": "^8.1.2",
|
||||||
|
"@capacitor/core": "^8.5.3",
|
||||||
|
"@capacitor/filesystem": "^8.1.4",
|
||||||
|
"@capacitor/local-notifications": "^8.3.1",
|
||||||
"@deadhead/content": "workspace:*",
|
"@deadhead/content": "workspace:*",
|
||||||
"@deadhead/engine": "workspace:*",
|
"@deadhead/engine": "workspace:*",
|
||||||
"@deadhead/schema": "workspace:*",
|
"@deadhead/schema": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ export function createEngineClient(engine: GameEngine): ApiClient {
|
||||||
return {
|
return {
|
||||||
async call(name, input) {
|
async call(name, input) {
|
||||||
try {
|
try {
|
||||||
return (await engine.call(name as RouteName as CommandName, input as never)) as never;
|
const result = await engine.call(name as RouteName as CommandName, input as never);
|
||||||
|
// Notifications are asked for once the player owns the business (decision 0.5.91).
|
||||||
|
if (name === "acceptDeal") void engine.requestNotificationPermission().catch(() => {});
|
||||||
|
return result as never;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof CommandError) {
|
if (error instanceof CommandError) {
|
||||||
throw new ApiError(statusFor(error.code), error.code, error.message);
|
throw new ApiError(statusFor(error.code), error.code, error.message);
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,20 @@ import {
|
||||||
GameEngine,
|
GameEngine,
|
||||||
IndexedDbBackend,
|
IndexedDbBackend,
|
||||||
MemoryBackend,
|
MemoryBackend,
|
||||||
|
NotificationSync,
|
||||||
ResolutionLoop,
|
ResolutionLoop,
|
||||||
SaveStorage,
|
SaveStorage,
|
||||||
TabLock,
|
TabLock,
|
||||||
type LockManagerLike,
|
type LockManagerLike,
|
||||||
type LoopHost,
|
type LoopHost,
|
||||||
|
type NotificationScheduler,
|
||||||
} from "@deadhead/engine";
|
} from "@deadhead/engine";
|
||||||
|
import { usePreferences } from "../state/preferences";
|
||||||
|
import { platformNotifications } from "./notifications";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The game on this device (CR-014): save storage, the device clock, the tab
|
* The game on this device (CR-014): save storage, the device clock, the tab
|
||||||
* lock and the engine, wired to the browser. Native storage and notifications
|
* lock, local notifications and the engine, wired to the browser.
|
||||||
* join in Step 14.9.
|
|
||||||
*/
|
*/
|
||||||
export interface Game {
|
export interface Game {
|
||||||
engine: GameEngine;
|
engine: GameEngine;
|
||||||
|
|
@ -37,7 +40,7 @@ function savedDevOffset(): number {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGame(): Game {
|
export function createGame(scheduler: NotificationScheduler = platformNotifications()): Game {
|
||||||
const storage = new SaveStorage(
|
const storage = new SaveStorage(
|
||||||
typeof indexedDB === "undefined" ? new MemoryBackend() : new IndexedDbBackend(indexedDB),
|
typeof indexedDB === "undefined" ? new MemoryBackend() : new IndexedDbBackend(indexedDB),
|
||||||
);
|
);
|
||||||
|
|
@ -63,6 +66,11 @@ export function createGame(): Game {
|
||||||
devTools: DEV_TOOLS,
|
devTools: DEV_TOOLS,
|
||||||
// Responses are checked in development, as the server did outside production.
|
// Responses are checked in development, as the server did outside production.
|
||||||
validateResponses: !import.meta.env.PROD,
|
validateResponses: !import.meta.env.PROD,
|
||||||
|
notifications: new NotificationSync(scheduler, () => usePreferences.getState().notifications),
|
||||||
|
});
|
||||||
|
// The Settings toggle reschedules (or clears) at once.
|
||||||
|
usePreferences.subscribe((prefs, before) => {
|
||||||
|
if (prefs.notifications !== before.notifications) void engine.resyncNotifications();
|
||||||
});
|
});
|
||||||
return { engine, lock, storage, clock, devTools: DEV_TOOLS };
|
return { engine, lock, storage, clock, devTools: DEV_TOOLS };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import { LocalNotifications } from "@capacitor/local-notifications";
|
||||||
|
import { noNotifications, type NotificationScheduler } from "@deadhead/engine";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local notifications on iOS and Android through Capacitor (decision 0.5.91).
|
||||||
|
* The web build has none: there, the no-op scheduler is used.
|
||||||
|
*/
|
||||||
|
export const capacitorNotifications: NotificationScheduler = {
|
||||||
|
async replaceAll(list) {
|
||||||
|
const { notifications: pending } = await LocalNotifications.getPending();
|
||||||
|
if (pending.length > 0) {
|
||||||
|
await LocalNotifications.cancel({ notifications: pending.map((n) => ({ id: n.id })) });
|
||||||
|
}
|
||||||
|
if (list.length === 0) return;
|
||||||
|
// Without permission scheduling fails; the player said no, so stay quiet.
|
||||||
|
const { display } = await LocalNotifications.checkPermissions();
|
||||||
|
if (display !== "granted") return;
|
||||||
|
await LocalNotifications.schedule({
|
||||||
|
notifications: list.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
schedule: { at: new Date(n.at), allowWhileIdle: true },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async requestPermission() {
|
||||||
|
const { display } = await LocalNotifications.checkPermissions();
|
||||||
|
if (display === "prompt" || display === "prompt-with-rationale") {
|
||||||
|
await LocalNotifications.requestPermissions();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The scheduler for this platform. */
|
||||||
|
export function platformNotifications(): NotificationScheduler {
|
||||||
|
return Capacitor.isNativePlatform() ? capacitorNotifications : noNotifications;
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,19 @@ export function Settings() {
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Notifications">
|
||||||
|
<Choice<"on" | "off">
|
||||||
|
label="Arrivals, rest and upgrades"
|
||||||
|
value={prefs.notifications ? "on" : "off"}
|
||||||
|
options={[
|
||||||
|
["on", "On"],
|
||||||
|
["off", "Off"],
|
||||||
|
]}
|
||||||
|
onChange={(v) => prefs.set({ notifications: v === "on" })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-ink-400">On the iOS and Android apps only.</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Help">
|
<Section title="Help">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={resetMapView}>
|
<Button variant="secondary" size="sm" onClick={resetMapView}>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ export interface Preferences {
|
||||||
dismissedTips: string[];
|
dismissedTips: string[];
|
||||||
/** The map view to restore; null for the default view. */
|
/** The map view to restore; null for the default view. */
|
||||||
mapCamera: Camera | null;
|
mapCamera: Camera | null;
|
||||||
|
/** Local notifications on iOS and Android (decision 0.5.91). */
|
||||||
|
notifications: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PREFERENCES: Preferences = {
|
export const DEFAULT_PREFERENCES: Preferences = {
|
||||||
|
|
@ -28,6 +30,7 @@ export const DEFAULT_PREFERENCES: Preferences = {
|
||||||
reducedMotion: null,
|
reducedMotion: null,
|
||||||
dismissedTips: [],
|
dismissedTips: [],
|
||||||
mapCamera: null,
|
mapCamera: null,
|
||||||
|
notifications: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The part of `Storage` the store needs (a fake in tests). */
|
/** The part of `Storage` the store needs (a fake in tests). */
|
||||||
|
|
@ -69,6 +72,10 @@ export function loadPreferences(storage: PreferenceStorage | null): Preferences
|
||||||
[saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite)
|
[saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite)
|
||||||
? saved.mapCamera
|
? saved.mapCamera
|
||||||
: null,
|
: null,
|
||||||
|
notifications:
|
||||||
|
typeof saved.notifications === "boolean"
|
||||||
|
? saved.notifications
|
||||||
|
: DEFAULT_PREFERENCES.notifications,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_PREFERENCES;
|
return DEFAULT_PREFERENCES;
|
||||||
|
|
@ -104,8 +111,16 @@ export function createPreferencesStore(
|
||||||
): UseBoundStore<StoreApi<PreferencesStore>> {
|
): UseBoundStore<StoreApi<PreferencesStore>> {
|
||||||
return create<PreferencesStore>((setState, getState) => {
|
return create<PreferencesStore>((setState, getState) => {
|
||||||
const persist = () => {
|
const persist = () => {
|
||||||
const { clock, countdown, reducedMotion, dismissedTips, mapCamera } = getState();
|
const { clock, countdown, reducedMotion, dismissedTips, mapCamera, notifications } =
|
||||||
savePreferences(storage, { clock, countdown, reducedMotion, dismissedTips, mapCamera });
|
getState();
|
||||||
|
savePreferences(storage, {
|
||||||
|
clock,
|
||||||
|
countdown,
|
||||||
|
reducedMotion,
|
||||||
|
dismissedTips,
|
||||||
|
mapCamera,
|
||||||
|
notifications,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...loadPreferences(storage),
|
...loadPreferences(storage),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import {
|
||||||
|
noNotifications,
|
||||||
|
type NotificationScheduler,
|
||||||
|
type ScheduledNotification,
|
||||||
|
} from "@deadhead/engine";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createEngineClient } from "../src/api/engine-client";
|
||||||
|
import { createGame } from "../src/platform/game";
|
||||||
|
import { platformNotifications } from "../src/platform/notifications";
|
||||||
|
import { DEFAULT_PREFERENCES, usePreferences } from "../src/state/preferences";
|
||||||
|
|
||||||
|
class FakeScheduler implements NotificationScheduler {
|
||||||
|
scheduled: ScheduledNotification[] | null = null;
|
||||||
|
permissionAsked = 0;
|
||||||
|
async replaceAll(list: ScheduledNotification[]) {
|
||||||
|
this.scheduled = list;
|
||||||
|
}
|
||||||
|
async requestPermission() {
|
||||||
|
this.permissionAsked++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => usePreferences.setState(DEFAULT_PREFERENCES));
|
||||||
|
|
||||||
|
describe("local notifications on the device (Step 14.9)", () => {
|
||||||
|
it("uses the no-op scheduler on the web, and playing still works", async () => {
|
||||||
|
expect(platformNotifications()).toBe(noNotifications);
|
||||||
|
const game = createGame();
|
||||||
|
await game.lock.acquire();
|
||||||
|
const { state } = await game.engine.call("createGuest", {});
|
||||||
|
expect(state.tutorial).not.toBeNull();
|
||||||
|
await expect(game.engine.requestNotificationPermission()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reschedules when the Settings toggle changes", async () => {
|
||||||
|
const scheduler = new FakeScheduler();
|
||||||
|
const game = createGame(scheduler);
|
||||||
|
await game.lock.acquire();
|
||||||
|
await game.engine.call("createGuest", {});
|
||||||
|
const [contract] = (await game.engine.call("getTutorialBoard", {})).contracts;
|
||||||
|
const { dispatch } = await game.engine.call("createDispatch", {
|
||||||
|
kind: "tutorial",
|
||||||
|
contractId: contract!.id,
|
||||||
|
});
|
||||||
|
expect(scheduler.scheduled).toMatchObject([{ at: dispatch.arrivalAt }]);
|
||||||
|
|
||||||
|
usePreferences.getState().set({ notifications: false });
|
||||||
|
await vi.waitFor(() => expect(scheduler.scheduled).toEqual([]));
|
||||||
|
usePreferences.getState().set({ notifications: true });
|
||||||
|
await vi.waitFor(() => expect(scheduler.scheduled).toMatchObject([{ at: dispatch.arrivalAt }]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for permission after the deal, not before", async () => {
|
||||||
|
const scheduler = new FakeScheduler();
|
||||||
|
const game = createGame(scheduler);
|
||||||
|
await game.lock.acquire();
|
||||||
|
const api = createEngineClient(game.engine);
|
||||||
|
await api.call("createGuest", {});
|
||||||
|
expect(scheduler.permissionAsked).toBe(0);
|
||||||
|
// The deal itself is covered by the engine's tests; here it just succeeds.
|
||||||
|
vi.spyOn(game.engine, "call").mockResolvedValueOnce({} as never);
|
||||||
|
await api.call("acceptDeal", {});
|
||||||
|
await vi.waitFor(() => expect(scheduler.permissionAsked).toBe(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,7 +8,7 @@ Living reference for how the code is organised. Update it after any database sch
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod |
|
| `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod |
|
||||||
| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. | content, astronomy-engine |
|
| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. | content, astronomy-engine |
|
||||||
| `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim |
|
| `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `notifications.ts`: the schedule is recomputed from the save after every saved change and replaces the platform's (`NotificationScheduler`: Capacitor on native, no-op on the web). `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim |
|
||||||
| `packages/schema` | Zod schemas for every API request, response and error. Shared by server and client. | sim (error codes), zod |
|
| `packages/schema` | Zod schemas for every API request, response and error. Shared by server and client. | sim (error codes), zod |
|
||||||
| `apps/server` | Fastify API, Postgres via Drizzle, pg-boss workers. The authority on outcomes. | content, sim, schema |
|
| `apps/server` | Fastify API, Postgres via Drizzle, pg-boss workers. The authority on outcomes. | content, sim, schema |
|
||||||
| `apps/web` | React client. Predicts with sim, never decides outcomes. | content, sim, schema |
|
| `apps/web` | React client. Predicts with sim, never decides outcomes. | content, sim, schema |
|
||||||
|
|
|
||||||
|
|
@ -349,7 +349,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011
|
||||||
- [x] **Step 14.6 — Resolution loop.** `packages/engine/src/loop.ts`: `ResolutionLoop` runs `GameEngine.resolveDue` once on start (the app has just loaded), on every resume and every 5 seconds (`RESOLUTION_INTERVAL_MS`), never two passes at once, and calls `onChange` when an arrival was resolved or a rest or shop job ended since the last pass. Timer and resume come from an injected `LoopHost` (the browser's `visibilitychange` and Capacitor's `resume` are wired in 14.7). `resolveDue` uses the same idempotent resolution every command runs first, and saves only when something was resolved. Tests (`test/loop.test.ts`): an arrival that passed while the app was closed is resolved exactly once when it opens again on the same storage and shows in the "while you were away" summaries; two passes in a row leave the save byte-for-byte unchanged the second time; the loop ticks on its timer and on resume and reports a rest ending; nothing happens before a company exists.
|
- [x] **Step 14.6 — Resolution loop.** `packages/engine/src/loop.ts`: `ResolutionLoop` runs `GameEngine.resolveDue` once on start (the app has just loaded), on every resume and every 5 seconds (`RESOLUTION_INTERVAL_MS`), never two passes at once, and calls `onChange` when an arrival was resolved or a rest or shop job ended since the last pass. Timer and resume come from an injected `LoopHost` (the browser's `visibilitychange` and Capacitor's `resume` are wired in 14.7). `resolveDue` uses the same idempotent resolution every command runs first, and saves only when something was resolved. Tests (`test/loop.test.ts`): an arrival that passed while the app was closed is resolved exactly once when it opens again on the same storage and shows in the "while you were away" summaries; two passes in a row leave the save byte-for-byte unchanged the second time; the loop ticks on its timer and on resume and reports a rest ending; nothing happens before a company exists.
|
||||||
- [x] **Step 14.7 — Switch the client to the engine.** `src/platform/game.ts` builds the game on the device: IndexedDB save storage (memory where IndexedDB is missing), the `DeviceClock`, the Web Locks `TabLock`, and the `GameEngine` (developer commands in development builds and in `vite build --mode e2e` test builds via `.env.e2e`; the developer clock offset is kept in local storage in those builds so a fast-forward survives a reload). `src/api/engine-client.ts`: the typed `ApiClient` over engine commands, refusals turned into the same `ApiError`s (status and code), so screens and TanStack Query are unchanged. `src/GameRoot.tsx`: takes the tab lock ("Deadhead is open in another tab" with "Use here" otherwise, and when another tab takes over), provides the engine client, runs the resolution loop (browser timer and `visibilitychange`; a change refetches state and boards), and exposes `window.deadhead.call` in developer builds for browser tests. The clock reads the engine clock instead of the server's. The Step 11.2 offline mode is gone (cached last state, offline banner, `needsServer` buttons, the connection store and their tests): the game is always offline-capable; a failed load now says "Could not load the game." Browser tests run against the web build alone: no API server in the Playwright config, helpers call engine commands through the hook and wait for it after each load, and wait for an acknowledgement to be saved before navigating; 12.4 reopens the app with the developer offset already moved past the arrival. CI builds with `build:e2e`. Tests: `test/on-device.test.tsx` (the engine client answers as the server did, refusals included; "open in another tab" until Use here takes over; the game runs at once when no other tab has it); `e2e/14-7-on-device.e2e.ts` (with every request outside the app's own files blocked from the first load: start a company, fly the tutorial, accept the deal, dispatch and land a contract, and nothing was blocked); the Phase 12 end-to-end tests pass on the engine.
|
- [x] **Step 14.7 — Switch the client to the engine.** `src/platform/game.ts` builds the game on the device: IndexedDB save storage (memory where IndexedDB is missing), the `DeviceClock`, the Web Locks `TabLock`, and the `GameEngine` (developer commands in development builds and in `vite build --mode e2e` test builds via `.env.e2e`; the developer clock offset is kept in local storage in those builds so a fast-forward survives a reload). `src/api/engine-client.ts`: the typed `ApiClient` over engine commands, refusals turned into the same `ApiError`s (status and code), so screens and TanStack Query are unchanged. `src/GameRoot.tsx`: takes the tab lock ("Deadhead is open in another tab" with "Use here" otherwise, and when another tab takes over), provides the engine client, runs the resolution loop (browser timer and `visibilitychange`; a change refetches state and boards), and exposes `window.deadhead.call` in developer builds for browser tests. The clock reads the engine clock instead of the server's. The Step 11.2 offline mode is gone (cached last state, offline banner, `needsServer` buttons, the connection store and their tests): the game is always offline-capable; a failed load now says "Could not load the game." Browser tests run against the web build alone: no API server in the Playwright config, helpers call engine commands through the hook and wait for it after each load, and wait for an acknowledgement to be saved before navigating; 12.4 reopens the app with the developer offset already moved past the arrival. CI builds with `build:e2e`. Tests: `test/on-device.test.tsx` (the engine client answers as the server did, refusals included; "open in another tab" until Use here takes over; the game runs at once when no other tab has it); `e2e/14-7-on-device.e2e.ts` (with every request outside the app's own files blocked from the first load: start a company, fly the tutorial, accept the deal, dispatch and land a contract, and nothing was blocked); the Phase 12 end-to-end tests pass on the engine.
|
||||||
- [x] **Step 14.8 — Company creation on the device.** With no save, the app shows the start screen (no company answers `unauthorized`, as before); "Start your company" runs `createGuest`, which builds the CR-001 starting state in a new save with a fresh world seed from the platform's crypto source (`startingSave`, shared with "Start a new company"). Asking again when a company exists returns that company. Tests (`packages/engine/test/start.test.ts`, with fixed ids and seed): from empty storage the state is exactly the CR-001 start (starting credits, no loan, the avatar at Earth beside the loaner, Earth and the Moon discovered, four tutorial runs to go, no ships, bays, flights, summaries or goals, the seed's company name); a new engine on the same storage opens the same company; different seeds give different companies.
|
- [x] **Step 14.8 — Company creation on the device.** With no save, the app shows the start screen (no company answers `unauthorized`, as before); "Start your company" runs `createGuest`, which builds the CR-001 starting state in a new save with a fresh world seed from the platform's crypto source (`startingSave`, shared with "Start a new company"). Asking again when a company exists returns that company. Tests (`packages/engine/test/start.test.ts`, with fixed ids and seed): from empty storage the state is exactly the CR-001 start (starting credits, no loan, the avatar at Earth beside the loaner, Earth and the Moon discovered, four tutorial runs to go, no ships, bays, flights, summaries or goals, the seed's company name); a new engine on the same storage opens the same company; different seeds give different companies.
|
||||||
- [ ] Step 14.9 — Local notifications
|
- [x] **Step 14.9 — Local notifications.** `packages/engine/src/notifications.ts`: `upcomingEvents` (each future arrival "X delivered to Y" / "X arrived at Y", each pilot's rest ending, each upgrade finishing), `plannedNotifications` (events in the same minute share one notification, titled "Deadhead: N updates", at the minute's last event), and `NotificationSync`, which replaces the platform's whole schedule from the save after every saved change (the engine core's `afterSave` hook; a failed sync never fails a command), so moved or cancelled events and a new company clear themselves. `GameEngine` takes a `notifications` option and adds `resyncNotifications()` and `requestNotificationPermission()`. Web: `src/platform/notifications.ts` uses Capacitor `LocalNotifications` on iOS and Android (cancel pending, schedule if permission is granted) and the no-op scheduler on the web; `createGame` wires it with the new `notifications` preference (default on) and resyncs when it changes; the engine client asks for permission right after `acceptDeal` succeeds; Settings has a Notifications section (On/Off). Tests: `packages/engine/test/notifications.test.ts` (a dispatch schedules one notification at its arrival; two arrivals in the same minute make one combined notification; a new company cancels everything; with the toggle off nothing is scheduled, and turning it on catches up; the no-op scheduler never errors); `apps/web/test/notifications.test.ts` (the web uses the no-op scheduler and play works; the toggle clears and restores the schedule; permission is asked after the deal and not before).
|
||||||
- [ ] Step 14.10 — Settings changes
|
- [ ] Step 14.10 — Settings changes
|
||||||
- [ ] Step 14.11 — Retire the server
|
- [ ] Step 14.11 — Retire the server
|
||||||
- [ ] Step 14.12 — Plan amendments and regression
|
- [ ] Step 14.12 — Plan amendments and regression
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export interface CoreDeps {
|
||||||
random?: RandomSource;
|
random?: RandomSource;
|
||||||
/** False while another tab holds the game; commands are refused. */
|
/** False while another tab holds the game; commands are refused. */
|
||||||
canWrite?: () => boolean;
|
canWrite?: () => boolean;
|
||||||
|
/** After each save (or after the save is cleared): e.g. rescheduling notifications. */
|
||||||
|
afterSave?: (save: SaveDocument | null, now: RealTime) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -86,6 +88,15 @@ export class EngineCore {
|
||||||
return this.#doc;
|
return this.#doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Side effects never fail a command that has already been saved. */
|
||||||
|
async #afterSave(save: SaveDocument | null, now: RealTime): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.deps.afterSave?.(save, now);
|
||||||
|
} catch {
|
||||||
|
// A notification that couldn't be scheduled is not worth losing a move over.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#enqueue<T>(work: () => Promise<T>): Promise<T> {
|
#enqueue<T>(work: () => Promise<T>): Promise<T> {
|
||||||
const next = this.#queue.then(work, work);
|
const next = this.#queue.then(work, work);
|
||||||
this.#queue = next.catch(() => undefined);
|
this.#queue = next.catch(() => undefined);
|
||||||
|
|
@ -117,6 +128,7 @@ export class EngineCore {
|
||||||
});
|
});
|
||||||
await this.deps.storage.save(next);
|
await this.deps.storage.save(next);
|
||||||
this.#doc = next;
|
this.#doc = next;
|
||||||
|
await this.#afterSave(next, now);
|
||||||
return out.result;
|
return out.result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +140,7 @@ export class EngineCore {
|
||||||
await this.deps.storage.clear();
|
await this.deps.storage.clear();
|
||||||
this.#doc = null;
|
this.#doc = null;
|
||||||
this.#loaded = true;
|
this.#loaded = true;
|
||||||
|
await this.#afterSave(null, this.now());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
import { realToGame, type RealTime } from "@deadhead/sim";
|
import { realToGame, type RealTime } from "@deadhead/sim";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { EngineCore, type CoreDeps, type RandomSource } from "../core";
|
import { EngineCore, type CoreDeps, type RandomSource } from "../core";
|
||||||
|
import type { NotificationSync } from "../notifications";
|
||||||
import { commandTable } from "./commands";
|
import { commandTable } from "./commands";
|
||||||
import { startingSave } from "./company";
|
import { startingSave } from "./company";
|
||||||
import { CommandError, fail } from "./errors";
|
import { CommandError, fail } from "./errors";
|
||||||
|
|
@ -50,6 +51,8 @@ export interface GameEngineOptions extends CoreDeps {
|
||||||
validateResponses?: boolean;
|
validateResponses?: boolean;
|
||||||
/** Replaces command handlers (tests only). */
|
/** Replaces command handlers (tests only). */
|
||||||
overrides?: Partial<CommandTable>;
|
overrides?: Partial<CommandTable>;
|
||||||
|
/** Local notifications (decision 0.5.91); none if absent. */
|
||||||
|
notifications?: NotificationSync;
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeIssues(error: z.ZodError): string {
|
function describeIssues(error: z.ZodError): string {
|
||||||
|
|
@ -72,7 +75,11 @@ export class GameEngine {
|
||||||
readonly #table: CommandTable;
|
readonly #table: CommandTable;
|
||||||
|
|
||||||
constructor(private readonly options: GameEngineOptions) {
|
constructor(private readonly options: GameEngineOptions) {
|
||||||
this.core = new EngineCore(options);
|
const notifications = options.notifications;
|
||||||
|
this.core = new EngineCore({
|
||||||
|
...options,
|
||||||
|
afterSave: notifications ? (save, now) => notifications.sync(save, now) : options.afterSave,
|
||||||
|
});
|
||||||
this.#table = { ...commandTable(), ...options.overrides } as CommandTable;
|
this.#table = { ...commandTable(), ...options.overrides } as CommandTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +87,18 @@ export class GameEngine {
|
||||||
return this.core.now();
|
return this.core.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reschedules notifications from the current save (the Settings toggle changed). */
|
||||||
|
resyncNotifications(): Promise<void> {
|
||||||
|
const notifications = this.options.notifications;
|
||||||
|
if (!notifications) return Promise.resolve();
|
||||||
|
return this.core.read((save, now) => notifications.sync(save, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks for notification permission (right after the deal, decision 0.5.91). */
|
||||||
|
requestNotificationPermission(): Promise<void> {
|
||||||
|
return this.options.notifications?.requestPermission() ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The resolution loop's pass (Step 14.6): applies any due arrivals and counts
|
* The resolution loop's pass (Step 14.6): applies any due arrivals and counts
|
||||||
* rests and shop jobs that ended after `since`. Saves only when an arrival
|
* rests and shop jobs that ended after `since`. Saves only when an arrival
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,11 @@ export { pruneSave, resolveArrivals } from "./game/resolve";
|
||||||
export { playerState } from "./game/state";
|
export { playerState } from "./game/state";
|
||||||
export { startingSave } from "./game/company";
|
export { startingSave } from "./game/company";
|
||||||
export { RESOLUTION_INTERVAL_MS, ResolutionLoop, type LoopHost } from "./loop";
|
export { RESOLUTION_INTERVAL_MS, ResolutionLoop, type LoopHost } from "./loop";
|
||||||
|
export {
|
||||||
|
noNotifications,
|
||||||
|
NotificationSync,
|
||||||
|
plannedNotifications,
|
||||||
|
upcomingEvents,
|
||||||
|
type NotificationScheduler,
|
||||||
|
type ScheduledNotification,
|
||||||
|
} from "./notifications";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { SaveDocument } from "@deadhead/schema";
|
||||||
|
import { MS_PER_MINUTE, type RealTime } from "@deadhead/sim";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local notifications (decision 0.5.91): the app schedules one for each
|
||||||
|
* arrival, each pilot's rest ending and each upgrade finishing; events in the
|
||||||
|
* same minute share one notification. The schedule is worked out from the save
|
||||||
|
* after every change and replaces whatever was scheduled before, so changed or
|
||||||
|
* cancelled events (a new company, an import) are dropped automatically.
|
||||||
|
*/
|
||||||
|
export interface ScheduledNotification {
|
||||||
|
/** Positive 32-bit integer (Capacitor's requirement). */
|
||||||
|
id: number;
|
||||||
|
at: RealTime;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The platform's notifications, injected (Capacitor on iOS and Android; nothing on the web). */
|
||||||
|
export interface NotificationScheduler {
|
||||||
|
/** Replaces every scheduled notification with these. */
|
||||||
|
replaceAll(notifications: ScheduledNotification[]): Promise<void>;
|
||||||
|
/** Asks the player for permission (after the deal, not at first launch). */
|
||||||
|
requestPermission(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The web build has no notifications. */
|
||||||
|
export const noNotifications: NotificationScheduler = {
|
||||||
|
replaceAll: async () => undefined,
|
||||||
|
requestPermission: async () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GameEvent {
|
||||||
|
at: RealTime;
|
||||||
|
/** One line, e.g. "Dusty Mule landed at the Moon". */
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationName = (id: string) => content.location(id)?.name ?? id;
|
||||||
|
|
||||||
|
/** Every future event worth a notification, in time order. */
|
||||||
|
export function upcomingEvents(save: SaveDocument, now: RealTime): GameEvent[] {
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
for (const d of save.dispatches) {
|
||||||
|
if (d.status !== "in-flight" || d.arrivalAt <= now) continue;
|
||||||
|
const ship = d.tutorial
|
||||||
|
? "The company loaner"
|
||||||
|
: (save.ships.find((s) => s.id === d.shipId)?.name ?? "A ship");
|
||||||
|
const where = locationName(d.record.destinationId);
|
||||||
|
events.push({
|
||||||
|
at: d.arrivalAt,
|
||||||
|
text: d.record.contract ? `${ship} delivered to ${where}` : `${ship} arrived at ${where}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const p of save.pilots) {
|
||||||
|
if (p.restEndsAt !== null && p.restEndsAt > now) {
|
||||||
|
events.push({ at: p.restEndsAt, text: `${p.name} is rested and ready to fly` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of save.ships) {
|
||||||
|
if (s.inShopUntil !== null && s.inShopUntil > now && s.shopUpgradeId) {
|
||||||
|
const upgrade = content.upgrade(s.shopUpgradeId)?.name ?? "Upgrade";
|
||||||
|
events.push({ at: s.inShopUntil, text: `${s.name} is out of the shop (${upgrade})` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events.sort((a, b) => a.at - b.at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The notifications to schedule: one per minute that has events. */
|
||||||
|
export function plannedNotifications(save: SaveDocument, now: RealTime): ScheduledNotification[] {
|
||||||
|
const byMinute = new Map<number, GameEvent[]>();
|
||||||
|
for (const event of upcomingEvents(save, now)) {
|
||||||
|
const minute = Math.floor(event.at / MS_PER_MINUTE);
|
||||||
|
byMinute.set(minute, [...(byMinute.get(minute) ?? []), event]);
|
||||||
|
}
|
||||||
|
return [...byMinute.values()].map((events, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
// The latest event of the minute, so everything in it has happened.
|
||||||
|
at: events.at(-1)!.at,
|
||||||
|
title: events.length === 1 ? "Deadhead" : `Deadhead: ${events.length} updates`,
|
||||||
|
body: events.map((e) => e.text).join("\n"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the platform's schedule matching the save; off (the Settings toggle) schedules nothing. */
|
||||||
|
export class NotificationSync {
|
||||||
|
constructor(
|
||||||
|
private readonly scheduler: NotificationScheduler,
|
||||||
|
private readonly enabled: () => boolean = () => true,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sync(save: SaveDocument | null, now: RealTime): Promise<void> {
|
||||||
|
const planned = save && this.enabled() ? plannedNotifications(save, now) : [];
|
||||||
|
await this.scheduler.replaceAll(planned);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPermission(): Promise<void> {
|
||||||
|
return this.scheduler.requestPermission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fullSave } from "../../schema/test/save-fixture";
|
||||||
|
import {
|
||||||
|
GameEngine,
|
||||||
|
MemoryBackend,
|
||||||
|
noNotifications,
|
||||||
|
NotificationSync,
|
||||||
|
plannedNotifications,
|
||||||
|
SaveStorage,
|
||||||
|
type NotificationScheduler,
|
||||||
|
type ScheduledNotification,
|
||||||
|
} from "../src/index";
|
||||||
|
import { makeOwner, putBoard, setCredits, signUp, TestClock, testContract } from "./support";
|
||||||
|
|
||||||
|
/** The platform plugin, recording what is scheduled. */
|
||||||
|
class FakeScheduler implements NotificationScheduler {
|
||||||
|
scheduled: ScheduledNotification[] = [];
|
||||||
|
async replaceAll(list: ScheduledNotification[]) {
|
||||||
|
this.scheduled = list;
|
||||||
|
}
|
||||||
|
async requestPermission() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(enabled = () => true) {
|
||||||
|
const clock = new TestClock();
|
||||||
|
const scheduler = new FakeScheduler();
|
||||||
|
const app = new GameEngine({
|
||||||
|
storage: new SaveStorage(new MemoryBackend()),
|
||||||
|
clock: { now: () => clock.now },
|
||||||
|
notifications: new NotificationSync(scheduler, enabled),
|
||||||
|
});
|
||||||
|
return { app, clock, scheduler };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An owner who dispatches the Courier on a board contract; returns the flight. */
|
||||||
|
async function dispatchOne(app: GameEngine) {
|
||||||
|
const guest = await signUp(app);
|
||||||
|
const { shipId } = await makeOwner(app, guest.avatarId);
|
||||||
|
await setCredits(app, 10_000);
|
||||||
|
const contract = testContract();
|
||||||
|
await putBoard(app, [contract]);
|
||||||
|
const { dispatch } = await app.call("createDispatch", {
|
||||||
|
kind: "contract",
|
||||||
|
contractId: contract.id,
|
||||||
|
shipId,
|
||||||
|
pilotId: guest.avatarId,
|
||||||
|
});
|
||||||
|
return dispatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("local notifications (Step 14.9, decision 0.5.91)", () => {
|
||||||
|
it("schedules one notification at a dispatch's arrival time", async () => {
|
||||||
|
const { app, scheduler } = setup();
|
||||||
|
const dispatch = await dispatchOne(app);
|
||||||
|
expect(scheduler.scheduled).toEqual([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
at: dispatch.arrivalAt,
|
||||||
|
title: "Deadhead",
|
||||||
|
body: "Test Courier delivered to Moon",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines events in the same minute into one notification", () => {
|
||||||
|
const save = fullSave();
|
||||||
|
const landing = save.dispatches.find((d) => d.status === "in-flight")!;
|
||||||
|
// A second ship lands 20 seconds after the first, in the same minute.
|
||||||
|
const minuteStart = Math.floor(landing.arrivalAt / 60_000) * 60_000;
|
||||||
|
landing.arrivalAt = minuteStart + 10_000;
|
||||||
|
save.dispatches.push({
|
||||||
|
...structuredClone(landing),
|
||||||
|
id: "d2",
|
||||||
|
shipId: "s2",
|
||||||
|
arrivalAt: minuteStart + 30_000,
|
||||||
|
record: { ...landing.record, id: "d2", shipId: "s2", destinationId: "mars" },
|
||||||
|
});
|
||||||
|
const now = save.lastTimeSeen;
|
||||||
|
const planned = plannedNotifications(save, now);
|
||||||
|
const combined = planned.find((n) => n.at === minuteStart + 30_000)!;
|
||||||
|
expect(combined.title).toBe("Deadhead: 2 updates");
|
||||||
|
expect(combined.body.split("\n")).toEqual([
|
||||||
|
"Dusty Mule delivered to Moon",
|
||||||
|
"Swift Heron delivered to Mars",
|
||||||
|
]);
|
||||||
|
// The resting veteran and the shop job are separate minutes.
|
||||||
|
expect(planned.map((n) => n.at)).toEqual([...planned.map((n) => n.at)].sort((a, b) => a - b));
|
||||||
|
expect(planned.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels everything when a new company starts", async () => {
|
||||||
|
const { app, scheduler } = setup();
|
||||||
|
await dispatchOne(app);
|
||||||
|
expect(scheduler.scheduled).toHaveLength(1);
|
||||||
|
await app.call("newCompany", {});
|
||||||
|
expect(scheduler.scheduled).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules nothing with notifications turned off, and catches up when turned on", async () => {
|
||||||
|
let on = false;
|
||||||
|
const { app, scheduler } = setup(() => on);
|
||||||
|
await dispatchOne(app);
|
||||||
|
expect(scheduler.scheduled).toEqual([]);
|
||||||
|
on = true;
|
||||||
|
await app.resyncNotifications();
|
||||||
|
expect(scheduler.scheduled).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing on the web (no-op scheduler), without errors", async () => {
|
||||||
|
const app = new GameEngine({
|
||||||
|
storage: new SaveStorage(new MemoryBackend()),
|
||||||
|
clock: { now: () => new TestClock().now },
|
||||||
|
notifications: new NotificationSync(noNotifications),
|
||||||
|
});
|
||||||
|
await expect(dispatchOne(app)).resolves.toBeDefined();
|
||||||
|
await expect(app.requestNotificationPermission()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -81,6 +81,18 @@ importers:
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@capacitor/app':
|
||||||
|
specifier: ^8.1.2
|
||||||
|
version: 8.1.2(@capacitor/core@8.5.3)
|
||||||
|
'@capacitor/core':
|
||||||
|
specifier: ^8.5.3
|
||||||
|
version: 8.5.3
|
||||||
|
'@capacitor/filesystem':
|
||||||
|
specifier: ^8.1.4
|
||||||
|
version: 8.1.4(@capacitor/core@8.5.3)
|
||||||
|
'@capacitor/local-notifications':
|
||||||
|
specifier: ^8.3.1
|
||||||
|
version: 8.3.1(@capacitor/core@8.5.3)
|
||||||
'@deadhead/content':
|
'@deadhead/content':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/content
|
version: link:../../packages/content
|
||||||
|
|
@ -824,6 +836,27 @@ packages:
|
||||||
'@cacheable/utils@2.5.0':
|
'@cacheable/utils@2.5.0':
|
||||||
resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==}
|
resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==}
|
||||||
|
|
||||||
|
'@capacitor/app@8.1.2':
|
||||||
|
resolution: {integrity: sha512-uKBlsrXH0IdvUn69e/KKYbZ32A06pcrUdG8LWuZ/6y33YMXZXLEEdNSwl+jKS/SrsTWqnePs8EqSWaiQM3mUGA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@capacitor/core': '>=8.0.0'
|
||||||
|
|
||||||
|
'@capacitor/core@8.5.3':
|
||||||
|
resolution: {integrity: sha512-oBE1oGuYE4ZzYYrulC75OuHdSsD6zLUy7d5o1tFwDLG4govGoubY15cxpiI5l0+q87Lm5p0bhbehMdzJg4aFag==}
|
||||||
|
|
||||||
|
'@capacitor/filesystem@8.1.4':
|
||||||
|
resolution: {integrity: sha512-8MXmt10cb+dGWoh8jUeO8on0U6iETQCX5k+fmdB278PZ3MYDEVGQo08+hroaOtUbK3+5stiRk4/K6QBHKepv0A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@capacitor/core': '>=8.0.0'
|
||||||
|
|
||||||
|
'@capacitor/local-notifications@8.3.1':
|
||||||
|
resolution: {integrity: sha512-F7zu/+p/mIR958YP4axOkeI5hBKRaXDyYjbNPrAil2wWfd7OxScPSTvP9t85NpQ0eULVRu0/sntoxOg6ck477A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@capacitor/core': '>=8.0.0'
|
||||||
|
|
||||||
|
'@capacitor/synapse@1.0.4':
|
||||||
|
resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==}
|
||||||
|
|
||||||
'@csstools/color-helpers@6.1.2':
|
'@csstools/color-helpers@6.1.2':
|
||||||
resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==}
|
resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
@ -5054,6 +5087,25 @@ snapshots:
|
||||||
hashery: 1.5.1
|
hashery: 1.5.1
|
||||||
keyv: 5.6.0
|
keyv: 5.6.0
|
||||||
|
|
||||||
|
'@capacitor/app@8.1.2(@capacitor/core@8.5.3)':
|
||||||
|
dependencies:
|
||||||
|
'@capacitor/core': 8.5.3
|
||||||
|
|
||||||
|
'@capacitor/core@8.5.3':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@capacitor/filesystem@8.1.4(@capacitor/core@8.5.3)':
|
||||||
|
dependencies:
|
||||||
|
'@capacitor/core': 8.5.3
|
||||||
|
'@capacitor/synapse': 1.0.4
|
||||||
|
|
||||||
|
'@capacitor/local-notifications@8.3.1(@capacitor/core@8.5.3)':
|
||||||
|
dependencies:
|
||||||
|
'@capacitor/core': 8.5.3
|
||||||
|
|
||||||
|
'@capacitor/synapse@1.0.4': {}
|
||||||
|
|
||||||
'@csstools/color-helpers@6.1.2': {}
|
'@csstools/color-helpers@6.1.2': {}
|
||||||
|
|
||||||
'@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)':
|
'@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)':
|
||||||
|
|
@ -7808,8 +7860,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
tsx@4.23.15:
|
tsx@4.23.15:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue