diff --git a/.env b/.env
new file mode 100644
index 0000000..5c1a29f
--- /dev/null
+++ b/.env
@@ -0,0 +1,2 @@
+VITE_LAST_BUILD_DATE=20260616
+VITE_LAST_SUCCESSFUL_COMMIT=13aa686
diff --git a/.husky/pre-commit b/.husky/pre-commit
index c2cc48f..9da317e 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1 +1,2 @@
+npm run version:bump-build
npm run validate:commit
diff --git a/AGENTS.md b/AGENTS.md
index f71278d..0d34c23 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -61,13 +61,15 @@ __tests__/ # Jest tests
## Current App Behavior
-- Home menu shows New Game, Saves, Settings, Policies, Credits, and Toolkit.
+- Home menu shows New Game, Saves, Settings, Policies, Change Log, 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`.
+- Change Log content is sourced from `src/data/changelog.json`.
- Credits Toolkit data is sourced from `src/data/credits.json` and synced through the socket server.
+- Toolkit placeholder datasets live under `src/data/toolkit/` as empty arrays until schemas stabilize.
## Common Tasks
@@ -96,6 +98,13 @@ __tests__/ # Jest tests
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.
+### Add or change data content
+
+1. Read `src/data/AGENTS.md` first.
+2. Keep app-imported JSON covered by a typed export and runtime validation.
+3. Keep Toolkit placeholder resources as arrays in `src/data/toolkit/` until concrete schemas are requested.
+4. Add tests for new data shape, ordering, or placeholder expectations.
+
### Work with assets
- Images: `src/assets/images/`
@@ -136,7 +145,7 @@ npm run start
- 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.
+Husky runs `npm run version:bump-build` and then `npm run validate:commit` on pre-commit. The version bump updates package metadata and public `.env` build fields for `version-yyyymmdd-last7commit` display.
## Commit Message Format
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..57ec14e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Change Log
+
+All notable project deliverables and build bumps are tracked here.
+
+## 0.0.1
+
+- Established the browser and Electron game shell.
+- Added persisted settings, route state, Toolkit gating, and socket status.
+- Added Toolkit planning surfaces and socket-backed Credits data.
+- Added the first-pass gameplay phase scaffold with local saves.
+
diff --git a/README.md b/README.md
index 11661e3..5af1318 100644
--- a/README.md
+++ b/README.md
@@ -70,14 +70,14 @@ npm run web:build
npm run electron:build
```
-Husky runs `npm run validate:commit` on commit. That command runs lint, Jest, and the Electron build path.
+Husky increments the patch build version with `npm run version:bump-build`, writes public build metadata to `.env`, then runs `npm run validate:commit` on commit. Validation runs lint, Jest, and the Electron build path. The menu build label uses `version-yyyymmdd-last7commit`.
## App Structure
```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/screens/ # Home, Gameplay, Saves, 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
diff --git a/__tests__/changelogData.test.ts b/__tests__/changelogData.test.ts
new file mode 100644
index 0000000..894360b
--- /dev/null
+++ b/__tests__/changelogData.test.ts
@@ -0,0 +1,59 @@
+import {
+ changelogEntries,
+ getChangelogEntriesNewestFirst,
+ isChangelogEntries,
+} from '../src/data';
+
+describe('changelog data', () => {
+ it('provides valid changelog entries', () => {
+ expect(isChangelogEntries(changelogEntries)).toBe(true);
+ expect(changelogEntries.length).toBeGreaterThan(0);
+ expect(changelogEntries[0]).toEqual(
+ expect.objectContaining({
+ id: 'build-metadata-changelog-poc',
+ title: 'Build Metadata and Changelog Scaffold',
+ }),
+ );
+ });
+
+ it('sorts changelog entries newest first', () => {
+ const sortedEntries = getChangelogEntriesNewestFirst([
+ {
+ changes: ['Older change'],
+ createdAt: '2026-06-01',
+ id: 'older-entry',
+ summary: 'Older summary',
+ title: 'Older Entry',
+ version: '0.0.1',
+ },
+ {
+ changes: ['Newer change'],
+ createdAt: '2026-06-16',
+ id: 'newer-entry',
+ summary: 'Newer summary',
+ title: 'Newer Entry',
+ version: '0.0.2',
+ },
+ ]);
+
+ expect(sortedEntries.map(entry => entry.id)).toEqual([
+ 'newer-entry',
+ 'older-entry',
+ ]);
+ });
+
+ it('rejects malformed changelog data', () => {
+ expect(
+ isChangelogEntries([
+ {
+ changes: 'not-an-array',
+ createdAt: '2026-06-16',
+ id: 'bad',
+ summary: 'Bad entry',
+ title: 'Bad',
+ version: '0.0.1',
+ },
+ ]),
+ ).toBe(false);
+ });
+});
diff --git a/__tests__/navigationRoutes.test.ts b/__tests__/navigationRoutes.test.ts
index e1baf19..322d5bc 100644
--- a/__tests__/navigationRoutes.test.ts
+++ b/__tests__/navigationRoutes.test.ts
@@ -14,6 +14,7 @@ describe('routeDefinitions', () => {
'newGame',
'saves',
'settings',
+ 'changelog',
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
@@ -49,6 +50,7 @@ describe('routeDefinitions', () => {
'saves',
'settings',
'policies',
+ 'changelog',
'credits',
'toolkit',
]);
diff --git a/__tests__/screenScaffold.test.tsx b/__tests__/screenScaffold.test.tsx
index 628b4ef..b76de49 100644
--- a/__tests__/screenScaffold.test.tsx
+++ b/__tests__/screenScaffold.test.tsx
@@ -3,6 +3,7 @@ import { ScrollView, Text } from 'react-native';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { AppNavigator } from '../src/navigation/AppNavigator';
+import { buildVersionLabel } from '../src/config/buildInfo';
import {
policyRoutes,
primaryMenuRoutes,
@@ -10,6 +11,7 @@ import {
toolkitRoutes,
} from '../src/navigation/routes';
import { defaultVolumeSettings } from '../src/settings/gameSettings';
+import { ChangelogScreen } from '../src/screens/ChangelogScreen';
import { GameplayScreen } from '../src/screens/GameplayScreen';
import { HomeScreen } from '../src/screens/HomeScreen';
import { PageScreen } from '../src/screens/PageScreen';
@@ -168,8 +170,9 @@ describe('screen scaffold', () => {
expect(text).toContain('Saves');
expect(text).toContain('Settings');
expect(text).toContain('Policies');
+ expect(text).toContain('Change Log');
expect(text).toContain('Credits');
- expect(text).toContain('Build v0.0.1');
+ expect(text).toContain(buildVersionLabel);
expect(text).not.toContain('Toolkit');
expect(renderer.root.findByProps({ testID: 'home-build-version' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'home-background' })).toBeTruthy();
@@ -178,6 +181,7 @@ describe('screen scaffold', () => {
expect(renderer.root.findByProps({ testID: 'menu-saves-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-settings-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-policies-icon' })).toBeTruthy();
+ expect(renderer.root.findByProps({ testID: 'menu-changelog-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-credits-icon' })).toBeTruthy();
expect(renderer.root.findAllByProps({ testID: 'menu-toolkit' })).toHaveLength(0);
});
@@ -258,6 +262,17 @@ describe('screen scaffold', () => {
expect(renderer.root.findByProps({ testID: 'back-home-button' })).toBeTruthy();
});
+ it('renders changelog entries newest first', () => {
+ const renderer = render();
+ const text = getRenderedText(renderer);
+
+ expect(text).toContain('Build Metadata and Changelog Scaffold');
+ expect(text).toContain('0.0.1-20260616-13aa686');
+ expect(text).toContain('Seeded data-backed changelog content');
+ expect(renderer.root.findByProps({ testID: 'changelog-screen' })).toBeTruthy();
+ expect(renderer.root.findByProps({ testID: 'back-home-button' })).toBeTruthy();
+ });
+
it('renders gameplay phases and moves through the turn scaffold', () => {
act(() => {
useGameplayStore.getState().startNewGame();
@@ -501,7 +516,7 @@ describe('screen scaffold', () => {
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(buildVersionLabel);
expect(text).toContain('Bundled fallback credits');
expect(renderer.root.findByProps({ testID: 'toolkit-credits-card' })).toBeTruthy();
expect(
@@ -619,6 +634,7 @@ describe('screen scaffold', () => {
it('does not use scroll containers for fixed landscape screens', () => {
const screens = [
,
+ ,
,
,
,
diff --git a/__tests__/toolkitDataPlaceholders.test.js b/__tests__/toolkitDataPlaceholders.test.js
new file mode 100644
index 0000000..8cd3998
--- /dev/null
+++ b/__tests__/toolkitDataPlaceholders.test.js
@@ -0,0 +1,36 @@
+const { readFileSync } = require('node:fs');
+const path = require('node:path');
+
+const toolkitPlaceholderFiles = [
+ 'audio.json',
+ 'credits.json',
+ 'endings.json',
+ 'factions.json',
+ 'flags.json',
+ 'lore.json',
+ 'map.json',
+ 'opening.json',
+ 'petitioners.json',
+ 'references.json',
+ 'resources.json',
+ 'rulings.json',
+ 'seed.json',
+ 'story.json',
+];
+
+describe('toolkit data placeholders', () => {
+ it('provides an empty array JSON file for every toolkit section', () => {
+ for (const fileName of toolkitPlaceholderFiles) {
+ const filePath = path.join(
+ process.cwd(),
+ 'src',
+ 'data',
+ 'toolkit',
+ fileName,
+ );
+ const content = JSON.parse(readFileSync(filePath, 'utf8'));
+
+ expect(content).toEqual([]);
+ }
+ });
+});
diff --git a/llm.txt b/llm.txt
index d5819f5..6982944 100644
--- a/llm.txt
+++ b/llm.txt
@@ -4,7 +4,7 @@
**Name:** Bone Lord Bob — Throne of the Bone King
**Repo:** `jacob-mathison/blb-throne-of-the-bone-king` (Gitea: `http://100.79.253.19:3000/jacob-mathison/blb-throne-of-the-bone-king`)
**Type:** Cross-platform game shell — React Native UI shared across mobile, web, and desktop
-**Status:** Early development / scaffolding (menu shell, settings, navigation)
+**Status:** Early development / scaffolding (menu shell, settings, navigation, gameplay phase flow, saves, toolkit data placeholders)
**Machine manifest:** `manifest.llm.json` — targets, task routes, commands, architecture index, known gaps
## Part of
@@ -27,7 +27,7 @@ Primary day-to-day development currently runs through **web** (`npm run web`) an
- **Zustand** ^5.0.14 — app state + persistence
- **Vite** ^8 — web/Electron renderer bundler
- **Electron** ^42 — desktop shell
-- **socket.io** / **socket.io-client** ^4.8 — declared; `socket/` not yet implemented
+- **socket.io** / **socket.io-client** ^4.8 — Toolkit socket server and client sync
- **Jest** — unit/integration tests
- **ESLint + Prettier** — lint/format
- **Node** >= 22.12.0
@@ -47,13 +47,21 @@ App.tsx
**Navigation** is store-driven, not React Navigation:
- Route catalog: `src/navigation/routes.ts` (`RouteName`, menu groupings)
- Navigator: `src/navigation/AppNavigator.tsx` — reads/writes `useNavigationStore`, animates opacity on route change
-- Screens: `src/screens/` (`HomeScreen`, `SettingsScreen`, `PoliciesScreen`, `ToolkitScreen`, `PageScreen`)
+- Screens: `src/screens/` (`HomeScreen`, `GameplayScreen`, `SavesScreen`, `ChangelogScreen`, `SettingsScreen`, `PoliciesScreen`, `ToolkitScreen`, `PageScreen`)
**State** lives in Zustand stores under `src/state/`:
- `navigationStore` — current/pending route (persisted as `blb-navigation`)
- `gameSettingsStore` — resolution, volumes, autosave (persisted as `blb-game-settings`)
+- `gameplayStore` — active run, day counter, and current gameplay phase (persisted as `blb-gameplay`)
+- `saveGameStore` — local save slots for gameplay runs (persisted as `blb-save-games`)
- `createPersistOptions` — shared persist middleware wiring
+**Data content** lives under `src/data/`:
+- `changelog.json` + `changelog.ts` — app Change Log entries, sorted newest-first in the UI
+- `credits.json` + `credits.ts` — Toolkit Credits fallback/source content
+- `legal.json` — policy content
+- `toolkit/*.json` — empty array placeholders for future Toolkit resources until schemas stabilize
+
**Settings** constants/helpers: `src/settings/gameSettings.ts`
Resolution presets include phone sizes plus a `browser` (unbounded) mode. Toolkit menu items are only shown when resolution is `browser`.
@@ -97,6 +105,7 @@ blb-throne-of-the-bone-king/
│ └── preload.ts # Preload bridge
├── src/
│ ├── components/ # Reusable UI
+│ ├── data/ # Editable JSON content + typed exports
│ ├── navigation/ # Routes + AppNavigator
│ ├── screens/ # Screen-level views
│ ├── settings/ # Game settings constants/helpers
@@ -105,7 +114,7 @@ blb-throne-of-the-bone-king/
├── __tests__/ # Jest test suite
├── socket/ # Planned real-time layer (stub)
├── docs/ # Project docs (stub)
-├── wiki/ # Wiki content (stub)
+├── wiki/ # Wiki/reference images and loose design assets
├── vite.config.ts # Vite + react-native-web aliases
├── tsconfig.electron.json # Electron main-process TS config
├── metro.config.js # Metro bundler (mobile)
@@ -163,9 +172,9 @@ npm run test # Jest
## Known Gaps
Canonical list lives in `manifest.llm.json` → `knownGaps`. Current highlights:
-- `socket/` is a stub (socket.io deps declared, not wired)
-- `docs/` and `wiki/` are placeholder stubs
-- `AGENTS.md` and `README.md` are stale relative to web/Electron targets
+- `docs/` is still a placeholder stub
+- `wiki/` currently stores reference images and loose wiki/design assets
+- Toolkit placeholder JSON files are empty arrays until schemas are requested
## Notes for LLMs
- **Read `manifest.llm.json` for task routing** — use `taskRoutes` to pick the narrowest file set
@@ -175,6 +184,6 @@ Canonical list lives in `manifest.llm.json` → `knownGaps`. Current highlights:
- **Metro** bundles mobile; **Vite** bundles web/Electron renderer — not interchangeable
- **Navigation is Zustand-based** — do not add React Navigation unless explicitly requested
- **Persistence is platform-split** — extend `persistStorage.ts` / `persistStorage.web.ts`, not stores directly
-- `socket/` is a stub — socket.io is installed but no server/client wiring yet
+- `src/data/AGENTS.md` contains extra rules for JSON content and Toolkit placeholders
- iOS Pods and `node_modules/` are not committed; run `pod install` after pulling native dep changes
- `dist/` build artifacts are gitignored
diff --git a/manifest.llm.json b/manifest.llm.json
index 29aff09..9d918bf 100644
--- a/manifest.llm.json
+++ b/manifest.llm.json
@@ -13,7 +13,7 @@
},
"universe": "Bone Lord Bob (BLB) - Mathison Projects Inc.",
"status": "early-development",
- "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."
+ "summary": "Menu shell with persisted route navigation, settings, policies, changelog, credits, gameplay phase scaffolding, local saves, build metadata display, 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.",
@@ -84,6 +84,11 @@
"path": "socket/README.md",
"type": "toolkit-socket-notes",
"purpose": "Socket folder notes."
+ },
+ {
+ "path": "src/data/AGENTS.md",
+ "type": "data-instructions",
+ "purpose": "Rules for editable JSON content and Toolkit placeholder resources."
}
],
"targets": [
@@ -149,7 +154,7 @@
"Sass ^1.101.0",
"Jest",
"ESLint + Prettier",
- "Husky pre-commit validation",
+ "Husky pre-commit build version bump and validation",
"Node >= 22.12.0"
],
"scripts": {
@@ -166,7 +171,8 @@
"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"
+ "validate:commit": "npm run lint && npm run test -- --runInBand && npm run electron:build",
+ "version:bump-build": "node tools/bump-build-version.mjs"
},
"architecture": {
"rootComponent": "App.tsx",
@@ -195,6 +201,16 @@
"persistKey": "blb-game-settings",
"purpose": "Resolution, autosave, mute, and audio volume settings."
},
+ {
+ "file": "src/state/gameplayStore.ts",
+ "persistKey": "blb-gameplay",
+ "purpose": "Active gameplay run, day counter, phase state, and phase navigation."
+ },
+ {
+ "file": "src/state/saveGameStore.ts",
+ "persistKey": "blb-save-games",
+ "purpose": "Local save slots for gameplay runs."
+ },
{
"file": "src/state/toolkitSocketStore.ts",
"purpose": "Toolkit socket connection status."
@@ -229,6 +245,9 @@
},
"screens": [
"src/screens/HomeScreen.tsx",
+ "src/screens/GameplayScreen.tsx",
+ "src/screens/SavesScreen.tsx",
+ "src/screens/ChangelogScreen.tsx",
"src/screens/SettingsScreen.tsx",
"src/screens/PoliciesScreen.tsx",
"src/screens/ToolkitScreen.tsx",
@@ -248,12 +267,37 @@
"favicon": "src/assets/images/favicon.svg"
},
"data": {
+ "changelog": {
+ "json": "src/data/changelog.json",
+ "typedExport": "src/data/changelog.ts",
+ "sortOrder": "newest-created-first"
+ },
"credits": {
"json": "src/data/credits.json",
"typedExport": "src/data/credits.ts"
},
"legal": {
"json": "src/data/legal.json"
+ },
+ "toolkitPlaceholders": {
+ "directory": "src/data/toolkit/",
+ "shape": "empty arrays until schemas stabilize",
+ "files": [
+ "audio.json",
+ "credits.json",
+ "endings.json",
+ "factions.json",
+ "flags.json",
+ "lore.json",
+ "map.json",
+ "opening.json",
+ "petitioners.json",
+ "references.json",
+ "resources.json",
+ "rulings.json",
+ "seed.json",
+ "story.json"
+ ]
}
},
"audio": {
@@ -399,6 +443,7 @@
"read": [
"llm.txt",
"manifest.llm.json",
+ "src/data/AGENTS.md",
"src/data/",
"socket/contentStore.cjs"
],
@@ -555,12 +600,17 @@
{
"command": "npm run validate:commit",
"purpose": "Pre-commit safety check: lint, Jest, and Electron build."
+ },
+ {
+ "command": "npm run version:bump-build",
+ "purpose": "Increment the package patch version, update public .env build metadata, and stage package/env metadata before a commit."
}
],
"knownGaps": [
- "docs/ and wiki/ are placeholder README stubs.",
+ "docs/ is still a placeholder README stub.",
"No JSON Schema validator exists yet for manifest.llm.json.",
"Toolkit content panels are read-only scaffolds except for socket/store save plumbing.",
+ "Toolkit placeholder JSON files are intentionally empty arrays until content schemas are requested.",
"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."
],
diff --git a/package-lock.json b/package-lock.json
index 86419e5..5009666 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "BlbThroneOfTheBoneKing",
- "version": "0.0.1",
+ "version": "0.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "BlbThroneOfTheBoneKing",
- "version": "0.0.1",
+ "version": "0.0.2",
"dependencies": {
"@react-native-async-storage/async-storage": "^3.1.1",
"@react-native/new-app-screen": "0.85.3",
diff --git a/package.json b/package.json
index 19f0952..e397b7d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "BlbThroneOfTheBoneKing",
- "version": "0.0.1",
+ "version": "0.0.2",
"private": true,
"main": "dist/electron/main.js",
"scripts": {
@@ -19,6 +19,7 @@
"start": "react-native start",
"test": "jest",
"validate:commit": "npm run lint && npm run test -- --runInBand && npm run electron:build",
+ "version:bump-build": "node tools/bump-build-version.mjs",
"web": "vite --host 127.0.0.1",
"web:build": "vite build",
"web:preview": "vite preview --host 127.0.0.1"
diff --git a/src/components/MenuButton.tsx b/src/components/MenuButton.tsx
index 14cb652..db1b86c 100644
--- a/src/components/MenuButton.tsx
+++ b/src/components/MenuButton.tsx
@@ -71,10 +71,10 @@ const styles = StyleSheet.create({
borderColor: '#4d4432',
borderRadius: 8,
borderWidth: 1,
- minHeight: 42,
+ minHeight: 38,
justifyContent: 'center',
- paddingHorizontal: 14,
- paddingVertical: 9,
+ paddingHorizontal: 12,
+ paddingVertical: 7,
width: '100%',
},
buttonPressed: {
diff --git a/src/config/buildInfo.ts b/src/config/buildInfo.ts
index 3b1f868..3ac31c9 100644
--- a/src/config/buildInfo.ts
+++ b/src/config/buildInfo.ts
@@ -1,3 +1,7 @@
-export const appVersion = '0.0.1';
+import packageJson from '../../package.json';
-export const buildVersionLabel = `Build v${appVersion}`;
+export const appVersion = packageJson.version;
+
+export const buildIdentifier = appVersion;
+
+export const buildVersionLabel = `Build ${buildIdentifier}`;
diff --git a/src/config/buildInfo.web.ts b/src/config/buildInfo.web.ts
index e816cce..6065cd2 100644
--- a/src/config/buildInfo.web.ts
+++ b/src/config/buildInfo.web.ts
@@ -1,10 +1,30 @@
///
+import packageJson from '../../package.json';
+
const viteAppVersion = import.meta.env.VITE_APP_VERSION;
+const viteLastBuildDate = import.meta.env.VITE_LAST_BUILD_DATE;
+const viteLastSuccessfulCommit = import.meta.env.VITE_LAST_SUCCESSFUL_COMMIT;
export const appVersion =
typeof viteAppVersion === 'string' && viteAppVersion.length > 0
? viteAppVersion
- : '0.0.1';
+ : packageJson.version;
-export const buildVersionLabel = `Build v${appVersion}`;
+export const buildDate =
+ typeof viteLastBuildDate === 'string' && viteLastBuildDate.length > 0
+ ? viteLastBuildDate
+ : null;
+
+export const lastSuccessfulCommit =
+ typeof viteLastSuccessfulCommit === 'string' &&
+ viteLastSuccessfulCommit.length > 0
+ ? viteLastSuccessfulCommit
+ : null;
+
+export const buildIdentifier =
+ buildDate == null || lastSuccessfulCommit == null
+ ? appVersion
+ : `${appVersion}-${buildDate}-${lastSuccessfulCommit}`;
+
+export const buildVersionLabel = `Build ${buildIdentifier}`;
diff --git a/src/data/AGENTS.md b/src/data/AGENTS.md
new file mode 100644
index 0000000..55524cf
--- /dev/null
+++ b/src/data/AGENTS.md
@@ -0,0 +1,19 @@
+# AGENTS.md - Data Folder
+
+This folder contains source-of-truth JSON content used by the app and Toolkit.
+
+## Rules
+
+- Keep JSON valid, stable, and reviewable.
+- Add TypeScript exports and runtime validation when app code imports JSON directly.
+- Keep Toolkit placeholder files in `toolkit/` as arrays until schemas stabilize.
+- Do not put secrets, credentials, local machine paths, or private notes in this folder.
+- Prefer additive schema changes. If a shape changes, update tests and any socket/content-store validation.
+
+## Current Content
+
+- `changelog.json`: app-facing changelog entries, newest records shown first in the Change Log screen.
+- `credits.json`: Toolkit Credits source content.
+- `legal.json`: policy/legal content.
+- `toolkit/*.json`: empty array placeholders for future Toolkit authoring resources.
+
diff --git a/src/data/changelog.json b/src/data/changelog.json
new file mode 100644
index 0000000..e2c59f8
--- /dev/null
+++ b/src/data/changelog.json
@@ -0,0 +1,14 @@
+[
+ {
+ "id": "build-metadata-changelog-poc",
+ "version": "0.0.1-20260616-13aa686",
+ "createdAt": "2026-06-16",
+ "title": "Build Metadata and Changelog Scaffold",
+ "summary": "Proof-of-concept changelog record for tracking deliverables alongside the visible build identifier.",
+ "changes": [
+ "Added pre-commit build metadata for version, build date, and last successful commit.",
+ "Added a Change Log route to the main menu.",
+ "Seeded data-backed changelog content for future release notes."
+ ]
+ }
+]
diff --git a/src/data/changelog.ts b/src/data/changelog.ts
new file mode 100644
index 0000000..4e17063
--- /dev/null
+++ b/src/data/changelog.ts
@@ -0,0 +1,45 @@
+import changelogJson from './changelog.json';
+
+export type ChangelogEntry = Readonly<{
+ changes: string[];
+ createdAt: string;
+ id: string;
+ summary: string;
+ title: string;
+ version: string;
+}>;
+
+function isRecord(value: unknown): value is Record {
+ return value != null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function isChangelogEntry(value: unknown): value is ChangelogEntry {
+ return (
+ isRecord(value) &&
+ typeof value.id === 'string' &&
+ typeof value.version === 'string' &&
+ typeof value.createdAt === 'string' &&
+ typeof value.title === 'string' &&
+ typeof value.summary === 'string' &&
+ Array.isArray(value.changes) &&
+ value.changes.every(change => typeof change === 'string')
+ );
+}
+
+export function isChangelogEntries(value: unknown): value is ChangelogEntry[] {
+ return Array.isArray(value) && value.every(isChangelogEntry);
+}
+
+if (!isChangelogEntries(changelogJson)) {
+ throw new Error('src/data/changelog.json does not match ChangelogEntry[].');
+}
+
+export const changelogEntries: ChangelogEntry[] = changelogJson;
+
+export function getChangelogEntriesNewestFirst(
+ entries: readonly ChangelogEntry[] = changelogEntries,
+) {
+ return [...entries].sort((firstEntry, secondEntry) =>
+ secondEntry.createdAt.localeCompare(firstEntry.createdAt),
+ );
+}
diff --git a/src/data/index.ts b/src/data/index.ts
index 9e9b1c9..982b1c3 100644
--- a/src/data/index.ts
+++ b/src/data/index.ts
@@ -1,3 +1,9 @@
+export {
+ changelogEntries,
+ getChangelogEntriesNewestFirst,
+ isChangelogEntries,
+ type ChangelogEntry,
+} from './changelog';
export {
fallbackCreditsContent,
isCreditsContent,
diff --git a/src/data/toolkit/audio.json b/src/data/toolkit/audio.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/audio.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/credits.json b/src/data/toolkit/credits.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/credits.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/endings.json b/src/data/toolkit/endings.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/endings.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/factions.json b/src/data/toolkit/factions.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/factions.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/flags.json b/src/data/toolkit/flags.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/flags.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/lore.json b/src/data/toolkit/lore.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/lore.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/map.json b/src/data/toolkit/map.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/map.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/opening.json b/src/data/toolkit/opening.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/opening.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/petitioners.json b/src/data/toolkit/petitioners.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/petitioners.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/references.json b/src/data/toolkit/references.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/references.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/resources.json b/src/data/toolkit/resources.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/resources.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/rulings.json b/src/data/toolkit/rulings.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/rulings.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/seed.json b/src/data/toolkit/seed.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/seed.json
@@ -0,0 +1 @@
+[]
diff --git a/src/data/toolkit/story.json b/src/data/toolkit/story.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/data/toolkit/story.json
@@ -0,0 +1 @@
+[]
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index c59541c..1748852 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
import { Animated, StyleSheet } from 'react-native';
import { useToolkitSocketConnection } from '../hooks/useToolkitSocketConnection';
+import { ChangelogScreen } from '../screens/ChangelogScreen';
import { GameplayScreen } from '../screens/GameplayScreen';
import { HomeScreen } from '../screens/HomeScreen';
import { PageScreen } from '../screens/PageScreen';
@@ -112,6 +113,9 @@ function renderRoute(
if (routeName === 'home') {
return ;
}
+ if (routeName === 'changelog') {
+ return ;
+ }
if (routeName === 'gameplay') {
return ;
}
diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts
index 4855d67..2712c1d 100644
--- a/src/navigation/routes.ts
+++ b/src/navigation/routes.ts
@@ -19,6 +19,10 @@ export const routeDefinitions = {
label: 'Policies',
title: 'Policies',
},
+ changelog: {
+ label: 'Change Log',
+ title: 'Change Log',
+ },
policiesLegal: {
label: 'Legal',
title: 'Legal',
@@ -112,6 +116,7 @@ export const primaryMenuRoutes = [
'saves',
'settings',
'policies',
+ 'changelog',
'credits',
'toolkit',
] as const satisfies readonly RouteName[];
@@ -167,6 +172,7 @@ export const requiredPageRoutes = [
'newGame',
'saves',
'settings',
+ 'changelog',
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
diff --git a/src/screens/ChangelogScreen.tsx b/src/screens/ChangelogScreen.tsx
new file mode 100644
index 0000000..d8a7e12
--- /dev/null
+++ b/src/screens/ChangelogScreen.tsx
@@ -0,0 +1,344 @@
+import { StyleSheet, Text, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { MenuButton } from '../components/MenuButton';
+import {
+ getChangelogEntriesNewestFirst,
+ type ChangelogEntry,
+} from '../data';
+import { defaultTextStyle, displayTextStyle } from '../styles/typography';
+
+type ChangelogScreenProps = Readonly<{
+ goHome: () => void;
+}>;
+
+const visibleChangeLimit = 4;
+
+export function ChangelogScreen({ goHome }: ChangelogScreenProps) {
+ const safeAreaInsets = useSafeAreaInsets();
+ const entries = getChangelogEntriesNewestFirst();
+ const latestEntry = entries[0];
+
+ return (
+
+
+ Bone Lord Bob
+ Change Log
+
+ Newest release notes are pinned at the top, with older deliverables
+ settling toward the bottom.
+
+ {latestEntry == null ? null : (
+
+ Latest Build
+ {latestEntry.version}
+ {latestEntry.createdAt}
+
+ )}
+
+
+
+
+
+
+
+ Release Ledger
+ Deliverable History
+
+
+ {entries.length} {entries.length === 1 ? 'record' : 'records'}
+
+
+
+ {entries.map((entry, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+type ChangelogCardProps = Readonly<{
+ entry: ChangelogEntry;
+ index: number;
+ latest: boolean;
+}>;
+
+function ChangelogCard({ entry, index, latest }: ChangelogCardProps) {
+ return (
+
+
+
+ {index + 1}
+
+
+
+
+
+
+ {entry.version}
+
+ {entry.createdAt}
+
+ {entry.title}
+ {entry.summary}
+
+ {entry.changes.slice(0, visibleChangeLimit).map(change => (
+
+ ◆
+ {change}
+
+ ))}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ boardHeader: {
+ alignItems: 'center',
+ borderBottomColor: '#37342d',
+ borderBottomWidth: 1,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingBottom: 10,
+ },
+ boardKicker: {
+ ...defaultTextStyle,
+ color: '#d6b25e',
+ fontSize: 10,
+ fontWeight: '800',
+ letterSpacing: 0,
+ textTransform: 'uppercase',
+ },
+ boardTitle: {
+ ...displayTextStyle,
+ color: '#f6f1e7',
+ fontSize: 18,
+ letterSpacing: 0,
+ lineHeight: 22,
+ },
+ changeBullet: {
+ color: '#d6b25e',
+ fontSize: 9,
+ lineHeight: 15,
+ },
+ changeItem: {
+ ...defaultTextStyle,
+ color: '#c9c1b3',
+ flex: 1,
+ fontSize: 11,
+ lineHeight: 15,
+ },
+ changeList: {
+ gap: 5,
+ },
+ changeRow: {
+ flexDirection: 'row',
+ gap: 7,
+ },
+ container: {
+ backgroundColor: '#111315',
+ flex: 1,
+ flexDirection: 'row',
+ gap: 18,
+ overflow: 'hidden',
+ },
+ date: {
+ ...defaultTextStyle,
+ color: '#80796c',
+ fontSize: 11,
+ fontWeight: '800',
+ letterSpacing: 0,
+ },
+ description: {
+ ...defaultTextStyle,
+ color: '#c9c1b3',
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ entryCard: {
+ backgroundColor: '#191b1f',
+ borderColor: '#37342d',
+ borderRadius: 8,
+ borderWidth: 1,
+ flex: 1,
+ gap: 8,
+ minWidth: 0,
+ padding: 12,
+ },
+ entryCardLatest: {
+ backgroundColor: '#1e1d1a',
+ borderColor: '#d6b25e',
+ },
+ entryCount: {
+ ...defaultTextStyle,
+ backgroundColor: '#24272b',
+ borderColor: '#4d4432',
+ borderRadius: 6,
+ borderWidth: 1,
+ color: '#f5d98d',
+ fontSize: 11,
+ fontWeight: '800',
+ letterSpacing: 0,
+ paddingHorizontal: 8,
+ paddingVertical: 5,
+ textTransform: 'uppercase',
+ },
+ entryHeader: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ entryTitle: {
+ ...displayTextStyle,
+ color: '#f6f1e7',
+ fontSize: 17,
+ letterSpacing: 0,
+ lineHeight: 21,
+ },
+ eyebrow: {
+ ...defaultTextStyle,
+ color: '#d6b25e',
+ fontSize: 12,
+ fontWeight: '800',
+ letterSpacing: 0,
+ textTransform: 'uppercase',
+ },
+ homeAction: {
+ marginTop: 'auto',
+ width: 164,
+ },
+ latestDate: {
+ ...defaultTextStyle,
+ color: '#80796c',
+ fontSize: 11,
+ fontWeight: '700',
+ letterSpacing: 0,
+ },
+ latestPanel: {
+ backgroundColor: '#191b1f',
+ borderColor: '#4d4432',
+ borderRadius: 8,
+ borderWidth: 1,
+ gap: 4,
+ padding: 12,
+ },
+ latestVersion: {
+ ...defaultTextStyle,
+ color: '#f5d98d',
+ fontSize: 11,
+ fontWeight: '800',
+ letterSpacing: 0,
+ lineHeight: 15,
+ },
+ leftRail: {
+ gap: 12,
+ justifyContent: 'flex-start',
+ width: 232,
+ zIndex: 1,
+ },
+ marker: {
+ alignItems: 'center',
+ backgroundColor: '#24272b',
+ borderColor: '#4d4432',
+ borderRadius: 14,
+ borderWidth: 1,
+ height: 28,
+ justifyContent: 'center',
+ width: 28,
+ },
+ markerLatest: {
+ backgroundColor: '#3a2918',
+ borderColor: '#d6b25e',
+ },
+ markerLine: {
+ backgroundColor: '#37342d',
+ flex: 1,
+ marginVertical: 5,
+ width: 1,
+ },
+ markerText: {
+ ...defaultTextStyle,
+ color: '#f5d98d',
+ fontSize: 11,
+ fontWeight: '800',
+ letterSpacing: 0,
+ },
+ panelLabel: {
+ ...defaultTextStyle,
+ color: '#d6b25e',
+ fontSize: 10,
+ fontWeight: '800',
+ letterSpacing: 0,
+ textTransform: 'uppercase',
+ },
+ releaseBoard: {
+ backgroundColor: '#15171a',
+ borderColor: '#37342d',
+ borderRadius: 8,
+ borderWidth: 1,
+ flex: 1,
+ gap: 12,
+ padding: 14,
+ zIndex: 1,
+ },
+ summary: {
+ ...defaultTextStyle,
+ color: '#d6b25e',
+ fontSize: 12,
+ lineHeight: 16,
+ },
+ timeline: {
+ flex: 1,
+ gap: 10,
+ justifyContent: 'flex-start',
+ },
+ timelineMarkerColumn: {
+ alignItems: 'center',
+ width: 28,
+ },
+ timelineRow: {
+ flexDirection: 'row',
+ gap: 10,
+ height: 170,
+ },
+ title: {
+ ...displayTextStyle,
+ color: '#f6f1e7',
+ fontSize: 34,
+ letterSpacing: 0,
+ lineHeight: 40,
+ },
+ version: {
+ ...displayTextStyle,
+ color: '#f5d98d',
+ fontSize: 12,
+ letterSpacing: 0,
+ },
+ versionPill: {
+ backgroundColor: '#3a2918',
+ borderColor: '#d6b25e',
+ borderRadius: 6,
+ borderWidth: 1,
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ },
+});
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index 18e19cc..8edd24d 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -24,6 +24,7 @@ type HomeScreenProps = Readonly<{
}>;
const menuIcons: Record = {
+ changelog: '✎',
credits: '★',
newGame: '▶',
policies: '§',
@@ -155,7 +156,7 @@ const styles = StyleSheet.create({
textTransform: 'uppercase',
},
menu: {
- gap: 8,
+ gap: 6,
width: 156,
},
menuBackgroundImage: {
diff --git a/src/screens/PageScreen.tsx b/src/screens/PageScreen.tsx
index fcf9f56..f5149d2 100644
--- a/src/screens/PageScreen.tsx
+++ b/src/screens/PageScreen.tsx
@@ -12,6 +12,7 @@ import { defaultTextStyle } from '../styles/typography';
type PageRouteName = Exclude<
RouteName,
| 'gameplay'
+ | 'changelog'
| 'home'
| 'policies'
| 'saves'
diff --git a/tools/bump-build-version.mjs b/tools/bump-build-version.mjs
new file mode 100644
index 0000000..ac9e805
--- /dev/null
+++ b/tools/bump-build-version.mjs
@@ -0,0 +1,118 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+const envPath = new URL('../.env', import.meta.url);
+const packagePath = new URL('../package.json', import.meta.url);
+const packageLockPath = new URL('../package-lock.json', import.meta.url);
+
+function getNextPatchVersion(version) {
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
+
+ if (match == null) {
+ throw new Error(`Expected package version to use #.#.# format, got "${version}".`);
+ }
+
+ const [, major, minor, patch] = match;
+
+ return `${major}.${minor}.${Number(patch) + 1}`;
+}
+
+async function readJson(filePath) {
+ return JSON.parse(await readFile(filePath, 'utf8'));
+}
+
+async function writeJson(filePath, json) {
+ await writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
+}
+
+async function getLastSuccessfulCommit() {
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--short=7', 'HEAD']);
+
+ return stdout.trim();
+}
+
+function getBuildDate() {
+ const now = new Date();
+ const month = String(now.getMonth() + 1).padStart(2, '0');
+ const day = String(now.getDate()).padStart(2, '0');
+
+ return `${now.getFullYear()}${month}${day}`;
+}
+
+async function readEnvLines() {
+ try {
+ return (await readFile(envPath, 'utf8')).split(/\r?\n/);
+ } catch (error) {
+ if (error != null && typeof error === 'object' && 'code' in error) {
+ const nodeError = error;
+
+ if (nodeError.code === 'ENOENT') {
+ return [];
+ }
+ }
+
+ throw error;
+ }
+}
+
+async function writeEnvValues(values) {
+ const lines = await readEnvLines();
+ const remainingKeys = new Set(Object.keys(values));
+ const nextLines = lines
+ .filter((line, index) => line.length > 0 || index < lines.length - 1)
+ .map(line => {
+ const [key] = line.split('=', 1);
+
+ if (!remainingKeys.has(key)) {
+ return line;
+ }
+
+ remainingKeys.delete(key);
+
+ return `${key}=${values[key]}`;
+ });
+
+ for (const key of remainingKeys) {
+ nextLines.push(`${key}=${values[key]}`);
+ }
+
+ await writeFile(envPath, `${nextLines.join('\n')}\n`);
+}
+
+async function main() {
+ const packageJson = await readJson(packagePath);
+ const nextVersion = getNextPatchVersion(packageJson.version);
+ const buildDate = getBuildDate();
+ const lastSuccessfulCommit = await getLastSuccessfulCommit();
+
+ packageJson.version = nextVersion;
+ await writeJson(packagePath, packageJson);
+
+ const packageLockJson = await readJson(packageLockPath);
+ packageLockJson.version = nextVersion;
+
+ if (
+ packageLockJson.packages != null &&
+ packageLockJson.packages[''] != null
+ ) {
+ packageLockJson.packages[''].version = nextVersion;
+ }
+
+ await writeJson(packageLockPath, packageLockJson);
+ await writeEnvValues({
+ VITE_LAST_BUILD_DATE: buildDate,
+ VITE_LAST_SUCCESSFUL_COMMIT: lastSuccessfulCommit,
+ });
+ await execFileAsync('git', ['add', '.env', 'package.json', 'package-lock.json']);
+
+ console.log(
+ `Updated build version to ${nextVersion}-${buildDate}-${lastSuccessfulCommit}`,
+ );
+}
+
+main().catch(error => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exitCode = 1;
+});
diff --git a/Game Assets 1/Throne of the Bone King.png b/wiki/Game Assets 1/Throne of the Bone King.png
similarity index 100%
rename from Game Assets 1/Throne of the Bone King.png
rename to wiki/Game Assets 1/Throne of the Bone King.png