diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..c2cc48f --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run validate:commit diff --git a/AGENTS.md b/AGENTS.md index d7819fd..f71278d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,87 +1,161 @@ -# AGENTS.md — Throne of the Bone King +# AGENTS.md - Throne of the Bone King AI agent and coding assistant instructions for this repo. ## Project Summary -React Native 0.85.3 mobile app. Part of the **Bone Lord Bob** universe. -See `llm.txt` for full project context and stack details. ---- +Throne of the Bone King is an early-stage Bone Lord Bob game shell. It uses React Native 0.85.3 with a shared UI layer for browser, Electron, and future native mobile targets. + +Primary iteration targets today: + +- Browser: Vite + React Native Web +- Desktop: Electron with a Vite renderer +- Toolkit: browser-only support surface backed by a local Socket.IO server + +Native React Native source is present, but `android/` and `ios/` are not currently in this checkout. + +Read `llm.txt`, `manifest.llm.json`, and this file before broad changes. ## Ground Rules ### Do -- Write TypeScript — strict, no `any` unless genuinely unavoidable -- Use functional components and React hooks exclusively -- Follow existing naming conventions (PascalCase components, camelCase hooks/utils) -- Use `StyleSheet.create()` for styles -- Write tests for new logic in `__tests__/` -- Commit as **Jacob Mathison** (`jacob@mathisonprojects.com`) — never commit as an AI identity + +- Write TypeScript with strict, explicit types. Avoid `any` unless genuinely unavoidable. +- Use functional components and React hooks exclusively. +- Keep shared UI in React Native components and `StyleSheet.create()`. +- Keep `App.tsx` as the app root and `src/navigation/AppNavigator.tsx` as the lightweight navigator. +- Use existing Zustand stores and persistence helpers for app state. +- Put browser-only APIs in `.web.ts`, `.web.tsx`, or `src/web/` only. +- Write focused tests in `__tests__/` for new logic, route metadata, stores, socket helpers, or screen behavior. +- Commit as Jacob Mathison (`jacob@mathisonprojects.com`) when asked to commit. Never commit as an AI identity. ### Don't -- Don't use browser/DOM APIs (`window`, `document`, `localStorage`) — this is React Native -- Don't install packages without noting them; confirm major additions with the team -- Don't modify `android/` or `ios/` native files unless explicitly asked -- Don't commit `node_modules/`, `Pods/`, or build artifacts -- Don't run destructive commands (`rm -rf`, `git push --force`) without confirmation ---- +- Do not use `window`, `document`, `localStorage`, `Audio`, or other DOM/browser APIs in shared native code. +- Do not add React Navigation or another navigation library unless explicitly requested. +- Do not modify `android/` or `ios/` native files unless explicitly asked and those folders exist. +- Do not commit `node_modules/`, `Pods/`, `dist/`, or other build artifacts. +- Do not run destructive commands such as `rm -rf`, `git reset --hard`, `git checkout --`, or `git push --force` without explicit confirmation. +- Do not replace user work in a dirty worktree. Work with existing changes. ## Structure Conventions -``` -src/ # (create when adding more than a few components) - components/ # Reusable UI components - screens/ # Screen-level components - hooks/ # Custom hooks - utils/ # Helper functions - types/ # Shared TypeScript types - assets/ # Images, fonts, icons -``` -Keep `App.tsx` as the navigation root. Move feature code into `src/` once the project grows. ---- +```text +src/ + assets/ # Images, fonts, music, SCSS entrypoints + components/ # Reusable React Native UI components + config/ # Build/runtime labels and platform config + data/ # Editable JSON content and typed data exports + hooks/ # Custom hooks and platform hooks + navigation/ # Route metadata and app-state navigator + screens/ # Screen-level components + settings/ # Game setting definitions + socket/ # Browser client for Toolkit socket sync + state/ # Zustand stores and persistence helpers + styles/ # Shared RN typography/style helpers + types/ # Asset and ambient type declarations +socket/ # Local Toolkit Socket.IO server +electron/ # Electron main/preload source +__tests__/ # Jest tests +``` + +## Current App Behavior + +- Home menu shows New Game, Saves, Settings, Policies, Credits, and Toolkit. +- Toolkit is shown only when the resolution setting is `browser`. +- Toolkit is clickable only when the socket status is connected. +- Route state persists through refreshes. +- Settings persist resolution, autosave, mute, and master/music/ambience/speech/SFX volume. +- Browser background music is implemented in `useBackgroundMusic.web.ts`. +- Credits Toolkit data is sourced from `src/data/credits.json` and synced through the socket server. ## Common Tasks -### Add a new screen -1. Create `src/screens/YourScreen.tsx` -2. Register in the navigator (once navigation is set up) -3. Add a test in `__tests__/YourScreen.test.tsx` +### Add or change a screen + +1. Update route metadata in `src/navigation/routes.ts` when adding a route. +2. Wire route rendering in `src/navigation/AppNavigator.tsx` if needed. +3. Add or update the screen in `src/screens/`. +4. Add tests in `__tests__/screenScaffold.test.tsx` or a focused test file. +5. Update `manifest.llm.json` if routes, screens, or architecture change. + +### Add or change persisted state + +1. Prefer an existing store in `src/state/` when the state belongs there. +2. Use `createPersistOptions`. +3. Keep platform storage inside `persistStorage.ts` and `persistStorage.web.ts`. +4. Add merge/migration handling for persisted shape changes. +5. Add store tests. +6. Update `manifest.llm.json` if store names, persist keys, or state surfaces change. + +### Add Toolkit content sync + +1. Keep canonical editable JSON in `src/data/`. +2. Add a typed model and runtime validation near the data export. +3. Extend `socket/contentStore.cjs` and `socket/server.mjs` for server read/save/watch behavior. +4. Extend `src/socket/toolkitSocketClient.ts` and a store under `src/state/` for client state. +5. Keep Toolkit UI read-only unless editable forms are explicitly requested. + +### Work with assets + +- Images: `src/assets/images/` +- Fonts: `src/assets/fonts/` +- Music: `src/assets/music/` +- Browser SCSS: `src/assets/scss/` +- Add or update asset type declarations in `src/types/assets.d.ts` when needed. + +## Commands -### Run the app ```bash npm install -# iOS (first time or after native changes): -cd ios && bundle exec pod install && cd .. -npm run ios - -# Android: -npm run android +npm run web +npm run dev:toolkit +npm run dev:all +npm run dev:clear +npm run electron:dev +npm run web:build +npm run electron:build +npm run lint +npm run test -- --runInBand +npm run validate:commit ``` -### Lint + Format +Mobile commands remain available for when native folders are restored: + ```bash -npm run lint # ESLint -npx prettier --write . # Format all files +npm run android +npm run ios +npm run start ``` ---- +## Verification + +- Logic or store changes: `npm run test -- --runInBand` +- UI changes: `npm run lint`, `npm run test -- --runInBand`, `npm run web:build` +- Electron changes: `npm run electron:build` +- Socket changes: run tests plus `npm run dev:toolkit` and check `/health` +- Commit validation: `npm run validate:commit` + +Husky runs `npm run validate:commit` on pre-commit. ## Commit Message Format -``` + +```text type(scope): short description - -Types: feat | fix | chore | docs | refactor | test | style -Examples: - feat(screens): add BoneKingHomeScreen - fix(android): resolve gradle build error - chore(deps): bump react-native to 0.85.3 - docs: update AGENTS.md ``` ---- +Types: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `style`. + +Examples: + +```text +feat(toolkit): add credits content sync +fix(settings): persist audio mute state +docs: update project agent instructions +``` ## Gitea Repo + `http://100.79.253.19:3000/jacob-mathison/blb-throne-of-the-bone-king` Default branch: `main` diff --git a/App.tsx b/App.tsx index ffaccd1..30e15ab 100644 --- a/App.tsx +++ b/App.tsx @@ -5,10 +5,12 @@ import { import { SafeAreaProvider } from 'react-native-safe-area-context'; import { GameViewport } from './src/components/GameViewport'; +import { useBackgroundMusic } from './src/hooks/useBackgroundMusic'; import { AppNavigator } from './src/navigation/AppNavigator'; function App() { const isDarkMode = useColorScheme() === 'dark'; + useBackgroundMusic(); return ( diff --git a/README.md b/README.md index 3e2c3f8..11661e3 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,135 @@ -This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). +# Throne of the Bone King -# Getting Started +React Native game shell for the Bone Lord Bob universe. The current app shares one React Native UI layer across browser, Electron, and future native mobile targets. -> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. +The project is in early development. The main working surface is a landscape-first menu and toolkit scaffold with persisted settings, route persistence, background music, policy/credits pages, and a browser-only Toolkit backed by a local Socket.IO server. -## Step 1: Start Metro +## Current Targets -First, you will need to run **Metro**, the JavaScript build tool for React Native. +- Browser: Vite + React Native Web at `http://127.0.0.1:5173` +- Electron: Vite renderer plus compiled Electron main/preload files +- Native mobile: React Native 0.85.3 source is present, but `android/` and `ios/` are not currently in this checkout +- Toolkit: browser-only support surface gated by the Browser resolution setting and an active socket connection -To start the Metro dev server, run the following command from the root of your React Native project: +## Requirements -```sh -# Using npm -npm start +- Node `>=22.12.0` +- npm +- Native mobile toolchains only when `android/` or `ios/` are restored or generated -# OR using Yarn -yarn start +## Install + +```bash +npm install ``` -## Step 2: Build and run your app +## Development -With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: - -### Android - -```sh -# Using npm -npm run android - -# OR using Yarn -yarn android +```bash +npm run web ``` -### iOS +Starts the Vite browser app on `http://127.0.0.1:5173`. -For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). - -The first time you create a new project, run the Ruby bundler to install CocoaPods itself: - -```sh -bundle install +```bash +npm run dev:toolkit ``` -Then, and every time you update your native dependencies, run: +Starts the local Toolkit socket server on `http://127.0.0.1:5174`. -```sh -bundle exec pod install +```bash +npm run dev:all ``` -For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). +Starts both the Vite app and Toolkit socket server. -```sh -# Using npm -npm run ios - -# OR using Yarn -yarn ios +```bash +npm run dev:clear ``` -If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. +Attempts to stop the related local dev processes if a previous run hangs. -This is one way to run your app — you can also build it directly from Android Studio or Xcode. +## Build Commands -## Step 3: Modify your app +```bash +npm run web:build +npm run web:preview +npm run electron:build +npm run electron:dev +npm run electron:package +``` -Now that you have successfully run the app, let's make changes! +`electron:build` builds the web renderer into `dist/renderer/` and compiles Electron process files into `dist/electron/`. -Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). +## Validation -When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: +```bash +npm run lint +npm run test -- --runInBand +npm run web:build +npm run electron:build +``` -- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). -- **iOS**: Press R in iOS Simulator. +Husky runs `npm run validate:commit` on commit. That command runs lint, Jest, and the Electron build path. -## Congratulations! :tada: +## App Structure -You've successfully run and modified your React Native App. :partying_face: +```text +App.tsx # Safe area, status bar, viewport, navigator root +src/navigation/ # Route metadata and lightweight app-state navigator +src/screens/ # Home, Settings, Policies, Toolkit, generic pages +src/components/ # Shared React Native UI primitives +src/state/ # Zustand stores and persistence adapters +src/settings/ # Resolution and audio setting definitions +src/hooks/ # Background music and socket connection hooks +src/socket/ # Browser client for Toolkit socket content sync +socket/ # Local Socket.IO Toolkit server +src/data/ # Canonical editable JSON content such as credits +src/assets/ # Images, fonts, music, and SCSS entrypoints +electron/ # Electron main and preload source +``` -### Now what? +## State and Persistence -- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). -- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). +The app uses Zustand. Route state, game settings, and Toolkit content state are separate stores. -# Troubleshooting +- Native persistence adapter: `src/state/persistStorage.ts` +- Web/Electron persistence adapter: `src/state/persistStorage.web.ts` +- Shared persist helper: `src/state/createPersistOptions.ts` +- Game settings persist key: `blb-game-settings` +- Navigation persist key: `blb-navigation` -If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. +Game settings currently include: -# Learn More +- resolution: `browser`, `iphoneMini`, `iphoneDefault`, `androidDefault`, `phoneLarge` +- autosave +- audio mute +- master, music, ambience, speech, and SFX volume -To learn more about React Native, take a look at the following resources: +## Toolkit -- [React Native Website](https://reactnative.dev) - learn more about React Native. -- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. -- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. -- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. -- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. +Toolkit access is intentionally limited: + +- resolution must be set to `Browser` +- socket status must be connected +- socket server runs with `npm run dev:toolkit` + +The Toolkit currently contains read-only planning panels for volatile gameplay systems, plus a dedicated Credits tab. Credits content is sourced from `src/data/credits.json` and synchronized through socket events for read, save, change broadcast, and refresh recovery. + +Socket health check: + +```bash +curl http://127.0.0.1:5174/health +``` + +## Assets + +Image assets live in `src/assets/images/`. Fonts live in `src/assets/fonts/`. Music lives in `src/assets/music/`. Browser SCSS entrypoints live in `src/assets/scss/`. + +The browser favicon is `src/assets/images/favicon.svg`. + +## Native Mobile Notes + +The shared UI is React Native and should remain native-compatible. Do not introduce DOM APIs into shared `src/` code. Browser-only APIs belong in `.web.ts` files or `src/web/`. + +When native folders are restored, native dependency setup such as CocoaPods should be handled in the platform folders at that time. diff --git a/__tests__/creditsData.test.ts b/__tests__/creditsData.test.ts new file mode 100644 index 0000000..65d910e --- /dev/null +++ b/__tests__/creditsData.test.ts @@ -0,0 +1,24 @@ +import { fallbackCreditsContent, isCreditsContent } from '../src/data'; + +describe('credits data', () => { + it('provides valid fallback credits content', () => { + expect(isCreditsContent(fallbackCreditsContent)).toBe(true); + expect(fallbackCreditsContent.mainLogo.title).toBe('Throne of the Bone King'); + expect(fallbackCreditsContent.contributors.length).toBeGreaterThan(0); + expect(fallbackCreditsContent.technologies.length).toBeGreaterThan(0); + expect(fallbackCreditsContent.specialThanks.length).toBeGreaterThan(0); + }); + + it('rejects malformed credits content', () => { + expect( + isCreditsContent({ + contributors: [], + mainLogo: { + title: 'Missing subtitle', + }, + specialThanks: [], + technologies: [], + }), + ).toBe(false); + }); +}); diff --git a/__tests__/gameSettings.test.ts b/__tests__/gameSettings.test.ts index d8ac1ad..a0f9954 100644 --- a/__tests__/gameSettings.test.ts +++ b/__tests__/gameSettings.test.ts @@ -44,6 +44,7 @@ describe('game settings definitions', () => { ambience: 70, master: 80, music: 70, + sfx: 80, speech: 80, }); }); diff --git a/__tests__/gameSettingsStore.test.ts b/__tests__/gameSettingsStore.test.ts index 2c1e993..bab855b 100644 --- a/__tests__/gameSettingsStore.test.ts +++ b/__tests__/gameSettingsStore.test.ts @@ -25,6 +25,7 @@ import { useGameSettingsStore } from '../src/state'; describe('useGameSettingsStore', () => { beforeEach(() => { useGameSettingsStore.setState({ + audioMuted: false, autosaveEnabled: true, resolutionId: 'iphoneDefault', volumes: defaultVolumeSettings, @@ -35,12 +36,14 @@ describe('useGameSettingsStore', () => { const partialize = useGameSettingsStore.persist.getOptions().partialize; expect(partialize?.(useGameSettingsStore.getState())).toEqual({ + audioMuted: false, autosaveEnabled: true, resolutionId: 'iphoneDefault', volumes: { ambience: 70, master: 80, music: 70, + sfx: 80, speech: 80, }, }); @@ -49,11 +52,13 @@ describe('useGameSettingsStore', () => { it('updates autosave, resolution, and volume settings', () => { act(() => { useGameSettingsStore.getState().toggleAutosave(); + useGameSettingsStore.getState().toggleAudioMuted(); useGameSettingsStore.getState().setResolution('phoneLarge'); useGameSettingsStore.getState().setVolume('music', 105); }); expect(useGameSettingsStore.getState().autosaveEnabled).toBe(false); + expect(useGameSettingsStore.getState().audioMuted).toBe(true); expect(useGameSettingsStore.getState().resolutionId).toBe('phoneLarge'); expect(useGameSettingsStore.getState().volumes.music).toBe(100); }); diff --git a/__tests__/navigationRoutes.test.ts b/__tests__/navigationRoutes.test.ts index a39ca95..4650861 100644 --- a/__tests__/navigationRoutes.test.ts +++ b/__tests__/navigationRoutes.test.ts @@ -31,6 +31,7 @@ describe('routeDefinitions', () => { 'toolkitStory', 'toolkitLore', 'toolkitAudio', + 'toolkitCredits', 'toolkitEndings', 'toolkitReferences', ]); @@ -70,6 +71,7 @@ describe('routeDefinitions', () => { 'toolkitStory', 'toolkitLore', 'toolkitAudio', + 'toolkitCredits', 'toolkitEndings', 'toolkitReferences', ]); @@ -77,8 +79,8 @@ describe('routeDefinitions', () => { it('normalizes stale persisted toolkit routes', () => { expect(normalizeRouteName('toolkitOpeningSequence')).toBe('toolkit'); - expect(normalizeRouteName('toolkitCredits')).toBe('toolkit'); expect(normalizeRouteName('not-a-route')).toBe('home'); expect(normalizeRouteName('toolkitPetitioners')).toBe('toolkitPetitioners'); + expect(normalizeRouteName('toolkitCredits')).toBe('toolkitCredits'); }); }); diff --git a/__tests__/screenScaffold.test.tsx b/__tests__/screenScaffold.test.tsx index 54b5421..0a88455 100644 --- a/__tests__/screenScaffold.test.tsx +++ b/__tests__/screenScaffold.test.tsx @@ -17,9 +17,11 @@ import { SettingsScreen } from '../src/screens/SettingsScreen'; import { ToolkitScreen } from '../src/screens/ToolkitScreen'; import { useGameSettingsStore, + useToolkitContentStore, useNavigationStore, useToolkitSocketStore, } from '../src/state'; +import { fallbackCreditsContent } from '../src/data'; const mountedRenderers: ReactTestRenderer[] = []; @@ -100,6 +102,7 @@ describe('screen scaffold', () => { beforeEach(() => { act(() => { useGameSettingsStore.setState({ + audioMuted: false, autosaveEnabled: true, resolutionId: 'iphoneDefault', volumes: defaultVolumeSettings, @@ -113,6 +116,13 @@ describe('screen scaffold', () => { socketId: null, status: 'disconnected', }); + useToolkitContentStore.setState({ + credits: fallbackCreditsContent, + error: null, + lastLoadedAt: null, + revision: null, + status: 'idle', + }); }); }); @@ -136,7 +146,11 @@ describe('screen scaffold', () => { expect(text).toContain('Settings'); expect(text).toContain('Policies'); expect(text).toContain('Credits'); + expect(text).toContain('Build v0.0.1'); expect(text).not.toContain('Toolkit'); + expect(renderer.root.findByProps({ testID: 'home-build-version' })).toBeTruthy(); + expect(renderer.root.findByProps({ testID: 'home-background' })).toBeTruthy(); + expect(renderer.root.findByProps({ testID: 'home-menu-background' })).toBeTruthy(); expect(renderer.root.findByProps({ testID: 'menu-newGame-icon' })).toBeTruthy(); expect(renderer.root.findByProps({ testID: 'menu-saves-icon' })).toBeTruthy(); expect(renderer.root.findByProps({ testID: 'menu-settings-icon' })).toBeTruthy(); @@ -148,6 +162,7 @@ describe('screen scaffold', () => { it('shows the toolkit home entry as a distinct button only in browser sizing', () => { act(() => { useGameSettingsStore.setState({ + audioMuted: false, autosaveEnabled: true, resolutionId: 'browser', volumes: defaultVolumeSettings, @@ -180,6 +195,7 @@ describe('screen scaffold', () => { it('enables the toolkit home entry when the toolkit socket is connected', () => { act(() => { useGameSettingsStore.setState({ + audioMuted: false, autosaveEnabled: true, resolutionId: 'browser', volumes: defaultVolumeSettings, @@ -222,11 +238,22 @@ describe('screen scaffold', () => { expect(text).toContain('Game Resolution'); expect(text).toContain('Phone Default'); - expect(text).toContain('Master Volume'); - expect(text).toContain('Music Volume'); - expect(text).toContain('Ambience Volume'); - expect(text).toContain('Speech Volume'); + expect(text).toContain('Master'); + expect(text).toContain('Music'); + expect(text).toContain('Ambience'); + expect(text).toContain('Speech'); + expect(text).toContain('SFX'); + expect(text).toContain('Mute'); expect(text).toContain('Autosave'); + expect(renderer.root.findByProps({ testID: 'audio-mute-checkbox' })).toBeTruthy(); + expect(renderer.root.findByProps({ testID: 'volume-master-slider' })).toBeTruthy(); + expect(renderer.root.findByProps({ testID: 'volume-music-slider' })).toBeTruthy(); + expect(renderer.root.findAllByProps({ testID: 'volume-music-decrease' })).toHaveLength( + 0, + ); + expect(renderer.root.findAllByProps({ testID: 'volume-music-increase' })).toHaveLength( + 0, + ); }); it('renders the toolkit template and dense toolkit menu entries', () => { @@ -251,6 +278,7 @@ describe('screen scaffold', () => { expect(text).toContain('Story'); expect(text).toContain('Lore'); expect(text).toContain('Audio'); + expect(text).toContain('Credits'); expect(text).toContain('Endings'); expect(text).toContain('References'); expect(text).toContain('Read-only planning panel'); @@ -284,6 +312,10 @@ describe('screen scaffold', () => { } as const; for (const routeName of toolkitRoutes) { + if (routeName === 'toolkitCredits') { + continue; + } + const renderer = render( { } }); + it('renders the toolkit credits display card from fallback data', () => { + const renderer = render( + , + ); + const text = getRenderedText(renderer); + + expect(text).toContain('Throne of the Bone King'); + expect(text).toContain('A Bone Lord Bob kingdom-management story'); + expect(text).toContain('Jacob Mathison'); + expect(text).toContain('React Native'); + expect(text).toContain('Font Library contributors'); + expect(text).toContain('Build v0.0.1'); + expect(text).toContain('Bundled fallback credits'); + expect(renderer.root.findByProps({ testID: 'toolkit-credits-card' })).toBeTruthy(); + expect( + renderer.root.findByProps({ testID: 'toolkit-credits-contributors' }), + ).toBeTruthy(); + expect( + renderer.root.findByProps({ testID: 'toolkit-credits-technologies' }), + ).toBeTruthy(); + expect(renderer.root.findByProps({ testID: 'toolkit-credits-thanks' })).toBeTruthy(); + }); + it('renders the toolkit websocket connection indicator', () => { act(() => { useToolkitSocketStore.getState().setConnected('test-socket'); diff --git a/__tests__/socketContentStore.test.js b/__tests__/socketContentStore.test.js new file mode 100644 index 0000000..068dfc8 --- /dev/null +++ b/__tests__/socketContentStore.test.js @@ -0,0 +1,97 @@ +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); + +const { + createJsonContentStore, +} = require('../socket/contentStore.cjs'); + +const validCredits = { + contributors: [ + { + credit: 'Implementation', + name: 'Test Contributor', + role: 'Developer', + url: null, + }, + ], + mainLogo: { + image: null, + subtitle: 'Test subtitle', + title: 'Test Credits', + }, + specialThanks: ['Test thanks'], + technologies: [ + { + name: 'Test Tech', + role: 'Testing', + url: null, + }, + ], +}; + +async function createTemporaryCreditsFile(content) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'blb-credits-')); + const filePath = path.join(directory, 'credits.json'); + + await fs.writeFile(filePath, `${JSON.stringify(content, null, 2)}\n`, 'utf8'); + + return filePath; +} + +describe('socket content store', () => { + it('reads valid JSON content with resource and revision metadata', async () => { + const filePath = await createTemporaryCreditsFile(validCredits); + const store = createJsonContentStore({ + credits: filePath, + }); + + await expect(store.readJsonResource('credits')).resolves.toMatchObject({ + content: validCredits, + resource: 'credits', + revision: expect.any(String), + }); + }); + + it('rejects invalid JSON content', async () => { + const filePath = await createTemporaryCreditsFile({ + mainLogo: { + title: 'Invalid', + }, + }); + const store = createJsonContentStore({ + credits: filePath, + }); + + await expect(store.readJsonResource('credits')).rejects.toThrow( + 'Invalid toolkit content', + ); + }); + + it('saves valid JSON content atomically and can read the changed payload', async () => { + const filePath = await createTemporaryCreditsFile(validCredits); + const store = createJsonContentStore({ + credits: filePath, + }); + const updatedCredits = { + ...validCredits, + mainLogo: { + image: null, + subtitle: 'Changed subtitle', + title: 'Changed Credits', + }, + }; + + await expect( + store.writeJsonResource('credits', updatedCredits), + ).resolves.toMatchObject({ + content: updatedCredits, + resource: 'credits', + revision: expect.any(String), + }); + await expect(store.readJsonResource('credits')).resolves.toMatchObject({ + content: updatedCredits, + resource: 'credits', + }); + }); +}); diff --git a/__tests__/toolkitContentStore.test.ts b/__tests__/toolkitContentStore.test.ts new file mode 100644 index 0000000..ca07670 --- /dev/null +++ b/__tests__/toolkitContentStore.test.ts @@ -0,0 +1,112 @@ +import { act } from 'react-test-renderer'; + +import { fallbackCreditsContent } from '../src/data'; +import { + readToolkitContent, + saveToolkitContent, +} from '../src/socket/toolkitSocketClient'; +import { useToolkitContentStore } from '../src/state/toolkitContentStore'; + +jest.mock('../src/socket/toolkitSocketClient', () => ({ + readToolkitContent: jest.fn(), + saveToolkitContent: jest.fn(), +})); + +const mockedReadToolkitContent = jest.mocked(readToolkitContent); +const mockedSaveToolkitContent = jest.mocked(saveToolkitContent); + +const changedCredits = { + ...fallbackCreditsContent, + mainLogo: { + image: null, + subtitle: 'Changed subtitle', + title: 'Changed Credits', + }, +}; + +describe('useToolkitContentStore', () => { + beforeEach(() => { + mockedReadToolkitContent.mockReset(); + mockedSaveToolkitContent.mockReset(); + useToolkitContentStore.setState({ + credits: fallbackCreditsContent, + error: null, + lastLoadedAt: null, + revision: null, + status: 'idle', + }); + }); + + it('loads credits from the socket client', async () => { + mockedReadToolkitContent.mockResolvedValue({ + content: changedCredits, + resource: 'credits', + revision: '123', + }); + + await act(async () => { + await useToolkitContentStore.getState().refreshCredits(); + }); + + expect(mockedReadToolkitContent).toHaveBeenCalledWith('credits'); + expect(useToolkitContentStore.getState()).toMatchObject({ + credits: changedCredits, + error: null, + revision: '123', + status: 'ready', + }); + expect(useToolkitContentStore.getState().lastLoadedAt).not.toBeNull(); + }); + + it('saves credits through the socket client', async () => { + mockedSaveToolkitContent.mockResolvedValue({ + content: changedCredits, + resource: 'credits', + revision: '456', + }); + + await act(async () => { + await useToolkitContentStore.getState().saveCredits(changedCredits); + }); + + expect(mockedSaveToolkitContent).toHaveBeenCalledWith( + 'credits', + changedCredits, + ); + expect(useToolkitContentStore.getState()).toMatchObject({ + credits: changedCredits, + error: null, + revision: '456', + status: 'ready', + }); + }); + + it('replaces credits from a socket change event', () => { + act(() => { + useToolkitContentStore + .getState() + .setCreditsFromSocket(changedCredits, '789'); + }); + + expect(useToolkitContentStore.getState()).toMatchObject({ + credits: changedCredits, + error: null, + revision: '789', + status: 'ready', + }); + }); + + it('tracks content request errors without losing fallback credits', async () => { + mockedReadToolkitContent.mockRejectedValue(new Error('socket unavailable')); + + await act(async () => { + await useToolkitContentStore.getState().refreshCredits(); + }); + + expect(useToolkitContentStore.getState()).toMatchObject({ + credits: fallbackCreditsContent, + error: 'socket unavailable', + status: 'error', + }); + }); +}); diff --git a/index.html b/index.html index 43dcde6..1558608 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> + + Throne of the Bone King diff --git a/llm.txt b/llm.txt index 6a9fe59..d5819f5 100644 --- a/llm.txt +++ b/llm.txt @@ -70,7 +70,7 @@ Vite resolves `.web.ts` before `.ts` (see `vite.config.ts` `resolve.extensions`) Shared stores import `./persistStorage`; the correct implementation is picked per bundler. ### Web entry -`src/web/main.tsx` registers `App` via `AppRegistry.runApplication` against `#root` in `index.html`. Global styles: `src/web/styles.css`. +`src/web/main.tsx` registers `App` via `AppRegistry.runApplication` against `#root` in `index.html`. Global styles: `src/assets/scss/index.scss`; image assets live under `src/assets/images/`. ### Electron shell - `electron/main.ts` — `BrowserWindow`, context isolation, no node integration diff --git a/manifest.llm.json b/manifest.llm.json index 6024847..29aff09 100644 --- a/manifest.llm.json +++ b/manifest.llm.json @@ -2,8 +2,8 @@ "schemaVersion": "1.0.0", "project": { "name": "blb-throne-of-the-bone-king", - "displayName": "Bone Lord Bob — Throne of the Bone King", - "purpose": "Cross-platform game shell with shared React Native UI across mobile, web, and desktop.", + "displayName": "Bone Lord Bob - Throne of the Bone King", + "purpose": "Landscape-first game shell with shared React Native UI across browser, Electron, and future native mobile targets.", "root": ".", "model": "single-repo", "repo": { @@ -11,19 +11,20 @@ "slug": "jacob-mathison/blb-throne-of-the-bone-king", "defaultBranch": "main" }, - "universe": "Bone Lord Bob (BLB) — Mathison Projects Inc.", + "universe": "Bone Lord Bob (BLB) - Mathison Projects Inc.", "status": "early-development", - "summary": "Menu shell with home navigation, settings (resolution/volume/autosave), policies, toolkit pages, and Zustand-persisted state. Primary iteration targets are web (Vite) and Electron; mobile targets share the same src/ UI layer." + "summary": "Menu shell with persisted route navigation, settings, policies, credits, background music, browser/Electron rendering, and a browser-only socket-gated Toolkit for volatile gameplay/content systems." }, "principles": [ "Start with llm.txt for human-readable orientation.", "Use manifest.llm.json for machine-readable routing, commands, targets, and known gaps.", - "Shared UI under src/ is React Native — not raw HTML.", + "Read AGENTS.md for coding-agent operating rules before broad changes.", + "Shared UI under src/ is React Native, not raw HTML.", "Do not use DOM APIs in shared src/ code; platform shims belong in .web.ts files or src/web/.", - "Metro bundles mobile; Vite bundles web/Electron renderer — they are not interchangeable.", + "Metro bundles mobile; Vite bundles web and the Electron renderer.", "Navigation is Zustand-based; do not add React Navigation unless explicitly requested.", - "Persistence is platform-split via persistStorage.ts (mobile) and persistStorage.web.ts (web/Electron).", - "Treat knownGaps in this manifest as the canonical repository-wide gap list.", + "Persistence is platform-split via persistStorage.ts for native and persistStorage.web.ts for web/Electron.", + "Toolkit is browser-only, requires Browser resolution, and requires an active Socket.IO connection for entry.", "Commit as Jacob Mathison (jacob@mathisonprojects.com); never commit as an AI identity." ], "guideFiles": [ @@ -44,12 +45,18 @@ "type": "agent-instructions", "purpose": "Operational instructions for coding agents: ground rules, structure conventions, and common tasks.", "alwaysRead": true + }, + { + "path": "README.md", + "type": "human-overview", + "purpose": "Human-facing overview for setup, commands, architecture, toolkit behavior, and validation." } ], "globalReadOrder": [ "llm.txt", "manifest.llm.json", - "AGENTS.md" + "AGENTS.md", + "README.md" ], "globalReadOrderSemantics": "Broad orientation order. Task routers should prefer taskRoutes read arrays to avoid over-reading.", "documentationFiles": [ @@ -71,7 +78,12 @@ { "path": "README.md", "type": "human-overview", - "purpose": "Human-facing overview (currently default React Native template; stale relative to web/Electron targets)." + "purpose": "Human-facing setup and development guide." + }, + { + "path": "socket/README.md", + "type": "toolkit-socket-notes", + "purpose": "Socket folder notes." } ], "targets": [ @@ -80,7 +92,7 @@ "label": "Web", "runtime": "react-native-web in browser", "bundler": "Vite", - "entry": "index.html → src/web/main.tsx → App.tsx", + "entry": "index.html -> src/web/main.tsx -> App.tsx", "devCommand": "npm run web", "devUrl": "http://127.0.0.1:5173", "primary": true @@ -88,10 +100,21 @@ { "id": "electron", "label": "Desktop", - "runtime": "Electron (Chromium renderer)", - "bundler": "Vite (renderer) + tsc (main)", - "entry": "electron/main.ts loads Vite dev server or dist/renderer/", + "runtime": "Electron Chromium renderer", + "bundler": "Vite renderer + TypeScript main/preload compile", + "entry": "electron/main.ts loads the Vite dev server in development or dist/renderer/ in production", "devCommand": "npm run electron:dev", + "buildCommand": "npm run electron:build", + "packageCommand": "npm run electron:package", + "primary": true + }, + { + "id": "toolkit-socket", + "label": "Toolkit Socket", + "runtime": "Node + Socket.IO", + "entry": "socket/server.mjs", + "devCommand": "npm run dev:toolkit", + "healthUrl": "http://127.0.0.1:5174/health", "primary": true }, { @@ -99,31 +122,52 @@ "label": "Android", "runtime": "React Native", "bundler": "Metro", - "entry": "index.js → App.tsx", - "devCommand": "npm run android" + "entry": "index.js -> App.tsx", + "devCommand": "npm run android", + "status": "native android/ folder is not currently present in this checkout" }, { "id": "ios", "label": "iOS", "runtime": "React Native", "bundler": "Metro", - "entry": "index.js → App.tsx", - "devCommand": "npm run ios" + "entry": "index.js -> App.tsx", + "devCommand": "npm run ios", + "status": "native ios/ folder is not currently present in this checkout" } ], "stack": [ "React Native 0.85.3", - "react-native-web", "React 19.2.3", + "react-native-web ^0.21.2", "TypeScript ^5.8.3", "Zustand ^5.0.14", - "Vite ^8", - "Electron ^42", - "socket.io / socket.io-client ^4.8 (declared, not wired)", + "@react-native-async-storage/async-storage ^3.1.1", + "Vite ^8.0.16", + "Electron ^42.4.0", + "Socket.IO and socket.io-client ^4.8.3", + "Sass ^1.101.0", "Jest", "ESLint + Prettier", + "Husky pre-commit validation", "Node >= 22.12.0" ], + "scripts": { + "dev": "npm run web", + "web": "vite --host 127.0.0.1", + "web:build": "vite build", + "web:preview": "vite preview --host 127.0.0.1", + "dev:toolkit": "node socket/server.mjs", + "dev:all": "concurrently -k -n app,toolkit -c blue,magenta \"npm run dev\" \"npm run dev:toolkit\"", + "dev:clear": "node socket/clear.mjs", + "electron:dev": "npm run electron:compile && cross-env NODE_ENV=development VITE_DEV_SERVER_URL=http://127.0.0.1:5173 concurrently -k \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && electron .\"", + "electron:build": "npm run web:build && npm run electron:compile", + "electron:compile": "tsc -p tsconfig.electron.json", + "electron:package": "npm run electron:build && electron-builder", + "lint": "eslint .", + "test": "jest", + "validate:commit": "npm run lint && npm run test -- --runInBand && npm run electron:build" + }, "architecture": { "rootComponent": "App.tsx", "componentTree": [ @@ -132,21 +176,32 @@ "AppNavigator" ], "navigation": { - "type": "zustand-store", + "type": "zustand-store with animated fade transitions", "routes": "src/navigation/routes.ts", "navigator": "src/navigation/AppNavigator.tsx", - "store": "src/state/navigationStore.ts" + "store": "src/state/navigationStore.ts", + "persistKey": "blb-navigation" }, "state": { "library": "zustand", "stores": [ { "file": "src/state/navigationStore.ts", - "persistKey": "blb-navigation" + "persistKey": "blb-navigation", + "purpose": "Current route and pending route persistence." }, { "file": "src/state/gameSettingsStore.ts", - "persistKey": "blb-game-settings" + "persistKey": "blb-game-settings", + "purpose": "Resolution, autosave, mute, and audio volume settings." + }, + { + "file": "src/state/toolkitSocketStore.ts", + "purpose": "Toolkit socket connection status." + }, + { + "file": "src/state/toolkitContentStore.ts", + "purpose": "Toolkit credits content, revision, load status, and socket refresh/save actions." } ], "persistHelper": "src/state/createPersistOptions.ts", @@ -155,7 +210,23 @@ "web": "src/state/persistStorage.web.ts" } }, - "settings": "src/settings/gameSettings.ts", + "settings": { + "file": "src/settings/gameSettings.ts", + "resolutionIds": [ + "browser", + "iphoneMini", + "iphoneDefault", + "androidDefault", + "phoneLarge" + ], + "volumeKeys": [ + "master", + "music", + "ambience", + "speech", + "sfx" + ] + }, "screens": [ "src/screens/HomeScreen.tsx", "src/screens/SettingsScreen.tsx", @@ -169,6 +240,67 @@ "src/components/MenuButton.tsx", "src/components/BackHomeButton.tsx" ], + "assets": { + "images": "src/assets/images/", + "fonts": "src/assets/fonts/", + "music": "src/assets/music/", + "scss": "src/assets/scss/", + "favicon": "src/assets/images/favicon.svg" + }, + "data": { + "credits": { + "json": "src/data/credits.json", + "typedExport": "src/data/credits.ts" + }, + "legal": { + "json": "src/data/legal.json" + } + }, + "audio": { + "hook": "src/hooks/useBackgroundMusic.ts", + "webHook": "src/hooks/useBackgroundMusic.web.ts", + "defaultTrack": "src/assets/music/throne-of-the-bone-king.mp3", + "volumeControls": [ + "master", + "music", + "mute" + ] + }, + "toolkit": { + "screen": "src/screens/ToolkitScreen.tsx", + "accessRules": [ + "resolution setting must be browser", + "socket status must be connected" + ], + "routes": [ + "toolkitOpening", + "toolkitPetitioners", + "toolkitRulings", + "toolkitSeed", + "toolkitResources", + "toolkitMap", + "toolkitFactions", + "toolkitFlags", + "toolkitStory", + "toolkitLore", + "toolkitAudio", + "toolkitCredits", + "toolkitEndings", + "toolkitReferences" + ], + "contentSync": { + "server": "socket/server.mjs", + "serverHelpers": "socket/contentStore.cjs", + "client": "src/socket/toolkitSocketClient.ts", + "store": "src/state/toolkitContentStore.ts", + "events": [ + "toolkit:content:read", + "toolkit:content:save", + "toolkit:content:changed", + "toolkit:content:error" + ] + } + }, "webEntry": "src/web/main.tsx", "electronMain": "electron/main.ts", "electronPreload": "electron/preload.ts", @@ -195,7 +327,7 @@ ] }, "state": { - "description": "Zustand stores, persistence, or game settings.", + "description": "Zustand stores, persistence, navigation, or game settings.", "read": [ "llm.txt", "manifest.llm.json", @@ -205,34 +337,105 @@ "src/state/persistStorage.web.ts" ], "policies": [ - "Extend persistStorage.ts / persistStorage.web.ts for platform storage changes.", - "Do not embed DOM or AsyncStorage calls directly in stores." + "Extend persistStorage.ts and persistStorage.web.ts for platform storage changes.", + "Do not embed DOM or AsyncStorage calls directly in stores.", + "Add merge handling when persisted state shape changes." ], "update": [ "manifest.llm.json when stores, persist keys, or settings surface change" ] }, "web-electron": { - "description": "Vite config, web entry, Electron shell, or platform-specific .web.ts overrides.", + "description": "Vite config, web entry, Electron shell, browser assets, SCSS, or platform-specific .web.ts overrides.", "read": [ "llm.txt", "manifest.llm.json", "vite.config.ts", "index.html", "src/web/", + "src/assets/", "electron/", "tsconfig.electron.json" ], "policies": [ "DOM APIs are allowed in src/web/ and .web.ts shims only.", - "Electron main/preload compile to dist/electron/." + "Electron main/preload compile to dist/electron/.", + "Vite emits renderer assets to dist/renderer/." + ], + "verify": [ + "npm run lint", + "npm run web:build", + "npm run electron:build" ], "update": [ "manifest.llm.json when targets, build outputs, or dev commands change" ] }, + "toolkit": { + "description": "Toolkit screens, socket-gated browser tooling, JSON content sync, or Toolkit content panels.", + "read": [ + "llm.txt", + "manifest.llm.json", + "src/screens/ToolkitScreen.tsx", + "src/navigation/routes.ts", + "src/state/toolkitSocketStore.ts", + "src/state/toolkitContentStore.ts", + "src/socket/toolkitSocketClient.ts", + "socket/" + ], + "policies": [ + "Keep Toolkit browser-only unless explicitly requested.", + "Keep Toolkit accessible only through Browser resolution and connected socket status.", + "Use src/data JSON as canonical editable content until schemas stabilize.", + "Do not add full CRUD forms unless explicitly requested." + ], + "verify": [ + "npm run test -- --runInBand", + "npm run dev:toolkit and check /health" + ] + }, + "data-content": { + "description": "Editable JSON content, typed data exports, and content validation.", + "read": [ + "llm.txt", + "manifest.llm.json", + "src/data/", + "socket/contentStore.cjs" + ], + "policies": [ + "Keep JSON valid and typed through src/data exports.", + "Add runtime validation for data imported by stores or socket helpers.", + "Use atomic writes for server-side JSON save behavior." + ], + "verify": [ + "npm run test -- --runInBand" + ] + }, + "realtime": { + "description": "Socket.IO server/client wiring for Toolkit connection status and content sync.", + "read": [ + "llm.txt", + "manifest.llm.json", + "socket/server.mjs", + "socket/contentStore.cjs", + "src/hooks/useToolkitSocketConnection.ts", + "src/socket/toolkitSocketClient.ts", + "src/state/toolkitSocketStore.ts", + "src/state/toolkitContentStore.ts", + "package.json" + ], + "policies": [ + "Keep socket host/port configurable through environment variables.", + "Broadcast content changes after valid reads or saves.", + "Emit toolkit:content:error for read, parse, validation, or write failures." + ], + "verify": [ + "npm run test -- --runInBand", + "npm run dev:toolkit and check http://127.0.0.1:5174/health" + ] + }, "mobile": { - "description": "React Native mobile target changes (Metro, native deps).", + "description": "React Native mobile target changes, Metro behavior, or native dependency setup.", "read": [ "llm.txt", "manifest.llm.json", @@ -242,12 +445,13 @@ "App.tsx" ], "policies": [ - "Do not modify android/ or ios/ native files unless explicitly requested.", - "Run pod install inside ios/ after native dependency changes." + "Do not modify android/ or ios/ native files unless explicitly requested and those folders exist.", + "Run pod install inside ios/ after native dependency changes when ios/ exists.", + "Keep shared src/ free of browser-only APIs." ] }, "testing": { - "description": "Jest tests for logic, stores, routes, or screen scaffolding.", + "description": "Jest tests for logic, stores, routes, socket helpers, or screen scaffolding.", "read": [ "llm.txt", "manifest.llm.json", @@ -255,23 +459,31 @@ "" ], "verify": [ - "npm run test" + "npm run test -- --runInBand" ] }, - "realtime": { - "description": "Socket.io server/client wiring (planned; not yet implemented).", + "docs": { + "description": "README, AGENTS, llm.txt, manifest, docs, or wiki maintenance.", "read": [ + "README.md", + "AGENTS.md", "llm.txt", "manifest.llm.json", - "socket/", - "package.json" + "package.json", + "src/navigation/routes.ts" ], - "policies": [ - "socket/ is currently a stub — confirm scope before implementing." + "verify": [ + "node -e \"JSON.parse(require('fs').readFileSync('manifest.llm.json','utf8')); console.log('manifest ok')\"", + "npm run lint" ] } }, "rootCommands": [ + { + "command": "npm run dev", + "purpose": "Alias for browser development.", + "target": "web" + }, { "command": "npm run web", "purpose": "Vite dev server for browser iteration.", @@ -287,6 +499,21 @@ "purpose": "Preview production web build.", "target": "web" }, + { + "command": "npm run dev:toolkit", + "purpose": "Start local Toolkit Socket.IO server.", + "target": "toolkit-socket" + }, + { + "command": "npm run dev:all", + "purpose": "Start browser app and Toolkit socket together.", + "target": "web+toolkit-socket" + }, + { + "command": "npm run dev:clear", + "purpose": "Stop related local dev processes if a run hangs.", + "target": "local-dev" + }, { "command": "npm run electron:dev", "purpose": "Run Vite dev server and Electron concurrently.", @@ -294,7 +521,7 @@ }, { "command": "npm run electron:build", - "purpose": "Build renderer and compile Electron main process.", + "purpose": "Build renderer and compile Electron main/preload files.", "target": "electron" }, { @@ -305,16 +532,16 @@ { "command": "npm run start", "purpose": "Start Metro bundler.", - "target": "android" + "target": "mobile" }, { "command": "npm run android", - "purpose": "Run on Android emulator or device.", + "purpose": "Run on Android emulator or device when android/ exists.", "target": "android" }, { "command": "npm run ios", - "purpose": "Run on iOS simulator or device.", + "purpose": "Run on iOS simulator or device when ios/ exists.", "target": "ios" }, { @@ -324,30 +551,45 @@ { "command": "npm run test", "purpose": "Jest test suite." + }, + { + "command": "npm run validate:commit", + "purpose": "Pre-commit safety check: lint, Jest, and Electron build." } ], "knownGaps": [ - "socket/ is a stub — socket.io and socket.io-client are installed but no server/client wiring exists.", "docs/ and wiki/ are placeholder README stubs.", - "AGENTS.md still describes a mobile-only app and forbids all DOM APIs; it is stale relative to web/Electron targets.", - "README.md is the default React Native Community CLI template and does not document web or Electron workflows.", - "No JSON Schema validator exists yet for manifest.llm.json." + "No JSON Schema validator exists yet for manifest.llm.json.", + "Toolkit content panels are read-only scaffolds except for socket/store save plumbing.", + "Native android/ and ios/ folders are not currently present in this checkout.", + "No database exists yet; editable content is intentionally JSON-backed until schemas stabilize." ], "verificationBaseline": { "logicOrStoreChanges": [ - "npm run test" + "npm run test -- --runInBand" ], "uiChanges": [ "npm run lint", - "npm run test" + "npm run test -- --runInBand", + "npm run web:build" ], "webElectronChanges": [ "npm run lint", - "npm run test" + "npm run test -- --runInBand", + "npm run electron:build" + ], + "socketChanges": [ + "npm run test -- --runInBand", + "npm run dev:toolkit", + "curl http://127.0.0.1:5174/health" ], "mobileChanges": [ "npm run lint", - "npm run test" + "npm run test -- --runInBand" + ], + "docsChanges": [ + "node -e \"JSON.parse(require('fs').readFileSync('manifest.llm.json','utf8')); console.log('manifest ok')\"", + "npm run lint" ] } } diff --git a/package-lock.json b/package-lock.json index c52235e..86419e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,9 +42,11 @@ "electron": "^42.4.0", "electron-builder": "^26.15.2", "eslint": "^8.19.0", + "husky": "^9.1.7", "jest": "^29.6.3", "prettier": "2.8.8", "react-test-renderer": "19.2.3", + "sass": "^1.101.0", "typescript": "^5.8.3", "vite": "^8.0.16", "wait-on": "^9.0.10" @@ -3480,6 +3482,348 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", @@ -6525,6 +6869,22 @@ "node": ">=10" } }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -10043,6 +10403,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", @@ -10114,6 +10490,13 @@ "node": ">=16.x" } }, + "node_modules/immutable": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.6.tgz", + "integrity": "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -12874,6 +13257,14 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -14245,6 +14636,20 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -14719,6 +15124,27 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "node_modules/sass": { + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", + "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", diff --git a/package.json b/package.json index e2bf206..19f0952 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "electron:package": "npm run electron:build && electron-builder", "ios": "react-native run-ios", "lint": "eslint .", + "prepare": "husky", "start": "react-native start", "test": "jest", + "validate:commit": "npm run lint && npm run test -- --runInBand && npm run electron:build", "web": "vite --host 127.0.0.1", "web:build": "vite build", "web:preview": "vite preview --host 127.0.0.1" @@ -56,9 +58,11 @@ "electron": "^42.4.0", "electron-builder": "^26.15.2", "eslint": "^8.19.0", + "husky": "^9.1.7", "jest": "^29.6.3", "prettier": "2.8.8", "react-test-renderer": "19.2.3", + "sass": "^1.101.0", "typescript": "^5.8.3", "vite": "^8.0.16", "wait-on": "^9.0.10" diff --git a/react-native.config.js b/react-native.config.js new file mode 100644 index 0000000..240776e --- /dev/null +++ b/react-native.config.js @@ -0,0 +1,3 @@ +module.exports = { + assets: ['./src/assets/fonts'], +}; diff --git a/socket/contentStore.cjs b/socket/contentStore.cjs new file mode 100644 index 0000000..5b46b3d --- /dev/null +++ b/socket/contentStore.cjs @@ -0,0 +1,118 @@ +/* eslint-env node */ + +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const defaultResourcePaths = { + credits: path.resolve(__dirname, '..', 'src', 'data', 'credits.json'), +}; + +function isRecord(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function isOptionalString(value) { + return value == null || typeof value === 'string'; +} + +function isCreditsLogo(value) { + return ( + isRecord(value) && + typeof value.title === 'string' && + typeof value.subtitle === 'string' && + isOptionalString(value.image) + ); +} + +function isCreditsContributor(value) { + return ( + isRecord(value) && + typeof value.name === 'string' && + typeof value.role === 'string' && + isOptionalString(value.credit) && + isOptionalString(value.url) + ); +} + +function isCreditsTechnology(value) { + return ( + isRecord(value) && + typeof value.name === 'string' && + isOptionalString(value.role) && + isOptionalString(value.url) + ); +} + +function isCreditsContent(value) { + return ( + isRecord(value) && + isCreditsLogo(value.mainLogo) && + Array.isArray(value.contributors) && + value.contributors.every(isCreditsContributor) && + Array.isArray(value.technologies) && + value.technologies.every(isCreditsTechnology) && + Array.isArray(value.specialThanks) && + value.specialThanks.every(item => typeof item === 'string') + ); +} + +function getResourcePath(resourcePaths, resource) { + const resourcePath = resourcePaths[resource]; + + if (resourcePath == null) { + throw new Error(`Unsupported toolkit content resource: ${String(resource)}`); + } + + return resourcePath; +} + +function validateResourceContent(resource, content) { + if (resource === 'credits' && isCreditsContent(content)) { + return content; + } + + throw new Error(`Invalid toolkit content for resource: ${resource}`); +} + +function createJsonContentStore(resourcePaths = defaultResourcePaths) { + async function readJsonResource(resource) { + const resourcePath = getResourcePath(resourcePaths, resource); + const source = await fs.readFile(resourcePath, 'utf8'); + const content = validateResourceContent(resource, JSON.parse(source)); + const stats = await fs.stat(resourcePath); + + return { + content, + resource, + revision: String(Math.round(stats.mtimeMs)), + }; + } + + async function writeJsonResource(resource, content) { + const resourcePath = getResourcePath(resourcePaths, resource); + const validatedContent = validateResourceContent(resource, content); + const temporaryPath = `${resourcePath}.${process.pid}.${Date.now()}.tmp`; + const formattedContent = `${JSON.stringify(validatedContent, null, 2)}\n`; + + await fs.writeFile(temporaryPath, formattedContent, 'utf8'); + await fs.rename(temporaryPath, resourcePath); + + return readJsonResource(resource); + } + + return { + readJsonResource, + resourcePaths, + writeJsonResource, + }; +} + +const defaultStore = createJsonContentStore(); + +module.exports = { + createJsonContentStore, + defaultResourcePaths, + isCreditsContent, + readJsonResource: defaultStore.readJsonResource, + writeJsonResource: defaultStore.writeJsonResource, +}; diff --git a/socket/server.mjs b/socket/server.mjs index 7bb5a22..fe073b6 100644 --- a/socket/server.mjs +++ b/socket/server.mjs @@ -1,9 +1,12 @@ import 'dotenv/config'; +import { watch } from 'node:fs'; import { createServer } from 'node:http'; import { Server } from 'socket.io'; +import contentStore from './contentStore.cjs'; + const defaultWebOrigin = 'http://127.0.0.1:5173'; const host = process.env.TOOLKIT_SOCKET_HOST ?? '127.0.0.1'; const port = Number.parseInt(process.env.TOOLKIT_SOCKET_PORT ?? '5174', 10); @@ -50,6 +53,55 @@ const io = new Server(httpServer, { }, }); +const contentWatchDebounceMs = 100; +let contentWatchTimer = null; + +function getErrorMessage(error) { + return error instanceof Error ? error.message : 'Unknown toolkit content error.'; +} + +function createContentError(resource, error) { + return { + error: getErrorMessage(error), + resource, + }; +} + +async function readContent(resource) { + return contentStore.readJsonResource(resource); +} + +async function saveContent(resource, content) { + return contentStore.writeJsonResource(resource, content); +} + +async function broadcastChangedContent(resource) { + try { + io.emit('toolkit:content:changed', await readContent(resource)); + } catch (error) { + io.emit('toolkit:content:error', createContentError(resource, error)); + } +} + +function scheduleContentBroadcast(resource) { + if (contentWatchTimer != null) { + clearTimeout(contentWatchTimer); + } + + contentWatchTimer = setTimeout(() => { + contentWatchTimer = null; + broadcastChangedContent(resource); + }, contentWatchDebounceMs); +} + +const creditsWatcher = watch( + contentStore.defaultResourcePaths.credits, + { persistent: false }, + () => { + scheduleContentBroadcast('credits'); + }, +); + io.on('connection', socket => { socket.emit('toolkit:ready', { connectedAt: new Date().toISOString(), @@ -59,11 +111,54 @@ io.on('connection', socket => { socket.on('toolkit:event', payload => { socket.broadcast.emit('toolkit:event', payload); }); + + socket.on('toolkit:content:read', async (payload, respond) => { + const resource = payload?.resource; + + try { + respond?.({ + ok: true, + ...(await readContent(resource)), + }); + } catch (error) { + const contentError = createContentError(resource, error); + + respond?.({ + ok: false, + ...contentError, + }); + socket.emit('toolkit:content:error', contentError); + } + }); + + socket.on('toolkit:content:save', async (payload, respond) => { + const resource = payload?.resource; + + try { + const changedContent = await saveContent(resource, payload?.content); + + respond?.({ + ok: true, + ...changedContent, + }); + io.emit('toolkit:content:changed', changedContent); + } catch (error) { + const contentError = createContentError(resource, error); + + respond?.({ + ok: false, + ...contentError, + }); + socket.emit('toolkit:content:error', contentError); + } + }); }); function closeServer(signal) { console.log(`[toolkit-socket] received ${signal}; shutting down`); + creditsWatcher.close(); + io.close(() => { httpServer.close(() => { process.exit(0); diff --git a/src/assets/fonts/mitica/MiTicaRegular.otf b/src/assets/fonts/mitica/MiTicaRegular.otf new file mode 100644 index 0000000..8721ddd Binary files /dev/null and b/src/assets/fonts/mitica/MiTicaRegular.otf differ diff --git a/src/assets/fonts/single-day/SingleDayRegular.ttf b/src/assets/fonts/single-day/SingleDayRegular.ttf new file mode 100644 index 0000000..e870674 Binary files /dev/null and b/src/assets/fonts/single-day/SingleDayRegular.ttf differ diff --git a/Game Assets 1/Bone Lord Bob sitting on throne (static) facing left.png b/src/assets/images/bone-lord-bob-throne-facing-left.png similarity index 100% rename from Game Assets 1/Bone Lord Bob sitting on throne (static) facing left.png rename to src/assets/images/bone-lord-bob-throne-facing-left.png diff --git a/Game Assets 1/Bone Lord Bob sitting on throne (static) facing right.png b/src/assets/images/bone-lord-bob-throne-facing-right.png similarity index 100% rename from Game Assets 1/Bone Lord Bob sitting on throne (static) facing right.png rename to src/assets/images/bone-lord-bob-throne-facing-right.png diff --git a/Game Assets 1/Concept 1 (Anime).png b/src/assets/images/concept-1-anime.png similarity index 100% rename from Game Assets 1/Concept 1 (Anime).png rename to src/assets/images/concept-1-anime.png diff --git a/Game Assets 1/Concept 1 (Realistic).png b/src/assets/images/concept-1-realistic.png similarity index 100% rename from Game Assets 1/Concept 1 (Realistic).png rename to src/assets/images/concept-1-realistic.png diff --git a/Game Assets 1/Concept 2 (Realistic).png b/src/assets/images/concept-2-realistic.png similarity index 100% rename from Game Assets 1/Concept 2 (Realistic).png rename to src/assets/images/concept-2-realistic.png diff --git a/src/assets/images/favicon.svg b/src/assets/images/favicon.svg new file mode 100644 index 0000000..4ee9455 --- /dev/null +++ b/src/assets/images/favicon.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Game Assets 1/Generic Background (Anime) 2.png b/src/assets/images/generic-background-anime-2.png similarity index 100% rename from Game Assets 1/Generic Background (Anime) 2.png rename to src/assets/images/generic-background-anime-2.png diff --git a/Game Assets 1/Generic Background (Anime).png b/src/assets/images/generic-background-anime.png similarity index 100% rename from Game Assets 1/Generic Background (Anime).png rename to src/assets/images/generic-background-anime.png diff --git a/Game Assets 1/Generic Background (Realistic).png b/src/assets/images/generic-background-realistic.png similarity index 100% rename from Game Assets 1/Generic Background (Realistic).png rename to src/assets/images/generic-background-realistic.png diff --git a/Game Assets 1/Landing Screen Background (Anime).png b/src/assets/images/landing-screen-background-anime.png similarity index 100% rename from Game Assets 1/Landing Screen Background (Anime).png rename to src/assets/images/landing-screen-background-anime.png diff --git a/Game Assets 1/Landing Screen Background (Realistic).png b/src/assets/images/landing-screen-background-realistic.png similarity index 100% rename from Game Assets 1/Landing Screen Background (Realistic).png rename to src/assets/images/landing-screen-background-realistic.png diff --git a/Game Assets 1/Landing Screen Menu Concept (Anime).png b/src/assets/images/landing-screen-menu-concept-anime.png similarity index 100% rename from Game Assets 1/Landing Screen Menu Concept (Anime).png rename to src/assets/images/landing-screen-menu-concept-anime.png diff --git a/Game Assets 1/Landing Screen Menu Concept (Realsitic).png b/src/assets/images/landing-screen-menu-concept-realistic.png similarity index 100% rename from Game Assets 1/Landing Screen Menu Concept (Realsitic).png rename to src/assets/images/landing-screen-menu-concept-realistic.png diff --git a/Game Assets 1/Menu Background (Anime).png b/src/assets/images/menu-background-anime.png similarity index 100% rename from Game Assets 1/Menu Background (Anime).png rename to src/assets/images/menu-background-anime.png diff --git a/Game Assets 1/Menu Background (Realistic).png b/src/assets/images/menu-background-realistic.png similarity index 100% rename from Game Assets 1/Menu Background (Realistic).png rename to src/assets/images/menu-background-realistic.png diff --git a/Game Assets 1/Square, high res button (Stretchable).png b/src/assets/images/square-high-res-button-stretchable.png similarity index 100% rename from Game Assets 1/Square, high res button (Stretchable).png rename to src/assets/images/square-high-res-button-stretchable.png diff --git a/src/assets/music/index.ts b/src/assets/music/index.ts new file mode 100644 index 0000000..72b31e4 --- /dev/null +++ b/src/assets/music/index.ts @@ -0,0 +1 @@ +export { default as throneOfTheBoneKingMusic } from './throne-of-the-bone-king.mp3'; diff --git a/src/assets/music/throne-of-the-bone-king.mp3 b/src/assets/music/throne-of-the-bone-king.mp3 new file mode 100644 index 0000000..238a6e7 Binary files /dev/null and b/src/assets/music/throne-of-the-bone-king.mp3 differ diff --git a/src/assets/music/world-of-the-bone-king.mp3 b/src/assets/music/world-of-the-bone-king.mp3 new file mode 100644 index 0000000..00b0d5f Binary files /dev/null and b/src/assets/music/world-of-the-bone-king.mp3 differ diff --git a/src/assets/scss/__globals.scss b/src/assets/scss/__globals.scss new file mode 100644 index 0000000..9e51ddd --- /dev/null +++ b/src/assets/scss/__globals.scss @@ -0,0 +1,33 @@ +@use './__variables' as variables; + +@font-face { + font-family: 'MiTicaRegular'; + font-style: normal; + font-weight: normal; + src: url('../fonts/mitica/MiTicaRegular.otf') format('opentype'); +} + +@font-face { + font-family: 'SingleDayRegular'; + font-style: normal; + font-weight: normal; + src: url('../fonts/single-day/SingleDayRegular.ttf') format('truetype'); +} + +html, +body, +#root { + height: 100%; + margin: 0; + min-height: 100%; +} + +body { + background: variables.$color-page-background; + font-family: 'SingleDayRegular', sans-serif; + overflow: hidden; +} + +#root { + display: flex; +} diff --git a/src/assets/scss/__variables.scss b/src/assets/scss/__variables.scss new file mode 100644 index 0000000..05cfe71 --- /dev/null +++ b/src/assets/scss/__variables.scss @@ -0,0 +1 @@ +$color-page-background: #050607; diff --git a/src/assets/scss/index.scss b/src/assets/scss/index.scss new file mode 100644 index 0000000..e385542 --- /dev/null +++ b/src/assets/scss/index.scss @@ -0,0 +1 @@ +@use './__globals'; diff --git a/src/components/MenuButton.tsx b/src/components/MenuButton.tsx index 7185f6f..14cb652 100644 --- a/src/components/MenuButton.tsx +++ b/src/components/MenuButton.tsx @@ -1,5 +1,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { displayTextStyle } from '../styles/typography'; + type MenuButtonTone = 'default' | 'toolkit'; type MenuButtonProps = Readonly<{ @@ -98,9 +100,9 @@ const styles = StyleSheet.create({ textAlign: 'center', }, label: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 14, - fontWeight: '700', letterSpacing: 0, textAlign: 'center', }, diff --git a/src/components/ScreenLayout.tsx b/src/components/ScreenLayout.tsx index a0d4ab7..da0c878 100644 --- a/src/components/ScreenLayout.tsx +++ b/src/components/ScreenLayout.tsx @@ -2,6 +2,8 @@ import type { ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { defaultTextStyle, displayTextStyle } from '../styles/typography'; + type ScreenLayoutProps = Readonly<{ children: ReactNode; eyebrow?: string; @@ -55,6 +57,7 @@ const styles = StyleSheet.create({ minWidth: 0, }, eyebrow: { + ...defaultTextStyle, color: '#d6b25e', fontSize: 12, fontWeight: '700', @@ -64,9 +67,9 @@ const styles = StyleSheet.create({ textTransform: 'uppercase', }, title: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 30, - fontWeight: '800', letterSpacing: 0, lineHeight: 36, textAlign: 'left', diff --git a/src/config/buildInfo.ts b/src/config/buildInfo.ts new file mode 100644 index 0000000..3b1f868 --- /dev/null +++ b/src/config/buildInfo.ts @@ -0,0 +1,3 @@ +export const appVersion = '0.0.1'; + +export const buildVersionLabel = `Build v${appVersion}`; diff --git a/src/config/buildInfo.web.ts b/src/config/buildInfo.web.ts new file mode 100644 index 0000000..e816cce --- /dev/null +++ b/src/config/buildInfo.web.ts @@ -0,0 +1,10 @@ +/// + +const viteAppVersion = import.meta.env.VITE_APP_VERSION; + +export const appVersion = + typeof viteAppVersion === 'string' && viteAppVersion.length > 0 + ? viteAppVersion + : '0.0.1'; + +export const buildVersionLabel = `Build v${appVersion}`; diff --git a/src/data/credits.json b/src/data/credits.json new file mode 100644 index 0000000..b389155 --- /dev/null +++ b/src/data/credits.json @@ -0,0 +1,58 @@ +{ + "mainLogo": { + "title": "Throne of the Bone King", + "subtitle": "A Bone Lord Bob kingdom-management story", + "image": null + }, + "contributors": [ + { + "name": "Jacob Mathison", + "role": "Creator", + "credit": "World, game direction, and implementation", + "url": null + }, + { + "name": "Bone Lord Bob Team", + "role": "Production", + "credit": "Design iteration, toolkit review, and content planning", + "url": null + } + ], + "technologies": [ + { + "name": "React Native", + "role": "Shared app interface", + "url": "https://reactnative.dev" + }, + { + "name": "React Native Web", + "role": "Browser renderer", + "url": "https://necolas.github.io/react-native-web/" + }, + { + "name": "Vite", + "role": "Web build tooling", + "url": "https://vite.dev" + }, + { + "name": "Electron", + "role": "Desktop shell", + "url": "https://www.electronjs.org" + }, + { + "name": "Socket.IO", + "role": "Toolkit sync channel", + "url": "https://socket.io" + }, + { + "name": "Zustand", + "role": "State management", + "url": "https://zustand-demo.pmnd.rs" + } + ], + "specialThanks": [ + "The open-source maintainers behind the browser, desktop, and React Native tooling.", + "Font Library contributors for MiTica and Single Day Regular.", + "Everyone helping shape the Bone Lord Bob universe." + ] +} diff --git a/src/data/credits.ts b/src/data/credits.ts new file mode 100644 index 0000000..c54f004 --- /dev/null +++ b/src/data/credits.ts @@ -0,0 +1,82 @@ +import creditsJson from './credits.json'; + +export type CreditsLogo = Readonly<{ + image?: string | null; + subtitle: string; + title: string; +}>; + +export type CreditsContributor = Readonly<{ + credit?: string; + name: string; + role: string; + url?: string | null; +}>; + +export type CreditsTechnology = Readonly<{ + name: string; + role?: string; + url?: string | null; +}>; + +export type CreditsContent = Readonly<{ + contributors: CreditsContributor[]; + mainLogo: CreditsLogo; + specialThanks: string[]; + technologies: CreditsTechnology[]; +}>; + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function isOptionalString(value: unknown) { + return value == null || typeof value === 'string'; +} + +function isCreditsLogo(value: unknown): value is CreditsLogo { + return ( + isRecord(value) && + typeof value.title === 'string' && + typeof value.subtitle === 'string' && + isOptionalString(value.image) + ); +} + +function isCreditsContributor(value: unknown): value is CreditsContributor { + return ( + isRecord(value) && + typeof value.name === 'string' && + typeof value.role === 'string' && + isOptionalString(value.credit) && + isOptionalString(value.url) + ); +} + +function isCreditsTechnology(value: unknown): value is CreditsTechnology { + return ( + isRecord(value) && + typeof value.name === 'string' && + isOptionalString(value.role) && + isOptionalString(value.url) + ); +} + +export function isCreditsContent(value: unknown): value is CreditsContent { + return ( + isRecord(value) && + isCreditsLogo(value.mainLogo) && + Array.isArray(value.contributors) && + value.contributors.every(isCreditsContributor) && + Array.isArray(value.technologies) && + value.technologies.every(isCreditsTechnology) && + Array.isArray(value.specialThanks) && + value.specialThanks.every(item => typeof item === 'string') + ); +} + +if (!isCreditsContent(creditsJson)) { + throw new Error('src/data/credits.json does not match CreditsContent.'); +} + +export const fallbackCreditsContent = creditsJson; diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 0000000..9e9b1c9 --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,8 @@ +export { + fallbackCreditsContent, + isCreditsContent, + type CreditsContent, + type CreditsContributor, + type CreditsLogo, + type CreditsTechnology, +} from './credits'; diff --git a/src/data/legal.json b/src/data/legal.json new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/useBackgroundMusic.ts b/src/hooks/useBackgroundMusic.ts new file mode 100644 index 0000000..3606552 --- /dev/null +++ b/src/hooks/useBackgroundMusic.ts @@ -0,0 +1,3 @@ +export function useBackgroundMusic() { + return undefined; +} diff --git a/src/hooks/useBackgroundMusic.web.ts b/src/hooks/useBackgroundMusic.web.ts new file mode 100644 index 0000000..e4d8425 --- /dev/null +++ b/src/hooks/useBackgroundMusic.web.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react'; + +import { throneOfTheBoneKingMusic } from '../assets/music'; +import { useGameSettingsStore } from '../state'; + +type AudioConstructor = new (src?: string) => HTMLAudioElement; + +type AudioHost = typeof globalThis & { + Audio?: AudioConstructor; +}; + +const audioHost = globalThis as AudioHost; + +function getEffectiveMusicVolume( + masterVolume: number, + musicVolume: number, + audioMuted: boolean, +) { + if (audioMuted) { + return 0; + } + + return (masterVolume / 100) * (musicVolume / 100); +} + +export function useBackgroundMusic() { + const audioMuted = useGameSettingsStore(state => state.audioMuted); + const masterVolume = useGameSettingsStore(state => state.volumes.master); + const musicVolume = useGameSettingsStore(state => state.volumes.music); + const audioRef = useRef(null); + + useEffect(() => { + if (audioHost.Audio == null) { + return undefined; + } + + const audio = new audioHost.Audio(throneOfTheBoneKingMusic); + + audio.loop = true; + audio.preload = 'auto'; + audio.volume = 0; + audioRef.current = audio; + + function playAudio() { + audio.play().catch(() => { + // Browsers may block autoplay until the first user gesture. + }); + } + + function unlockAudio() { + playAudio(); + globalThis.removeEventListener('pointerdown', unlockAudio); + globalThis.removeEventListener('keydown', unlockAudio); + } + + playAudio(); + globalThis.addEventListener('pointerdown', unlockAudio, { once: true }); + globalThis.addEventListener('keydown', unlockAudio, { once: true }); + + return () => { + globalThis.removeEventListener('pointerdown', unlockAudio); + globalThis.removeEventListener('keydown', unlockAudio); + audio.pause(); + audioRef.current = null; + }; + }, []); + + useEffect(() => { + if (audioRef.current == null) { + return; + } + + audioRef.current.volume = getEffectiveMusicVolume( + masterVolume, + musicVolume, + audioMuted, + ); + }, [audioMuted, masterVolume, musicVolume]); +} diff --git a/src/hooks/useToolkitSocketConnection.ts b/src/hooks/useToolkitSocketConnection.ts index 0f6ef74..bbfb4df 100644 --- a/src/hooks/useToolkitSocketConnection.ts +++ b/src/hooks/useToolkitSocketConnection.ts @@ -1,55 +1,33 @@ import { useEffect } from 'react'; -import { io, type Socket } from 'socket.io-client'; -import { useToolkitSocketStore } from '../state'; - -type NodeProcess = { - env?: Partial>; -}; - -const defaultToolkitSocketUrl = 'http://127.0.0.1:5174'; - -let toolkitSocket: Socket | null = null; - -function getToolkitSocketUrl() { - const maybeProcess = - typeof process === 'undefined' ? undefined : (process as NodeProcess); - - return maybeProcess?.env?.TOOLKIT_SOCKET_URL ?? defaultToolkitSocketUrl; -} - -function getNodeEnvironment() { - const maybeProcess = - typeof process === 'undefined' ? undefined : (process as NodeProcess); - - return maybeProcess?.env?.NODE_ENV; -} +import { + getNodeEnvironment, + getToolkitSocket, + type ToolkitContentErrorPayload, + type ToolkitContentPayload, +} from '../socket/toolkitSocketClient'; +import { useToolkitContentStore, useToolkitSocketStore } from '../state'; export function useToolkitSocketConnection() { const setConnected = useToolkitSocketStore(state => state.setConnected); const setConnecting = useToolkitSocketStore(state => state.setConnecting); const setDisconnected = useToolkitSocketStore(state => state.setDisconnected); + const refreshCredits = useToolkitContentStore(state => state.refreshCredits); + const setContentError = useToolkitContentStore(state => state.setContentError); + const setCreditsFromSocket = useToolkitContentStore( + state => state.setCreditsFromSocket, + ); useEffect(() => { if (getNodeEnvironment() === 'test') { return undefined; } - if (toolkitSocket == null) { - setConnecting(); - - toolkitSocket = io(getToolkitSocketUrl(), { - reconnection: true, - reconnectionAttempts: Number.POSITIVE_INFINITY, - reconnectionDelay: 750, - transports: ['websocket'], - }); - } - - const activeSocket = toolkitSocket; + const activeSocket = getToolkitSocket(); function handleConnect() { setConnected(activeSocket.id ?? 'unknown'); + refreshCredits(); } function handleConnectionAttempt() { @@ -60,8 +38,20 @@ export function useToolkitSocketConnection() { setDisconnected(); } + function handleContentChanged(payload: ToolkitContentPayload) { + if (payload.resource === 'credits') { + setCreditsFromSocket(payload.content, payload.revision); + } + } + + function handleContentError(payload: ToolkitContentErrorPayload) { + setContentError(payload.error); + } + activeSocket.on('connect', handleConnect); activeSocket.on('disconnect', handleDisconnect); + activeSocket.on('toolkit:content:changed', handleContentChanged); + activeSocket.on('toolkit:content:error', handleContentError); activeSocket.io.on('reconnect_attempt', handleConnectionAttempt); if (activeSocket.connected) { @@ -74,7 +64,16 @@ export function useToolkitSocketConnection() { return () => { activeSocket.off('connect', handleConnect); activeSocket.off('disconnect', handleDisconnect); + activeSocket.off('toolkit:content:changed', handleContentChanged); + activeSocket.off('toolkit:content:error', handleContentError); activeSocket.io.off('reconnect_attempt', handleConnectionAttempt); }; - }, [setConnected, setConnecting, setDisconnected]); + }, [ + refreshCredits, + setConnected, + setConnecting, + setContentError, + setCreditsFromSocket, + setDisconnected, + ]); } diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts index c8c6964..02de5b0 100644 --- a/src/navigation/routes.ts +++ b/src/navigation/routes.ts @@ -87,6 +87,10 @@ export const routeDefinitions = { label: 'Audio', title: 'Audio', }, + toolkitCredits: { + label: 'Credits', + title: 'Toolkit Credits', + }, toolkitEndings: { label: 'Endings', title: 'Failure & Endings', @@ -127,6 +131,7 @@ export const toolkitRoutes = [ 'toolkitStory', 'toolkitLore', 'toolkitAudio', + 'toolkitCredits', 'toolkitEndings', 'toolkitReferences', ] as const satisfies readonly RouteName[]; @@ -135,7 +140,6 @@ export type ToolkitRouteName = (typeof toolkitRoutes)[number]; const legacyToolkitRoutes = [ 'toolkitOpeningSequence', - 'toolkitCredits', ] as const; export function normalizeRouteName(routeName: unknown): RouteName { @@ -176,6 +180,7 @@ export const requiredPageRoutes = [ 'toolkitStory', 'toolkitLore', 'toolkitAudio', + 'toolkitCredits', 'toolkitEndings', 'toolkitReferences', ] as const satisfies readonly RouteName[]; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 42177a7..bda074f 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,13 +1,17 @@ -import { StyleSheet, Text, View } from 'react-native'; +import { ImageBackground, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import homeBackgroundImage from '../assets/images/landing-screen-background-anime.png'; +import menuBackgroundImage from '../assets/images/menu-background-anime.png'; import { MenuButton } from '../components/MenuButton'; +import { buildVersionLabel } from '../config/buildInfo'; import { primaryMenuRoutes, routeDefinitions, type RouteName, } from '../navigation/routes'; import { useGameSettingsStore, useToolkitSocketStore } from '../state'; +import { defaultTextStyle, displayTextStyle } from '../styles/typography'; type PrimaryMenuRouteName = (typeof primaryMenuRoutes)[number]; @@ -33,7 +37,10 @@ export function HomeScreen({ navigate }: HomeScreenProps) { ); return ( - + ]} + testID="home-background"> + Bone Lord Bob @@ -51,27 +60,49 @@ export function HomeScreen({ navigate }: HomeScreenProps) { Choose a path through the kingdom. - - {visibleMenuRoutes.map(routeName => ( - navigate(routeName)} - testID={`menu-${routeName}`} - tone={routeName === 'toolkit' ? 'toolkit' : 'default'} - /> - ))} - + + + {visibleMenuRoutes.map(routeName => ( + navigate(routeName)} + testID={`menu-${routeName}`} + tone={routeName === 'toolkit' ? 'toolkit' : 'default'} + /> + ))} + + + {buildVersionLabel} + + - + ); } const styles = StyleSheet.create({ + backgroundImage: { + opacity: 0.9, + }, + buildVersion: { + ...defaultTextStyle, + color: '#d6b25e', + fontSize: 12, + letterSpacing: 0, + marginTop: 8, + textAlign: 'center', + textTransform: 'uppercase', + }, container: { backgroundColor: '#111315', flex: 1, @@ -84,6 +115,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingHorizontal: 22, paddingVertical: 18, + zIndex: 1, }, copy: { alignItems: 'flex-start', @@ -91,6 +123,7 @@ const styles = StyleSheet.create({ minWidth: 0, }, description: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 14, lineHeight: 20, @@ -98,6 +131,7 @@ const styles = StyleSheet.create({ textAlign: 'left', }, eyebrow: { + ...defaultTextStyle, color: '#d6b25e', fontSize: 12, fontWeight: '700', @@ -107,13 +141,28 @@ const styles = StyleSheet.create({ }, menu: { gap: 8, - marginLeft: 'auto', width: 156, }, + menuBackgroundImage: { + opacity: 0.96, + }, + menuFrame: { + alignItems: 'center', + justifyContent: 'center', + marginLeft: 'auto', + minHeight: 600, + paddingHorizontal: 58, + paddingVertical: 52, + width: 332, + }, + scrim: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(5, 6, 7, 0.48)', + }, title: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 34, - fontWeight: '800', letterSpacing: 0, lineHeight: 40, marginBottom: 16, diff --git a/src/screens/PageScreen.tsx b/src/screens/PageScreen.tsx index 05e85fe..367da9d 100644 --- a/src/screens/PageScreen.tsx +++ b/src/screens/PageScreen.tsx @@ -7,6 +7,7 @@ import { type RouteName, type ToolkitRouteName, } from '../navigation/routes'; +import { defaultTextStyle } from '../styles/typography'; type PageRouteName = Exclude< RouteName, @@ -46,6 +47,7 @@ const styles = StyleSheet.create({ marginTop: 12, }, description: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 16, lineHeight: 24, diff --git a/src/screens/PoliciesScreen.tsx b/src/screens/PoliciesScreen.tsx index f277103..df3520f 100644 --- a/src/screens/PoliciesScreen.tsx +++ b/src/screens/PoliciesScreen.tsx @@ -8,6 +8,7 @@ import { routeDefinitions, type RouteName, } from '../navigation/routes'; +import { defaultTextStyle } from '../styles/typography'; type PoliciesScreenProps = Readonly<{ goHome: () => void; @@ -39,6 +40,7 @@ export function PoliciesScreen({ goHome, navigate }: PoliciesScreenProps) { const styles = StyleSheet.create({ description: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 16, lineHeight: 24, diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index e66f2af..cded705 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useCallback, useMemo, useState } from 'react'; +import { PanResponder, Pressable, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BackHomeButton } from '../components/BackHomeButton'; @@ -7,21 +7,28 @@ import { formatResolutionLabel, gameResolutionOptions, volumeSettings, + clampVolume, type VolumeSettingKey, } from '../settings/gameSettings'; import { useGameSettingsStore } from '../state'; +import { defaultTextStyle, displayTextStyle } from '../styles/typography'; type SettingsScreenProps = Readonly<{ goHome: () => void; }>; +const sliderTrackWidth = 128; +const sliderThumbSize = 16; + export function SettingsScreen({ goHome }: SettingsScreenProps) { const safeAreaInsets = useSafeAreaInsets(); const [isResolutionSelectOpen, setIsResolutionSelectOpen] = useState(false); + const audioMuted = useGameSettingsStore(state => state.audioMuted); const autosaveEnabled = useGameSettingsStore(state => state.autosaveEnabled); const resolutionId = useGameSettingsStore(state => state.resolutionId); const setResolution = useGameSettingsStore(state => state.setResolution); const setVolume = useGameSettingsStore(state => state.setVolume); + const toggleAudioMuted = useGameSettingsStore(state => state.toggleAudioMuted); const toggleAutosave = useGameSettingsStore(state => state.toggleAutosave); const volumes = useGameSettingsStore(state => state.volumes); @@ -104,7 +111,20 @@ export function SettingsScreen({ goHome }: SettingsScreenProps) { - Audio + + Audio + + + {audioMuted ? '✓' : ''} + + Mute + + {volumeSettings.map(setting => ( { + const sliderLocation = Math.min(sliderTrackWidth, Math.max(0, locationX)); + const nextVolume = clampVolume( + Math.round((sliderLocation / sliderTrackWidth) * 100), + ); + onChange(nextVolume); + }, + [onChange], + ); + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: event => { + updateVolumeFromLocation(event.nativeEvent.locationX); + }, + onPanResponderMove: event => { + updateVolumeFromLocation(event.nativeEvent.locationX); + }, + onStartShouldSetPanResponder: () => true, + }), + [updateVolumeFromLocation], + ); + const fillWidth = `${value}%`; + const thumbOffset = (value / 100) * sliderTrackWidth - sliderThumbSize / 2; + return ( {label} - onChange(value - 5)} - testID={`volume-${settingKey}-decrease`} - /> + + + + + + {value} - onChange(value + 5)} - testID={`volume-${settingKey}-increase`} - /> ); @@ -194,9 +248,14 @@ const styles = StyleSheet.create({ borderColor: '#37342d', borderRadius: 8, borderWidth: 1, - gap: 10, + gap: 8, padding: 12, - width: 268, + width: 286, + }, + audioHeader: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', }, container: { alignItems: 'stretch', @@ -206,6 +265,7 @@ const styles = StyleSheet.create({ gap: 14, }, eyebrow: { + ...defaultTextStyle, color: '#d6b25e', fontSize: 11, fontWeight: '700', @@ -218,6 +278,7 @@ const styles = StyleSheet.create({ width: 190, }, helperText: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 12, lineHeight: 16, @@ -239,17 +300,19 @@ const styles = StyleSheet.create({ flex: 1, }, checkboxHint: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 11, lineHeight: 14, }, checkboxLabel: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 13, - fontWeight: '800', letterSpacing: 0, }, checkboxMark: { + ...defaultTextStyle, color: '#f5d98d', fontSize: 18, fontWeight: '800', @@ -260,6 +323,36 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: 10, }, + muteCheckbox: { + alignItems: 'center', + backgroundColor: '#24272b', + borderColor: '#d6b25e', + borderRadius: 4, + borderWidth: 1, + height: 20, + justifyContent: 'center', + width: 20, + }, + muteCheckboxMark: { + ...defaultTextStyle, + color: '#f5d98d', + fontSize: 14, + fontWeight: '800', + lineHeight: 18, + }, + muteLabel: { + ...defaultTextStyle, + color: '#f6f1e7', + fontSize: 12, + fontWeight: '700', + letterSpacing: 0, + textTransform: 'uppercase', + }, + muteRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 6, + }, resolutionPanel: { backgroundColor: '#191b1f', borderColor: '#37342d', @@ -272,9 +365,9 @@ const styles = StyleSheet.create({ zIndex: 2, }, sectionTitle: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 16, - fontWeight: '800', letterSpacing: 0, }, settingButton: { @@ -294,9 +387,9 @@ const styles = StyleSheet.create({ borderColor: '#d6b25e', }, settingButtonLabel: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 12, - fontWeight: '700', letterSpacing: 0, textAlign: 'center', }, @@ -324,14 +417,15 @@ const styles = StyleSheet.create({ zIndex: 3, }, summary: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 13, lineHeight: 18, }, title: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 30, - fontWeight: '800', letterSpacing: 0, lineHeight: 34, marginBottom: 12, @@ -339,9 +433,10 @@ const styles = StyleSheet.create({ volumeControls: { alignItems: 'center', flexDirection: 'row', - gap: 8, + gap: 10, }, volumeLabel: { + ...defaultTextStyle, color: '#f6f1e7', flex: 1, fontSize: 13, @@ -351,10 +446,38 @@ const styles = StyleSheet.create({ volumeRow: { alignItems: 'center', flexDirection: 'row', - gap: 12, + gap: 10, justifyContent: 'space-between', }, + volumeSlider: { + height: 24, + justifyContent: 'center', + width: sliderTrackWidth, + }, + volumeThumb: { + backgroundColor: '#f5d98d', + borderColor: '#6f5422', + borderRadius: sliderThumbSize / 2, + borderWidth: 1, + height: sliderThumbSize, + position: 'absolute', + width: sliderThumbSize, + }, + volumeTrack: { + backgroundColor: '#24272b', + borderColor: '#4d4432', + borderRadius: 4, + borderWidth: 1, + height: 8, + overflow: 'hidden', + width: sliderTrackWidth, + }, + volumeTrackFill: { + backgroundColor: '#d6b25e', + height: '100%', + }, volumeValue: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 13, fontWeight: '700', diff --git a/src/screens/ToolkitScreen.tsx b/src/screens/ToolkitScreen.tsx index 240a05f..889b13e 100644 --- a/src/screens/ToolkitScreen.tsx +++ b/src/screens/ToolkitScreen.tsx @@ -1,13 +1,21 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { buildVersionLabel } from '../config/buildInfo'; +import type { CreditsContent } from '../data'; import { routeDefinitions, toolkitRoutes, type RouteName, type ToolkitRouteName, } from '../navigation/routes'; -import { useToolkitSocketStore, type ToolkitSocketStatus } from '../state'; +import { + useToolkitContentStore, + useToolkitSocketStore, + type ToolkitContentStatus, + type ToolkitSocketStatus, +} from '../state'; +import { defaultTextStyle, displayTextStyle } from '../styles/typography'; type ToolkitScreenProps = Readonly<{ goHome: () => void; @@ -52,6 +60,21 @@ const toolkitSections: Record = { 'Preview and catalog sound identity tied to throne room, map, petitioner, BLB voice, and comedy contexts.', sources: ['Music', 'Gameplay', 'References'], }, + toolkitCredits: { + futureFields: [ + 'contributor names', + 'roles', + 'asset attribution', + 'font licenses', + 'music credits', + 'special thanks', + ], + liableToChange: + 'Credits will change whenever art, audio, fonts, tooling, collaborators, or external inspirations are added.', + purpose: + 'Track contributor credits, asset attribution, licenses, and acknowledgement copy for the game and toolkit.', + sources: ['Credits', 'References', 'Home'], + }, toolkitEndings: { futureFields: [ 'resource collapse', @@ -242,6 +265,7 @@ const toolkitSections: Record = { const toolkitIcons: Record = { toolkitAudio: '♪', + toolkitCredits: '☆', toolkitEndings: '!', toolkitFactions: '◇', toolkitFlags: '#', @@ -262,6 +286,14 @@ const socketStatusCopy: Record = { disconnected: 'Disconnected', }; +const creditsStatusCopy: Record = { + error: 'Using fallback credits', + idle: 'Bundled fallback credits', + loading: 'Loading credits', + ready: 'Live credits synced', + saving: 'Saving credits', +}; + export function ToolkitScreen({ goHome, navigate, @@ -269,8 +301,13 @@ export function ToolkitScreen({ }: ToolkitScreenProps) { const safeAreaInsets = useSafeAreaInsets(); const activeRoute = routeDefinitions[routeName]; + const credits = useToolkitContentStore(state => state.credits); + const creditsError = useToolkitContentStore(state => state.error); + const creditsRevision = useToolkitContentStore(state => state.revision); + const creditsStatus = useToolkitContentStore(state => state.status); const socketStatus = useToolkitSocketStore(state => state.status); const toolkitContent = getToolkitContent(routeName); + const isCreditsRoute = routeName === 'toolkitCredits'; return ( Bone Lord Bob {activeRoute.title} Read-only planning panel - - - - - - Source wiki pages - - {toolkitContent.sources.map(source => ( - - {source} + ) : ( + <> + + + + + + Source wiki pages + + {toolkitContent.sources.map(source => ( + + {source} + + ))} + + + + + Future authoring area + + Forms, tables, previews, and schema-backed validation will mount + here after the JSON and Markdown content formats stabilize. - ))} - - - - - Future authoring area - - Forms, tables, previews, and schema-backed validation will mount - here after the JSON and Markdown content formats stabilize. - - + + + )} @@ -362,6 +411,93 @@ function getToolkitContent(routeName: RouteName) { return toolkitOverview; } +type ToolkitCreditsCardProps = Readonly<{ + credits: CreditsContent; + error: string | null; + revision: string | null; + socketStatus: ToolkitSocketStatus; + status: ToolkitContentStatus; +}>; + +function ToolkitCreditsCard({ + credits, + error, + revision, + socketStatus, + status, +}: ToolkitCreditsCardProps) { + const syncCopy = + socketStatus === 'connected' + ? creditsStatusCopy[status] + : 'Bundled fallback credits'; + const revisionCopy = revision == null ? 'No live revision' : `Revision ${revision}`; + + return ( + + + + BLB + + + {credits.mainLogo.title} + {credits.mainLogo.subtitle} + + + {buildVersionLabel} + {syncCopy} + {revisionCopy} + + + + {error == null ? null : ( + + {error} + + )} + + + + Contributors + {credits.contributors.map(contributor => ( + + {contributor.name} + {contributor.role} + {contributor.credit == null ? null : ( + {contributor.credit} + )} + + ))} + + + + Technologies + + {credits.technologies.map(technology => ( + + {technology.name} + {technology.role == null ? null : ( + {technology.role} + )} + + ))} + + + + + + Special Thanks + + {credits.specialThanks.map(thanks => ( + + {thanks} + + ))} + + + + ); +} + type ToolkitDetailProps = Readonly<{ label: string; testID: string; @@ -468,12 +604,179 @@ const styles = StyleSheet.create({ padding: 14, position: 'relative', }, + creditsBuildBadge: { + alignItems: 'flex-end', + backgroundColor: '#24272b', + borderColor: '#4d4432', + borderRadius: 6, + borderWidth: 1, + marginLeft: 'auto', + paddingHorizontal: 10, + paddingVertical: 8, + }, + creditsBuildLabel: { + ...displayTextStyle, + color: '#f5d98d', + fontSize: 12, + letterSpacing: 0, + }, + creditsCard: { + backgroundColor: '#131518', + borderColor: '#4d4432', + borderRadius: 8, + borderWidth: 1, + flex: 1, + gap: 10, + padding: 12, + }, + creditsError: { + ...defaultTextStyle, + color: '#d85a4d', + fontSize: 11, + lineHeight: 14, + }, + creditsGrid: { + flex: 1, + flexDirection: 'row', + gap: 10, + }, + creditsHero: { + alignItems: 'center', + flexDirection: 'row', + gap: 12, + }, + creditsHeroCopy: { + flex: 1, + minWidth: 0, + }, + creditsItemCopy: { + ...defaultTextStyle, + color: '#80796c', + fontSize: 11, + lineHeight: 14, + }, + creditsItemMeta: { + ...defaultTextStyle, + color: '#d6b25e', + fontSize: 11, + fontWeight: '700', + letterSpacing: 0, + lineHeight: 14, + textTransform: 'uppercase', + }, + creditsItemTitle: { + ...displayTextStyle, + color: '#f6f1e7', + fontSize: 14, + letterSpacing: 0, + }, + creditsLogoInitials: { + ...displayTextStyle, + color: '#f5d98d', + fontSize: 18, + letterSpacing: 0, + }, + creditsLogoMark: { + alignItems: 'center', + backgroundColor: '#3a2918', + borderColor: '#d6b25e', + borderRadius: 8, + borderWidth: 1, + height: 48, + justifyContent: 'center', + width: 58, + }, + creditsRevisionLabel: { + ...defaultTextStyle, + color: '#80796c', + fontSize: 10, + lineHeight: 13, + }, + creditsSection: { + backgroundColor: '#191b1f', + borderColor: '#2d2f32', + borderRadius: 6, + borderWidth: 1, + flex: 1, + gap: 7, + padding: 10, + }, + creditsSectionTitle: { + ...defaultTextStyle, + color: '#d6b25e', + fontSize: 10, + fontWeight: '800', + letterSpacing: 0, + textTransform: 'uppercase', + }, + creditsSubtitle: { + ...defaultTextStyle, + color: '#c9c1b3', + fontSize: 12, + lineHeight: 16, + }, + creditsSyncLabel: { + ...defaultTextStyle, + color: '#c9c1b3', + fontSize: 10, + lineHeight: 13, + textTransform: 'uppercase', + }, + creditsTechName: { + ...displayTextStyle, + color: '#f6f1e7', + fontSize: 11, + letterSpacing: 0, + }, + creditsTechPill: { + backgroundColor: '#24272b', + borderColor: '#4d4432', + borderRadius: 4, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 5, + }, + creditsTechRole: { + ...defaultTextStyle, + color: '#c9c1b3', + fontSize: 10, + lineHeight: 13, + }, + creditsThanks: { + backgroundColor: '#191b1f', + borderColor: '#2d2f32', + borderRadius: 6, + borderWidth: 1, + gap: 7, + padding: 10, + }, + creditsThanksItem: { + ...defaultTextStyle, + color: '#c9c1b3', + flex: 1, + fontSize: 11, + lineHeight: 14, + minWidth: 150, + }, + creditsThanksRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + creditsTitle: { + ...displayTextStyle, + color: '#f6f1e7', + fontSize: 24, + letterSpacing: 0, + lineHeight: 28, + }, detailGrid: { flexDirection: 'row', gap: 10, marginBottom: 10, }, detailLabel: { + ...defaultTextStyle, color: '#d6b25e', fontSize: 10, fontWeight: '800', @@ -490,11 +793,13 @@ const styles = StyleSheet.create({ padding: 10, }, detailValue: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 12, lineHeight: 17, }, eyebrow: { + ...defaultTextStyle, color: '#d6b25e', fontSize: 11, fontWeight: '700', @@ -503,14 +808,15 @@ const styles = StyleSheet.create({ textTransform: 'uppercase', }, title: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 30, - fontWeight: '800', letterSpacing: 0, lineHeight: 34, marginBottom: 12, }, placeholderCopy: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 12, lineHeight: 17, @@ -525,13 +831,14 @@ const styles = StyleSheet.create({ padding: 10, }, placeholderTitle: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 12, - fontWeight: '800', letterSpacing: 0, marginBottom: 4, }, readOnlyLabel: { + ...defaultTextStyle, color: '#80796c', fontSize: 11, fontWeight: '800', @@ -548,6 +855,7 @@ const styles = StyleSheet.create({ padding: 10, }, sourcePill: { + ...defaultTextStyle, backgroundColor: '#2a261d', borderColor: '#4d4432', borderRadius: 4, @@ -578,6 +886,7 @@ const styles = StyleSheet.create({ right: 12, }, socketLabel: { + ...defaultTextStyle, color: '#c9c1b3', fontSize: 11, fontWeight: '700', @@ -626,9 +935,9 @@ const styles = StyleSheet.create({ paddingHorizontal: 9, }, toolbarButtonLabel: { + ...displayTextStyle, color: '#f6f1e7', fontSize: 10, - fontWeight: '800', letterSpacing: 0, textAlign: 'center', }, diff --git a/src/settings/gameSettings.ts b/src/settings/gameSettings.ts index 2508aca..49c6906 100644 --- a/src/settings/gameSettings.ts +++ b/src/settings/gameSettings.ts @@ -39,19 +39,23 @@ export const defaultAutosaveEnabled = true; export const volumeSettings = [ { key: 'master', - label: 'Master Volume', + label: 'Master', }, { key: 'music', - label: 'Music Volume', + label: 'Music', }, { key: 'ambience', - label: 'Ambience Volume', + label: 'Ambience', }, { key: 'speech', - label: 'Speech Volume', + label: 'Speech', + }, + { + key: 'sfx', + label: 'SFX', }, ] as const; @@ -63,6 +67,7 @@ export const defaultVolumeSettings: VolumeSettings = { ambience: 70, master: 80, music: 70, + sfx: 80, speech: 80, }; diff --git a/src/socket/toolkitSocketClient.ts b/src/socket/toolkitSocketClient.ts new file mode 100644 index 0000000..6697017 --- /dev/null +++ b/src/socket/toolkitSocketClient.ts @@ -0,0 +1,144 @@ +import { io, type Socket } from 'socket.io-client'; + +import { isCreditsContent, type CreditsContent } from '../data'; + +type NodeProcess = { + env?: Partial>; +}; + +export type ToolkitContentResource = 'credits'; + +export type ToolkitContentPayload = Readonly<{ + content: CreditsContent; + resource: ToolkitContentResource; + revision: string; +}>; + +export type ToolkitContentErrorPayload = Readonly<{ + error: string; + resource: ToolkitContentResource; +}>; + +type ToolkitContentSuccessResponse = ToolkitContentPayload & + Readonly<{ + ok: true; + }>; + +type ToolkitContentErrorResponse = ToolkitContentErrorPayload & + Readonly<{ + ok: false; + }>; + +type ToolkitContentResponse = + | ToolkitContentSuccessResponse + | ToolkitContentErrorResponse; + +const defaultToolkitSocketUrl = 'http://127.0.0.1:5174'; +const toolkitSocketTimeoutMs = 5000; + +let toolkitSocket: Socket | null = null; + +function getMaybeProcess() { + return typeof process === 'undefined' ? undefined : (process as NodeProcess); +} + +export function getToolkitSocketUrl() { + return ( + getMaybeProcess()?.env?.TOOLKIT_SOCKET_URL ?? defaultToolkitSocketUrl + ); +} + +export function getNodeEnvironment() { + return getMaybeProcess()?.env?.NODE_ENV; +} + +export function getToolkitSocket() { + if (toolkitSocket == null) { + toolkitSocket = io(getToolkitSocketUrl(), { + autoConnect: false, + reconnection: true, + reconnectionAttempts: Number.POSITIVE_INFINITY, + reconnectionDelay: 750, + transports: ['websocket'], + }); + } + + return toolkitSocket; +} + +function isToolkitContentResource(value: unknown): value is ToolkitContentResource { + return value === 'credits'; +} + +function isToolkitContentPayload(value: unknown): value is ToolkitContentPayload { + if (value == null || typeof value !== 'object') { + return false; + } + + const payload = value as Partial; + + return ( + isToolkitContentResource(payload.resource) && + typeof payload.revision === 'string' && + isCreditsContent(payload.content) + ); +} + +function parseToolkitContentResponse( + response: unknown, +): ToolkitContentPayload { + if (response == null || typeof response !== 'object') { + throw new Error('Toolkit content response was empty.'); + } + + const contentResponse = response as Partial; + + if (contentResponse.ok === false) { + throw new Error(contentResponse.error ?? 'Toolkit content request failed.'); + } + + if (contentResponse.ok === true && isToolkitContentPayload(contentResponse)) { + return contentResponse; + } + + throw new Error('Toolkit content response was invalid.'); +} + +async function emitContentRequest( + eventName: 'toolkit:content:read' | 'toolkit:content:save', + payload: Readonly<{ + content?: CreditsContent; + resource: ToolkitContentResource; + }>, +) { + const socket = getToolkitSocket(); + + if (!socket.connected) { + throw new Error('Toolkit socket is not connected.'); + } + + const response = await socket + .timeout(toolkitSocketTimeoutMs) + .emitWithAck(eventName, payload); + + return parseToolkitContentResponse(response); +} + +export function readToolkitContent(resource: ToolkitContentResource) { + return emitContentRequest('toolkit:content:read', { resource }); +} + +export function saveToolkitContent( + resource: ToolkitContentResource, + content: CreditsContent, +) { + return emitContentRequest('toolkit:content:save', { + content, + resource, + }); +} + +export function resetToolkitSocketForTests() { + toolkitSocket?.disconnect(); + toolkitSocket = null; +} diff --git a/src/state/gameSettingsStore.ts b/src/state/gameSettingsStore.ts index 6577cc3..c84ebda 100644 --- a/src/state/gameSettingsStore.ts +++ b/src/state/gameSettingsStore.ts @@ -13,29 +13,63 @@ import { import { createPersistOptions } from './createPersistOptions'; type PersistedGameSettingsState = { + audioMuted: boolean; autosaveEnabled: boolean; resolutionId: GameResolutionId; volumes: VolumeSettings; }; type GameSettingsState = PersistedGameSettingsState & { + setAudioMuted: (audioMuted: boolean) => void; setAutosaveEnabled: (autosaveEnabled: boolean) => void; setResolution: (resolutionId: GameResolutionId) => void; + toggleAudioMuted: () => void; toggleAutosave: () => void; setVolume: (key: VolumeSettingKey, volume: number) => void; }; +function isPersistedGameSettingsState( + state: unknown, +): state is Partial { + return state != null && typeof state === 'object'; +} + +function mergeVolumeSettings(volumes: unknown): VolumeSettings { + if (volumes == null || typeof volumes !== 'object') { + return defaultVolumeSettings; + } + + const persistedVolumes = volumes as Partial>; + + return { + ambience: clampVolume( + persistedVolumes.ambience ?? defaultVolumeSettings.ambience, + ), + master: clampVolume(persistedVolumes.master ?? defaultVolumeSettings.master), + music: clampVolume(persistedVolumes.music ?? defaultVolumeSettings.music), + sfx: clampVolume(persistedVolumes.sfx ?? defaultVolumeSettings.sfx), + speech: clampVolume(persistedVolumes.speech ?? defaultVolumeSettings.speech), + }; +} + export const useGameSettingsStore = create()( persist( set => ({ + audioMuted: false, autosaveEnabled: defaultAutosaveEnabled, resolutionId: defaultGameResolutionId, + setAudioMuted: (audioMuted: boolean) => { + set({ audioMuted }); + }, setAutosaveEnabled: (autosaveEnabled: boolean) => { set({ autosaveEnabled }); }, setResolution: (resolutionId: GameResolutionId) => { set({ resolutionId }); }, + toggleAudioMuted: () => { + set(state => ({ audioMuted: !state.audioMuted })); + }, toggleAutosave: () => { set(state => ({ autosaveEnabled: !state.autosaveEnabled })); }, @@ -50,8 +84,23 @@ export const useGameSettingsStore = create()( volumes: defaultVolumeSettings, }), createPersistOptions({ + merge: (persistedState, currentState) => { + if (!isPersistedGameSettingsState(persistedState)) { + return currentState; + } + + return { + ...currentState, + audioMuted: persistedState.audioMuted ?? currentState.audioMuted, + autosaveEnabled: + persistedState.autosaveEnabled ?? currentState.autosaveEnabled, + resolutionId: persistedState.resolutionId ?? currentState.resolutionId, + volumes: mergeVolumeSettings(persistedState.volumes), + }; + }, name: 'blb-game-settings', partialize: state => ({ + audioMuted: state.audioMuted, autosaveEnabled: state.autosaveEnabled, resolutionId: state.resolutionId, volumes: state.volumes, diff --git a/src/state/index.ts b/src/state/index.ts index 179770a..9908483 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -5,6 +5,10 @@ export { export { useGameSettingsStore } from './gameSettingsStore'; export { createMemoryStateStorage } from './memoryStateStorage'; export { useNavigationStore } from './navigationStore'; +export { + useToolkitContentStore, + type ToolkitContentStatus, +} from './toolkitContentStore'; export { useToolkitSocketStore, type ToolkitSocketStatus, diff --git a/src/state/toolkitContentStore.ts b/src/state/toolkitContentStore.ts new file mode 100644 index 0000000..bf50fa1 --- /dev/null +++ b/src/state/toolkitContentStore.ts @@ -0,0 +1,89 @@ +import { create } from 'zustand'; + +import { fallbackCreditsContent, type CreditsContent } from '../data'; +import { + readToolkitContent, + saveToolkitContent, +} from '../socket/toolkitSocketClient'; + +export type ToolkitContentStatus = + | 'error' + | 'idle' + | 'loading' + | 'ready' + | 'saving'; + +type ToolkitContentState = { + credits: CreditsContent; + error: string | null; + lastLoadedAt: string | null; + refreshCredits: () => Promise; + revision: string | null; + saveCredits: (credits: CreditsContent) => Promise; + setContentError: (error: string) => void; + setCreditsFromSocket: (credits: CreditsContent, revision: string) => void; + status: ToolkitContentStatus; +}; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : 'Unknown toolkit content error.'; +} + +export const useToolkitContentStore = create()( + (set, get) => ({ + credits: fallbackCreditsContent, + error: null, + lastLoadedAt: null, + refreshCredits: async () => { + set({ + error: null, + status: 'loading', + }); + + try { + const payload = await readToolkitContent('credits'); + + get().setCreditsFromSocket(payload.content, payload.revision); + } catch (error) { + set({ + error: getErrorMessage(error), + status: 'error', + }); + } + }, + revision: null, + saveCredits: async (credits: CreditsContent) => { + set({ + error: null, + status: 'saving', + }); + + try { + const payload = await saveToolkitContent('credits', credits); + + get().setCreditsFromSocket(payload.content, payload.revision); + } catch (error) { + set({ + error: getErrorMessage(error), + status: 'error', + }); + } + }, + setContentError: (error: string) => { + set({ + error, + status: 'error', + }); + }, + setCreditsFromSocket: (credits: CreditsContent, revision: string) => { + set({ + credits, + error: null, + lastLoadedAt: new Date().toISOString(), + revision, + status: 'ready', + }); + }, + status: 'idle', + }), +); diff --git a/src/styles/typography.ts b/src/styles/typography.ts new file mode 100644 index 0000000..c86eb4a --- /dev/null +++ b/src/styles/typography.ts @@ -0,0 +1,13 @@ +export const fontFamilies = { + default: 'SingleDayRegular', + display: 'MiTicaRegular', +} as const; + +export const defaultTextStyle = { + fontFamily: fontFamilies.default, +} as const; + +export const displayTextStyle = { + fontFamily: fontFamilies.display, + fontWeight: '400', +} as const; diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts new file mode 100644 index 0000000..69c942b --- /dev/null +++ b/src/types/assets.d.ts @@ -0,0 +1,11 @@ +declare module '*.png' { + import type { ImageSourcePropType } from 'react-native'; + + const source: ImageSourcePropType; + export default source; +} + +declare module '*.mp3' { + const source: string; + export default source; +} diff --git a/src/web/main.tsx b/src/web/main.tsx index e363f84..83e2e49 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -2,7 +2,7 @@ import { AppRegistry } from 'react-native'; import { name as appName } from '../../app.json'; import App from '../../App'; -import './styles.css'; +import '../assets/scss/index.scss'; const rootTag = globalThis.document.getElementById('root'); diff --git a/src/web/styles.css b/src/web/styles.css deleted file mode 100644 index 5a7c882..0000000 --- a/src/web/styles.css +++ /dev/null @@ -1,16 +0,0 @@ -html, -body, -#root { - height: 100%; - margin: 0; - min-height: 100%; -} - -body { - background: #050607; - overflow: hidden; -} - -#root { - display: flex; -} diff --git a/tsconfig.json b/tsconfig.json index 266ba9c..def577a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@react-native/typescript-config", "compilerOptions": { + "resolveJsonModule": true, "types": ["jest"] }, "include": ["**/*.ts", "**/*.tsx"], diff --git a/vite.config.ts b/vite.config.ts index e153ef7..247e32d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; +import packageJson from './package.json'; + const platformExtensions = [ '.web.tsx', '.web.ts', @@ -16,6 +18,9 @@ const platformExtensions = [ ]; export default defineConfig({ + define: { + 'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version), + }, plugins: [react()], resolve: { alias: [{ find: /^react-native$/, replacement: 'react-native-web' }],