feat(app): add browser toolkit support
@@ -0,0 +1 @@
|
||||
npm run validate:commit
|
||||
@@ -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`
|
||||
|
||||
@@ -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 (
|
||||
<SafeAreaProvider>
|
||||
|
||||
@@ -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 <kbd>R</kbd> key twice or select **"Reload"** from the **Dev Menu**, accessed via <kbd>Ctrl</kbd> + <kbd>M</kbd> (Windows/Linux) or <kbd>Cmd ⌘</kbd> + <kbd>M</kbd> (macOS).
|
||||
- **iOS**: Press <kbd>R</kbd> 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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ describe('game settings definitions', () => {
|
||||
ambience: 70,
|
||||
master: 80,
|
||||
music: 70,
|
||||
sfx: 80,
|
||||
speech: 80,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<ToolkitScreen
|
||||
goHome={jest.fn()}
|
||||
@@ -308,6 +340,33 @@ describe('screen scaffold', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the toolkit credits display card from fallback data', () => {
|
||||
const renderer = render(
|
||||
<ToolkitScreen
|
||||
goHome={jest.fn()}
|
||||
navigate={jest.fn()}
|
||||
routeName="toolkitCredits"
|
||||
/>,
|
||||
);
|
||||
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');
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/images/favicon.svg" />
|
||||
<meta name="theme-color" content="#111418" />
|
||||
<title>Throne of the Bone King</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
"<related __tests__ file>"
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
assets: ['./src/assets/fonts'],
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 5.7 MiB After Width: | Height: | Size: 5.7 MiB |
|
Before Width: | Height: | Size: 6.5 MiB After Width: | Height: | Size: 6.5 MiB |
|
Before Width: | Height: | Size: 7.6 MiB After Width: | Height: | Size: 7.6 MiB |
|
Before Width: | Height: | Size: 6.5 MiB After Width: | Height: | Size: 6.5 MiB |
|
Before Width: | Height: | Size: 6.4 MiB After Width: | Height: | Size: 6.4 MiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Throne of the Bone King favicon">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="8" y1="4" x2="56" y2="60" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#241133" />
|
||||
<stop offset="0.55" stop-color="#111418" />
|
||||
<stop offset="1" stop-color="#35220f" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gold" x1="14" y1="10" x2="50" y2="42" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ffe599" />
|
||||
<stop offset="0.55" stop-color="#d6a437" />
|
||||
<stop offset="1" stop-color="#6e4318" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="64" height="64" rx="13" fill="url(#bg)" />
|
||||
<rect x="4" y="4" width="56" height="56" rx="10" fill="none" stroke="#d6a437" stroke-width="2" />
|
||||
|
||||
<path
|
||||
d="M16 25 21 12l8 11 5-14 6 14 8-11 5 13-5 3H21l-5-3Z"
|
||||
fill="url(#gold)"
|
||||
stroke="#fff0b8"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<circle cx="21" cy="13" r="2.2" fill="#f6d66f" />
|
||||
<circle cx="34" cy="9" r="2.2" fill="#f6d66f" />
|
||||
<circle cx="48" cy="13" r="2.2" fill="#f6d66f" />
|
||||
|
||||
<path
|
||||
d="M20 48 44 31"
|
||||
fill="none"
|
||||
stroke="#f4ead7"
|
||||
stroke-linecap="round"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<circle cx="18" cy="49" r="4" fill="#f4ead7" />
|
||||
<circle cx="46" cy="30" r="4" fill="#f4ead7" />
|
||||
|
||||
<path
|
||||
d="M22 35c0-8 5-13 12-13s12 5 12 13c0 6-3 10-8 12v5H30v-5c-5-2-8-6-8-12Z"
|
||||
fill="#e6ddcb"
|
||||
stroke="#201914"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="30" cy="35" r="3.2" fill="#151515" />
|
||||
<circle cx="38" cy="35" r="3.2" fill="#151515" />
|
||||
<path d="M34 38 31 44h6l-3-6Z" fill="#151515" />
|
||||
<path d="M28 48h12" fill="none" stroke="#151515" stroke-linecap="round" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 7.8 MiB After Width: | Height: | Size: 7.8 MiB |
|
Before Width: | Height: | Size: 6.9 MiB After Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 7.4 MiB After Width: | Height: | Size: 7.4 MiB |
|
Before Width: | Height: | Size: 8.5 MiB After Width: | Height: | Size: 8.5 MiB |
|
Before Width: | Height: | Size: 8.6 MiB After Width: | Height: | Size: 8.6 MiB |
|
Before Width: | Height: | Size: 8.1 MiB After Width: | Height: | Size: 8.1 MiB |
|
Before Width: | Height: | Size: 8.5 MiB After Width: | Height: | Size: 8.5 MiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1 @@
|
||||
export { default as throneOfTheBoneKingMusic } from './throne-of-the-bone-king.mp3';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
$color-page-background: #050607;
|
||||
@@ -0,0 +1 @@
|
||||
@use './__globals';
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const appVersion = '0.0.1';
|
||||
|
||||
export const buildVersionLabel = `Build v${appVersion}`;
|
||||
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
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}`;
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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;
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
fallbackCreditsContent,
|
||||
isCreditsContent,
|
||||
type CreditsContent,
|
||||
type CreditsContributor,
|
||||
type CreditsLogo,
|
||||
type CreditsTechnology,
|
||||
} from './credits';
|
||||
@@ -0,0 +1,3 @@
|
||||
export function useBackgroundMusic() {
|
||||
return undefined;
|
||||
}
|
||||
@@ -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<HTMLAudioElement | null>(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]);
|
||||
}
|
||||
@@ -1,55 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
import { useToolkitSocketStore } from '../state';
|
||||
|
||||
type NodeProcess = {
|
||||
env?: Partial<Record<string, string>>;
|
||||
};
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
<ImageBackground
|
||||
imageStyle={styles.backgroundImage}
|
||||
resizeMode="cover"
|
||||
source={homeBackgroundImage}
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
@@ -42,7 +49,9 @@ export function HomeScreen({ navigate }: HomeScreenProps) {
|
||||
paddingRight: safeAreaInsets.right,
|
||||
paddingTop: safeAreaInsets.top,
|
||||
},
|
||||
]}>
|
||||
]}
|
||||
testID="home-background">
|
||||
<View style={styles.scrim} />
|
||||
<View style={styles.content}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
|
||||
@@ -51,27 +60,49 @@ export function HomeScreen({ navigate }: HomeScreenProps) {
|
||||
Choose a path through the kingdom.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.menu}>
|
||||
{visibleMenuRoutes.map(routeName => (
|
||||
<MenuButton
|
||||
key={routeName}
|
||||
disabled={
|
||||
routeName === 'toolkit' && toolkitSocketStatus !== 'connected'
|
||||
}
|
||||
icon={menuIcons[routeName]}
|
||||
label={routeDefinitions[routeName].label}
|
||||
onPress={() => navigate(routeName)}
|
||||
testID={`menu-${routeName}`}
|
||||
tone={routeName === 'toolkit' ? 'toolkit' : 'default'}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<ImageBackground
|
||||
imageStyle={styles.menuBackgroundImage}
|
||||
resizeMode="stretch"
|
||||
source={menuBackgroundImage}
|
||||
style={styles.menuFrame}
|
||||
testID="home-menu-background">
|
||||
<View style={styles.menu}>
|
||||
{visibleMenuRoutes.map(routeName => (
|
||||
<MenuButton
|
||||
key={routeName}
|
||||
disabled={
|
||||
routeName === 'toolkit' && toolkitSocketStatus !== 'connected'
|
||||
}
|
||||
icon={menuIcons[routeName]}
|
||||
label={routeDefinitions[routeName].label}
|
||||
onPress={() => navigate(routeName)}
|
||||
testID={`menu-${routeName}`}
|
||||
tone={routeName === 'toolkit' ? 'toolkit' : 'default'}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.buildVersion} testID="home-build-version">
|
||||
{buildVersionLabel}
|
||||
</Text>
|
||||
</ImageBackground>
|
||||
</View>
|
||||
</View>
|
||||
</ImageBackground>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
</View>
|
||||
|
||||
<View style={styles.audioPanel}>
|
||||
<Text style={styles.sectionTitle}>Audio</Text>
|
||||
<View style={styles.audioHeader}>
|
||||
<Text style={styles.sectionTitle}>Audio</Text>
|
||||
<View style={styles.muteRow}>
|
||||
<Pressable
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: audioMuted }}
|
||||
onPress={toggleAudioMuted}
|
||||
style={styles.muteCheckbox}
|
||||
testID="audio-mute-checkbox">
|
||||
<Text style={styles.muteCheckboxMark}>{audioMuted ? '✓' : ''}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.muteLabel}>Mute</Text>
|
||||
</View>
|
||||
</View>
|
||||
{volumeSettings.map(setting => (
|
||||
<VolumeControl
|
||||
key={setting.key}
|
||||
@@ -166,23 +186,57 @@ function VolumeControl({
|
||||
settingKey,
|
||||
value,
|
||||
}: VolumeControlProps) {
|
||||
const updateVolumeFromLocation = useCallback(
|
||||
(locationX: number) => {
|
||||
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 (
|
||||
<View style={styles.volumeRow}>
|
||||
<Text style={styles.volumeLabel}>{label}</Text>
|
||||
<View style={styles.volumeControls}>
|
||||
<SettingButton
|
||||
label="-"
|
||||
onPress={() => onChange(value - 5)}
|
||||
testID={`volume-${settingKey}-decrease`}
|
||||
/>
|
||||
<View
|
||||
accessibilityLabel={`${label} ${value}`}
|
||||
accessibilityRole="adjustable"
|
||||
style={styles.volumeSlider}
|
||||
testID={`volume-${settingKey}-slider`}
|
||||
{...panResponder.panHandlers}>
|
||||
<View style={styles.volumeTrack} testID={`volume-${settingKey}-track`}>
|
||||
<View
|
||||
style={[styles.volumeTrackFill, { width: fillWidth }]}
|
||||
testID={`volume-${settingKey}-fill`}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={[styles.volumeThumb, { left: thumbOffset }]}
|
||||
testID={`volume-${settingKey}-thumb`}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.volumeValue} testID={`volume-${settingKey}-value`}>
|
||||
{value}
|
||||
</Text>
|
||||
<SettingButton
|
||||
label="+"
|
||||
onPress={() => onChange(value + 5)}
|
||||
testID={`volume-${settingKey}-increase`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -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',
|
||||
|
||||
@@ -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<ToolkitRouteName, ToolkitSectionContent> = {
|
||||
'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<ToolkitRouteName, ToolkitSectionContent> = {
|
||||
|
||||
const toolkitIcons: Record<ToolkitRouteName, string> = {
|
||||
toolkitAudio: '♪',
|
||||
toolkitCredits: '☆',
|
||||
toolkitEndings: '!',
|
||||
toolkitFactions: '◇',
|
||||
toolkitFlags: '#',
|
||||
@@ -262,6 +286,14 @@ const socketStatusCopy: Record<ToolkitSocketStatus, string> = {
|
||||
disconnected: 'Disconnected',
|
||||
};
|
||||
|
||||
const creditsStatusCopy: Record<ToolkitContentStatus, string> = {
|
||||
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 (
|
||||
<View
|
||||
@@ -312,42 +349,54 @@ export function ToolkitScreen({
|
||||
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
|
||||
<Text style={styles.title}>{activeRoute.title}</Text>
|
||||
<Text style={styles.readOnlyLabel}>Read-only planning panel</Text>
|
||||
<View style={styles.detailGrid}>
|
||||
<ToolkitDetail
|
||||
label="Purpose"
|
||||
testID="toolkit-purpose"
|
||||
value={toolkitContent.purpose}
|
||||
{isCreditsRoute ? (
|
||||
<ToolkitCreditsCard
|
||||
credits={credits}
|
||||
error={creditsError}
|
||||
revision={creditsRevision}
|
||||
socketStatus={socketStatus}
|
||||
status={creditsStatus}
|
||||
/>
|
||||
<ToolkitDetail
|
||||
label="Liable to change because"
|
||||
testID="toolkit-change-note"
|
||||
value={toolkitContent.liableToChange}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.sourcePanel} testID="toolkit-sources">
|
||||
<Text style={styles.detailLabel}>Source wiki pages</Text>
|
||||
<View style={styles.sourceRow}>
|
||||
{toolkitContent.sources.map(source => (
|
||||
<Text key={source} style={styles.sourcePill}>
|
||||
{source}
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.detailGrid}>
|
||||
<ToolkitDetail
|
||||
label="Purpose"
|
||||
testID="toolkit-purpose"
|
||||
value={toolkitContent.purpose}
|
||||
/>
|
||||
<ToolkitDetail
|
||||
label="Liable to change because"
|
||||
testID="toolkit-change-note"
|
||||
value={toolkitContent.liableToChange}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.sourcePanel} testID="toolkit-sources">
|
||||
<Text style={styles.detailLabel}>Source wiki pages</Text>
|
||||
<View style={styles.sourceRow}>
|
||||
{toolkitContent.sources.map(source => (
|
||||
<Text key={source} style={styles.sourcePill}>
|
||||
{source}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<ToolkitDetail
|
||||
label="Anticipated future fields / entities"
|
||||
testID="toolkit-future-fields"
|
||||
value={toolkitContent.futureFields.join(', ')}
|
||||
/>
|
||||
<View
|
||||
style={styles.placeholderPanel}
|
||||
testID="toolkit-future-placeholder">
|
||||
<Text style={styles.placeholderTitle}>Future authoring area</Text>
|
||||
<Text style={styles.placeholderCopy}>
|
||||
Forms, tables, previews, and schema-backed validation will mount
|
||||
here after the JSON and Markdown content formats stabilize.
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<ToolkitDetail
|
||||
label="Anticipated future fields / entities"
|
||||
testID="toolkit-future-fields"
|
||||
value={toolkitContent.futureFields.join(', ')}
|
||||
/>
|
||||
<View
|
||||
style={styles.placeholderPanel}
|
||||
testID="toolkit-future-placeholder">
|
||||
<Text style={styles.placeholderTitle}>Future authoring area</Text>
|
||||
<Text style={styles.placeholderCopy}>
|
||||
Forms, tables, previews, and schema-backed validation will mount
|
||||
here after the JSON and Markdown content formats stabilize.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<ToolkitSocketIndicator status={socketStatus} />
|
||||
</View>
|
||||
</View>
|
||||
@@ -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 (
|
||||
<View style={styles.creditsCard} testID="toolkit-credits-card">
|
||||
<View style={styles.creditsHero}>
|
||||
<View style={styles.creditsLogoMark} testID="toolkit-credits-logo-mark">
|
||||
<Text style={styles.creditsLogoInitials}>BLB</Text>
|
||||
</View>
|
||||
<View style={styles.creditsHeroCopy}>
|
||||
<Text style={styles.creditsTitle}>{credits.mainLogo.title}</Text>
|
||||
<Text style={styles.creditsSubtitle}>{credits.mainLogo.subtitle}</Text>
|
||||
</View>
|
||||
<View style={styles.creditsBuildBadge} testID="toolkit-credits-build">
|
||||
<Text style={styles.creditsBuildLabel}>{buildVersionLabel}</Text>
|
||||
<Text style={styles.creditsSyncLabel}>{syncCopy}</Text>
|
||||
<Text style={styles.creditsRevisionLabel}>{revisionCopy}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{error == null ? null : (
|
||||
<Text style={styles.creditsError} testID="toolkit-credits-error">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.creditsGrid}>
|
||||
<View style={styles.creditsSection} testID="toolkit-credits-contributors">
|
||||
<Text style={styles.creditsSectionTitle}>Contributors</Text>
|
||||
{credits.contributors.map(contributor => (
|
||||
<View key={`${contributor.name}-${contributor.role}`}>
|
||||
<Text style={styles.creditsItemTitle}>{contributor.name}</Text>
|
||||
<Text style={styles.creditsItemMeta}>{contributor.role}</Text>
|
||||
{contributor.credit == null ? null : (
|
||||
<Text style={styles.creditsItemCopy}>{contributor.credit}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.creditsSection} testID="toolkit-credits-technologies">
|
||||
<Text style={styles.creditsSectionTitle}>Technologies</Text>
|
||||
<View style={styles.creditsPillGrid}>
|
||||
{credits.technologies.map(technology => (
|
||||
<View key={technology.name} style={styles.creditsTechPill}>
|
||||
<Text style={styles.creditsTechName}>{technology.name}</Text>
|
||||
{technology.role == null ? null : (
|
||||
<Text style={styles.creditsTechRole}>{technology.role}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.creditsThanks} testID="toolkit-credits-thanks">
|
||||
<Text style={styles.creditsSectionTitle}>Special Thanks</Text>
|
||||
<View style={styles.creditsThanksRow}>
|
||||
{credits.specialThanks.map(thanks => (
|
||||
<Text key={thanks} style={styles.creditsThanksItem}>
|
||||
{thanks}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
import { isCreditsContent, type CreditsContent } from '../data';
|
||||
|
||||
type NodeProcess = {
|
||||
env?: Partial<Record<string, string>>;
|
||||
};
|
||||
|
||||
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<ToolkitContentPayload>;
|
||||
|
||||
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<ToolkitContentResponse>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<PersistedGameSettingsState> {
|
||||
return state != null && typeof state === 'object';
|
||||
}
|
||||
|
||||
function mergeVolumeSettings(volumes: unknown): VolumeSettings {
|
||||
if (volumes == null || typeof volumes !== 'object') {
|
||||
return defaultVolumeSettings;
|
||||
}
|
||||
|
||||
const persistedVolumes = volumes as Partial<Record<VolumeSettingKey, number>>;
|
||||
|
||||
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<GameSettingsState>()(
|
||||
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<GameSettingsState>()(
|
||||
volumes: defaultVolumeSettings,
|
||||
}),
|
||||
createPersistOptions<GameSettingsState, PersistedGameSettingsState>({
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>;
|
||||
revision: string | null;
|
||||
saveCredits: (credits: CreditsContent) => Promise<void>;
|
||||
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<ToolkitContentState>()(
|
||||
(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',
|
||||
}),
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #050607;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "@react-native/typescript-config",
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true,
|
||||
"types": ["jest"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
|
||||
@@ -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' }],
|
||||
|
||||