feat(app): add browser toolkit support

This commit is contained in:
2026-06-12 08:19:40 -05:00
parent 255e5a6b24
commit 26bdc8747f
71 changed files with 2746 additions and 314 deletions
+1
View File
@@ -0,0 +1 @@
npm run validate:commit
+125 -51
View File
@@ -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`
+2
View File
@@ -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>
+99 -61
View File
@@ -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.
+24
View File
@@ -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);
});
});
+1
View File
@@ -44,6 +44,7 @@ describe('game settings definitions', () => {
ambience: 70,
master: 80,
music: 70,
sfx: 80,
speech: 80,
});
});
+5
View File
@@ -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);
});
+3 -1
View File
@@ -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');
});
});
+63 -4
View File
@@ -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');
+97
View File
@@ -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',
});
});
});
+112
View File
@@ -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',
});
});
});
+2
View File
@@ -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>
+1 -1
View File
@@ -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
+297 -55
View File
@@ -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"
]
}
}
+426
View File
@@ -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",
+4
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
assets: ['./src/assets/fonts'],
};
+118
View File
@@ -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,
};
+95
View File
@@ -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);
Binary file not shown.
Binary file not shown.

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

+49
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
export { default as throneOfTheBoneKingMusic } from './throne-of-the-bone-king.mp3';
Binary file not shown.
Binary file not shown.
+33
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
$color-page-background: #050607;
+1
View File
@@ -0,0 +1 @@
@use './__globals';
+3 -1
View File
@@ -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',
},
+4 -1
View File
@@ -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',
+3
View File
@@ -0,0 +1,3 @@
export const appVersion = '0.0.1';
export const buildVersionLabel = `Build v${appVersion}`;
+10
View File
@@ -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}`;
+58
View File
@@ -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."
]
}
+82
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
export {
fallbackCreditsContent,
isCreditsContent,
type CreditsContent,
type CreditsContributor,
type CreditsLogo,
type CreditsTechnology,
} from './credits';
View File
+3
View File
@@ -0,0 +1,3 @@
export function useBackgroundMusic() {
return undefined;
}
+79
View File
@@ -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]);
}
+36 -37
View File
@@ -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,
]);
}
+6 -1
View File
@@ -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[];
+55 -6
View File
@@ -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,6 +60,12 @@ export function HomeScreen({ navigate }: HomeScreenProps) {
Choose a path through the kingdom.
</Text>
</View>
<ImageBackground
imageStyle={styles.menuBackgroundImage}
resizeMode="stretch"
source={menuBackgroundImage}
style={styles.menuFrame}
testID="home-menu-background">
<View style={styles.menu}>
{visibleMenuRoutes.map(routeName => (
<MenuButton
@@ -66,12 +81,28 @@ export function HomeScreen({ navigate }: HomeScreenProps) {
/>
))}
</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,
+2
View File
@@ -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,
+2
View File
@@ -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,
+142 -19
View File
@@ -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}>
<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',
+313 -4
View File
@@ -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,6 +349,16 @@ 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>
{isCreditsRoute ? (
<ToolkitCreditsCard
credits={credits}
error={creditsError}
revision={creditsRevision}
socketStatus={socketStatus}
status={creditsStatus}
/>
) : (
<>
<View style={styles.detailGrid}>
<ToolkitDetail
label="Purpose"
@@ -348,6 +395,8 @@ export function ToolkitScreen({
here after the JSON and Markdown content formats stabilize.
</Text>
</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',
},
+9 -4
View File
@@ -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,
};
+144
View File
@@ -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;
}
+49
View File
@@ -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,
+4
View File
@@ -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,
+89
View File
@@ -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',
}),
);
+13
View File
@@ -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;
+11
View File
@@ -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;
}
+1 -1
View File
@@ -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');
-16
View File
@@ -1,16 +0,0 @@
html,
body,
#root {
height: 100%;
margin: 0;
min-height: 100%;
}
body {
background: #050607;
overflow: hidden;
}
#root {
display: flex;
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "@react-native/typescript-config",
"compilerOptions": {
"resolveJsonModule": true,
"types": ["jest"]
},
"include": ["**/*.ts", "**/*.tsx"],
+5
View File
@@ -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' }],