feat(toolkit): scaffold wiki-driven toolkit sections

This commit is contained in:
2026-06-12 07:13:41 -05:00
parent ef3253d324
commit 255e5a6b24
6 changed files with 574 additions and 65 deletions
+32 -8
View File
@@ -4,6 +4,7 @@ import {
requiredPageRoutes,
toolkitRoutes,
routeDefinitions,
normalizeRouteName,
} from '../src/navigation/routes';
describe('routeDefinitions', () => {
@@ -19,11 +20,19 @@ describe('routeDefinitions', () => {
'policiesCookies',
'credits',
'toolkit',
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitOpening',
'toolkitPetitioners',
'toolkitRulings',
'toolkitSeed',
'toolkitResources',
'toolkitMap',
'toolkitFactions',
'toolkitFlags',
'toolkitStory',
'toolkitLore',
'toolkitAudio',
'toolkitEndings',
'toolkitReferences',
]);
for (const routeName of requiredPageRoutes) {
@@ -50,11 +59,26 @@ describe('routeDefinitions', () => {
]);
expect(toolkitRoutes).toEqual([
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitOpening',
'toolkitPetitioners',
'toolkitRulings',
'toolkitSeed',
'toolkitResources',
'toolkitMap',
'toolkitFactions',
'toolkitFlags',
'toolkitStory',
'toolkitLore',
'toolkitAudio',
'toolkitEndings',
'toolkitReferences',
]);
});
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');
});
});
+58 -6
View File
@@ -240,11 +240,21 @@ describe('screen scaffold', () => {
const text = getRenderedText(renderer);
expect(text).toContain('Toolkit');
expect(text).toContain('Opening Sequence');
expect(text).toContain('Credits');
expect(text).toContain('Audio');
expect(text).toContain('Lore');
expect(text).toContain('Opening');
expect(text).toContain('Petitioners');
expect(text).toContain('Rulings');
expect(text).toContain('Seed');
expect(text).toContain('Resources');
expect(text).toContain('Map');
expect(text).toContain('Factions');
expect(text).toContain('Flags');
expect(text).toContain('Story');
expect(text).toContain('Lore');
expect(text).toContain('Audio');
expect(text).toContain('Endings');
expect(text).toContain('References');
expect(text).toContain('Read-only planning panel');
expect(text).toContain('Future authoring area');
const homeButton = renderer.root.findByProps({ testID: 'toolkit-home' });
@@ -256,6 +266,48 @@ describe('screen scaffold', () => {
expect(renderer.root.findByProps({ testID: 'toolkit-socket-indicator' })).toBeTruthy();
});
it('renders purpose panels for every toolkit section without forms', () => {
const expectedPurposeText = {
toolkitAudio: 'Preview and catalog sound identity',
toolkitEndings: 'Tune collapse states',
toolkitFactions: 'Tune faction trust',
toolkitFlags: 'Debug delayed consequences',
toolkitLore: 'Reference changing canon',
toolkitMap: 'Support settlement and world-map iteration',
toolkitOpening: 'Preview and sequence the first-run premise',
toolkitPetitioners: 'Inspect and eventually author throne-room cases',
toolkitReferences: 'Keep design inspirations',
toolkitResources: 'Tune the core Bones, Minions, and Milk economy',
toolkitRulings: 'Inspect and eventually tune choice outcomes',
toolkitSeed: 'Debug why a day produced specific petitioners',
toolkitStory: 'Organize Act I-IV pressure',
} as const;
for (const routeName of toolkitRoutes) {
const renderer = render(
<ToolkitScreen
goHome={jest.fn()}
navigate={jest.fn()}
routeName={routeName}
/>,
);
const text = getRenderedText(renderer);
expect(text).toContain(expectedPurposeText[routeName]);
expect(renderer.root.findByProps({ testID: 'toolkit-purpose' })).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'toolkit-change-note' }),
).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'toolkit-sources' })).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'toolkit-future-fields' }),
).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'toolkit-future-placeholder' }),
).toBeTruthy();
}
});
it('renders the toolkit websocket connection indicator', () => {
act(() => {
useToolkitSocketStore.getState().setConnected('test-socket');
@@ -300,10 +352,10 @@ describe('screen scaffold', () => {
expect(optionGroup.props.style).toMatchObject({ flexDirection: 'row' });
expect(homeSlot.props.style).toMatchObject({ marginLeft: 'auto' });
expect(
renderer.root.findByProps({ testID: 'toolkit-toolkitOpeningSequence-icon' }),
renderer.root.findByProps({ testID: 'toolkit-toolkitOpening-icon' }),
).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'toolkit-toolkitOpeningSequence-label' }),
renderer.root.findByProps({ testID: 'toolkit-toolkitOpening-label' }),
).toBeTruthy();
});
+10 -3
View File
@@ -8,7 +8,7 @@ import { PoliciesScreen } from '../screens/PoliciesScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { ToolkitScreen } from '../screens/ToolkitScreen';
import { useNavigationStore } from '../state';
import { toolkitRoutes, type RouteName } from './routes';
import { normalizeRouteName, toolkitRoutes, type RouteName } from './routes';
const fadeOutDurationMs = process.env.NODE_ENV === 'test' ? 0 : 140;
const fadeInDurationMs = process.env.NODE_ENV === 'test' ? 0 : 180;
@@ -19,8 +19,9 @@ export function AppNavigator() {
const cancelPendingRoute = useNavigationStore(state => state.cancelPendingRoute);
const completeNavigation = useNavigationStore(state => state.completeNavigation);
const pendingRouteName = useNavigationStore(state => state.pendingRouteName);
const routeName = useNavigationStore(state => state.currentRouteName);
const rawRouteName = useNavigationStore(state => state.currentRouteName);
const setPendingRoute = useNavigationStore(state => state.setPendingRoute);
const routeName = normalizeRouteName(rawRouteName);
const opacity = useRef(new Animated.Value(1)).current;
const isTransitioning = useRef(false);
@@ -29,9 +30,15 @@ export function AppNavigator() {
return;
}
completeNavigation(pendingRouteName);
completeNavigation(normalizeRouteName(pendingRouteName));
}, [completeNavigation, pendingRouteName]);
useEffect(() => {
if (routeName !== rawRouteName) {
completeNavigation(routeName);
}
}, [completeNavigation, rawRouteName, routeName]);
const navigate = useCallback((nextRouteName: RouteName) => {
if (
nextRouteName === routeName ||
+90 -19
View File
@@ -43,25 +43,57 @@ export const routeDefinitions = {
label: 'Toolkit',
title: 'Toolkit',
},
toolkitOpeningSequence: {
label: 'Opening Sequence',
toolkitOpening: {
label: 'Opening',
title: 'Opening Sequence',
},
toolkitCredits: {
label: 'Credits',
title: 'Toolkit Credits',
toolkitPetitioners: {
label: 'Petitioners',
title: 'Petitioners',
},
toolkitAudio: {
label: 'Audio',
title: 'Audio',
toolkitRulings: {
label: 'Rulings',
title: 'Rulings',
},
toolkitSeed: {
label: 'Seed',
title: 'Daily Seed',
},
toolkitResources: {
label: 'Resources',
title: 'Resources',
},
toolkitMap: {
label: 'Map',
title: 'World Map',
},
toolkitFactions: {
label: 'Factions',
title: 'Factions',
},
toolkitFlags: {
label: 'Flags',
title: 'Flags & History',
},
toolkitStory: {
label: 'Story',
title: 'Story Arcs',
},
toolkitLore: {
label: 'Lore',
title: 'Lore',
},
toolkitResources: {
label: 'Resources',
title: 'Resources',
toolkitAudio: {
label: 'Audio',
title: 'Audio',
},
toolkitEndings: {
label: 'Endings',
title: 'Failure & Endings',
},
toolkitReferences: {
label: 'References',
title: 'References',
},
} as const;
@@ -84,12 +116,43 @@ export const policyRoutes = [
] as const satisfies readonly RouteName[];
export const toolkitRoutes = [
'toolkitOpening',
'toolkitPetitioners',
'toolkitRulings',
'toolkitSeed',
'toolkitResources',
'toolkitMap',
'toolkitFactions',
'toolkitFlags',
'toolkitStory',
'toolkitLore',
'toolkitAudio',
'toolkitEndings',
'toolkitReferences',
] as const satisfies readonly RouteName[];
export type ToolkitRouteName = (typeof toolkitRoutes)[number];
const legacyToolkitRoutes = [
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitResources',
] as const satisfies readonly RouteName[];
] as const;
export function normalizeRouteName(routeName: unknown): RouteName {
if (typeof routeName !== 'string') {
return 'home';
}
if (routeName in routeDefinitions) {
return routeName as RouteName;
}
if ((legacyToolkitRoutes as readonly string[]).includes(routeName)) {
return 'toolkit';
}
return 'home';
}
export const requiredPageRoutes = [
'home',
@@ -102,9 +165,17 @@ export const requiredPageRoutes = [
'policiesCookies',
'credits',
'toolkit',
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitOpening',
'toolkitPetitioners',
'toolkitRulings',
'toolkitSeed',
'toolkitResources',
'toolkitMap',
'toolkitFactions',
'toolkitFlags',
'toolkitStory',
'toolkitLore',
'toolkitAudio',
'toolkitEndings',
'toolkitReferences',
] as const satisfies readonly RouteName[];
+6 -8
View File
@@ -2,17 +2,15 @@ import { StyleSheet, Text, View } from 'react-native';
import { BackHomeButton } from '../components/BackHomeButton';
import { ScreenLayout } from '../components/ScreenLayout';
import { routeDefinitions, type RouteName } from '../navigation/routes';
import {
routeDefinitions,
type RouteName,
type ToolkitRouteName,
} from '../navigation/routes';
type PageRouteName = Exclude<
RouteName,
| 'home'
| 'policies'
| 'settings'
| 'toolkit'
| 'toolkitOpeningSequence'
| 'toolkitCredits'
| 'toolkitAudio'
'home' | 'policies' | 'settings' | 'toolkit' | ToolkitRouteName
>;
type PageScreenProps = Readonly<{
+378 -21
View File
@@ -5,32 +5,255 @@ import {
routeDefinitions,
toolkitRoutes,
type RouteName,
type ToolkitRouteName,
} from '../navigation/routes';
import { useToolkitSocketStore, type ToolkitSocketStatus } from '../state';
type ToolkitRouteName = (typeof toolkitRoutes)[number];
type ToolkitScreenProps = Readonly<{
goHome: () => void;
navigate: (routeName: RouteName) => void;
routeName: RouteName;
}>;
const toolkitDescriptions: Partial<Record<RouteName, string>> = {
toolkit: 'Select a toolkit area to preview game support assets.',
toolkitAudio: 'Audio tools and checks will live here.',
toolkitCredits: 'Toolkit-specific credit review will live here.',
toolkitLore: 'Lore reference material will live here.',
toolkitOpeningSequence: 'Opening sequence preview controls will live here.',
toolkitResources: 'Production and gameplay resources will live here.',
type ToolkitSectionContent = Readonly<{
futureFields: string[];
liableToChange: string;
purpose: string;
sources: string[];
}>;
const toolkitOverview: ToolkitSectionContent = {
futureFields: [
'section purpose map',
'wiki source references',
'future forms',
'tables',
'previews',
],
liableToChange:
'The Toolkit should follow whichever gameplay loops prove most volatile during early design.',
purpose:
'Select a toolkit area to inspect the systems most likely to change while the throne room loop stabilizes.',
sources: ['Home', 'Gameplay', 'Technical Design Specs'],
};
const toolkitSections: Record<ToolkitRouteName, ToolkitSectionContent> = {
toolkitAudio: {
futureFields: [
'throne room theme',
'world map theme',
'petitioner stings',
'BLB voice direction',
'comedy SFX rules',
],
liableToChange:
'Audio identity will shift as petitioner types, faction tone, and readable decision pacing evolve.',
purpose:
'Preview and catalog sound identity tied to throne room, map, petitioner, BLB voice, and comedy contexts.',
sources: ['Music', 'Gameplay', 'References'],
},
toolkitEndings: {
futureFields: [
'resource collapse',
'stability collapse',
'settlement collapse count',
'faction coup',
'ending summaries',
],
liableToChange:
'Failure thresholds and ending categories will change as the run length and kingdom identity model settle.',
purpose:
'Tune collapse states and outcome summaries so failure and victory reflect accumulated rulings.',
sources: ['Story', 'Technical Design Specs'],
},
toolkitFactions: {
futureFields: [
'faction ID',
'trust',
'power',
'agenda',
'likes',
'dislikes',
'coup risks',
],
liableToChange:
'Faction trust and power are expected to move as petitioner pressure and map consequence rules mature.',
purpose:
'Tune faction trust, power, agenda pressure, and the events attached to faction relationships.',
sources: ['Lore', 'Gameplay', 'Technical Design Specs'],
},
toolkitFlags: {
futureFields: [
'active flags',
'seen petitioners',
'rulings',
'daily summaries',
'unlocked events',
'chain triggers',
],
liableToChange:
'Delayed consequences and recurring petitioner chains depend on flags that will expand quickly.',
purpose:
'Debug delayed consequences, recurring petitioner chains, run history, and unlocked future events.',
sources: ['Gameplay', 'Story', 'Technical Design Specs'],
},
toolkitLore: {
futureFields: [
'settlements',
'factions',
'BLB origin variants',
'BLB limits',
'canon rules',
'joke rules',
],
liableToChange:
'Canon needs room to absorb absurd jokes while still preserving consequence and internal logic.',
purpose:
'Reference changing canon, world concepts, BLB rules, faction needs, and settlement identity.',
sources: ['Lore', 'Story', 'References'],
},
toolkitMap: {
futureFields: [
'settlements',
'owner',
'faction influence',
'production',
'stability',
'need',
'status effects',
],
liableToChange:
'The map is the consequence layer, so settlement state will change as ruling effects become visible.',
purpose:
'Support settlement and world-map iteration as throne room choices ripple into territory state.',
sources: ['Gameplay', 'Lore', 'Technical Design Specs'],
},
toolkitOpening: {
futureFields: [
'opening premise',
'first day setup',
'initial resources',
'first petitioners',
'first map action',
],
liableToChange:
'The first-run sequence will change as the tutorial learns how much of the loop to teach up front.',
purpose:
'Preview and sequence the first-run premise, Act I intro beats, and early tutorial framing.',
sources: ['Story', 'Gameplay', 'Technical Design Specs'],
},
toolkitPetitioners: {
futureFields: [
'petitioner ID',
'name',
'type',
'faction',
'settlement',
'tags',
'day range',
'weight',
'flags',
],
liableToChange:
'Petitioners are the primary content loop and will change as cases, factions, and map pressure evolve.',
purpose:
'Inspect and eventually author throne-room cases, the main variable content loop of each day.',
sources: ['Gameplay', 'Story', 'Technical Design Specs'],
},
toolkitReferences: {
futureFields: [
'game references',
'visual pillars',
'tone references',
'BLB asset links',
'repo links',
'build notes',
],
liableToChange:
'Reference material will grow as art, audio, design, and implementation sources become concrete.',
purpose:
'Keep design inspirations, source links, asset references, and build notes close to implementation.',
sources: ['References', 'Home'],
},
toolkitResources: {
futureFields: [
'current totals',
'daily production',
'upkeep',
'conversion rules',
'thresholds',
'warnings',
'collapse triggers',
],
liableToChange:
'The Bones, Minions, and Milk triangle is the core economy and will need repeated tuning.',
purpose:
'Tune the core Bones, Minions, and Milk economy plus production, upkeep, and collapse rules.',
sources: ['Gameplay', 'Technical Design Specs'],
},
toolkitRulings: {
futureFields: [
'label',
'tone',
'costs',
'resource effects',
'settlement effects',
'faction effects',
'future weights',
'result text',
],
liableToChange:
'Ruling effects need to be tuned separately from flavor text as consequence balance changes.',
purpose:
'Inspect and eventually tune choice outcomes independently from petitioner presentation.',
sources: ['Gameplay', 'Technical Design Specs'],
},
toolkitSeed: {
futureFields: [
'eligible pool',
'filters',
'weights',
'shortage modifiers',
'trust modifiers',
'selected IDs',
'day history',
],
liableToChange:
'Daily selection rules will change as shortages, unstable settlements, and faction pressure matter more.',
purpose:
'Debug why a day produced specific petitioners from deterministic seed and world-state weighting.',
sources: ['Gameplay', 'Technical Design Specs'],
},
toolkitStory: {
futureFields: [
'act definitions',
'recurring petitioner arcs',
'campaign pressure',
'kingdom signals',
'endgame hooks',
],
liableToChange:
'Story pressure should remain sandbox-first, so arcs will shift around emergent kingdom state.',
purpose:
'Organize Act I-IV pressure, recurring petitioner arcs, and long-term kingdom identity.',
sources: ['Story', 'Lore', 'Gameplay'],
},
};
const toolkitIcons: Record<ToolkitRouteName, string> = {
toolkitAudio: '♪',
toolkitCredits: '',
toolkitEndings: '!',
toolkitFactions: '◇',
toolkitFlags: '#',
toolkitLore: '✦',
toolkitOpeningSequence: '',
toolkitMap: '',
toolkitOpening: '▶',
toolkitPetitioners: '◉',
toolkitReferences: '※',
toolkitResources: '▦',
toolkitRulings: '§',
toolkitSeed: '◎',
toolkitStory: '★',
};
const socketStatusCopy: Record<ToolkitSocketStatus, string> = {
@@ -47,6 +270,7 @@ export function ToolkitScreen({
const safeAreaInsets = useSafeAreaInsets();
const activeRoute = routeDefinitions[routeName];
const socketStatus = useToolkitSocketStore(state => state.status);
const toolkitContent = getToolkitContent(routeName);
return (
<View
@@ -87,15 +311,72 @@ export function ToolkitScreen({
<View style={styles.contentPanel}>
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
<Text style={styles.title}>{activeRoute.title}</Text>
<Text style={styles.description}>
{toolkitDescriptions[routeName] ?? toolkitDescriptions.toolkit}
</Text>
<Text style={styles.readOnlyLabel}>Read-only planning panel</Text>
<View style={styles.detailGrid}>
<ToolkitDetail
label="Purpose"
testID="toolkit-purpose"
value={toolkitContent.purpose}
/>
<ToolkitDetail
label="Liable to change because"
testID="toolkit-change-note"
value={toolkitContent.liableToChange}
/>
</View>
<View style={styles.sourcePanel} testID="toolkit-sources">
<Text style={styles.detailLabel}>Source wiki pages</Text>
<View style={styles.sourceRow}>
{toolkitContent.sources.map(source => (
<Text key={source} style={styles.sourcePill}>
{source}
</Text>
))}
</View>
</View>
<ToolkitDetail
label="Anticipated future fields / entities"
testID="toolkit-future-fields"
value={toolkitContent.futureFields.join(', ')}
/>
<View
style={styles.placeholderPanel}
testID="toolkit-future-placeholder">
<Text style={styles.placeholderTitle}>Future authoring area</Text>
<Text style={styles.placeholderCopy}>
Forms, tables, previews, and schema-backed validation will mount
here after the JSON and Markdown content formats stabilize.
</Text>
</View>
<ToolkitSocketIndicator status={socketStatus} />
</View>
</View>
);
}
function getToolkitContent(routeName: RouteName) {
if ((toolkitRoutes as readonly RouteName[]).includes(routeName)) {
return toolkitSections[routeName as ToolkitRouteName];
}
return toolkitOverview;
}
type ToolkitDetailProps = Readonly<{
label: string;
testID: string;
value: string;
}>;
function ToolkitDetail({ label, testID, value }: ToolkitDetailProps) {
return (
<View style={styles.detailPanel} testID={testID}>
<Text style={styles.detailLabel}>{label}</Text>
<Text style={styles.detailValue}>{value}</Text>
</View>
);
}
type ToolkitSocketIndicatorProps = Readonly<{
status: ToolkitSocketStatus;
}>;
@@ -187,11 +468,31 @@ const styles = StyleSheet.create({
padding: 14,
position: 'relative',
},
description: {
detailGrid: {
flexDirection: 'row',
gap: 10,
marginBottom: 10,
},
detailLabel: {
color: '#d6b25e',
fontSize: 10,
fontWeight: '800',
letterSpacing: 0,
marginBottom: 5,
textTransform: 'uppercase',
},
detailPanel: {
backgroundColor: '#131518',
borderColor: '#2d2f32',
borderRadius: 6,
borderWidth: 1,
flex: 1,
padding: 10,
},
detailValue: {
color: '#c9c1b3',
fontSize: 14,
lineHeight: 20,
maxWidth: 420,
fontSize: 12,
lineHeight: 17,
},
eyebrow: {
color: '#d6b25e',
@@ -209,6 +510,60 @@ const styles = StyleSheet.create({
lineHeight: 34,
marginBottom: 12,
},
placeholderCopy: {
color: '#c9c1b3',
fontSize: 12,
lineHeight: 17,
},
placeholderPanel: {
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 6,
borderStyle: 'dashed',
borderWidth: 1,
marginTop: 10,
padding: 10,
},
placeholderTitle: {
color: '#f6f1e7',
fontSize: 12,
fontWeight: '800',
letterSpacing: 0,
marginBottom: 4,
},
readOnlyLabel: {
color: '#80796c',
fontSize: 11,
fontWeight: '800',
letterSpacing: 0,
marginBottom: 10,
textTransform: 'uppercase',
},
sourcePanel: {
backgroundColor: '#131518',
borderColor: '#2d2f32',
borderRadius: 6,
borderWidth: 1,
marginBottom: 10,
padding: 10,
},
sourcePill: {
backgroundColor: '#2a261d',
borderColor: '#4d4432',
borderRadius: 4,
borderWidth: 1,
color: '#f5d98d',
fontSize: 11,
fontWeight: '800',
letterSpacing: 0,
paddingHorizontal: 7,
paddingVertical: 4,
},
sourceRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
socketDot: {
borderRadius: 5,
height: 10,
@@ -245,7 +600,7 @@ const styles = StyleSheet.create({
borderWidth: 1,
justifyContent: 'center',
minHeight: 34,
paddingHorizontal: 12,
paddingHorizontal: 8,
paddingVertical: 6,
},
toolbarButtonActive: {
@@ -255,7 +610,7 @@ const styles = StyleSheet.create({
toolbarButtonContent: {
alignItems: 'center',
flexDirection: 'row',
gap: 5,
gap: 4,
justifyContent: 'center',
},
toolbarButtonIcon: {
@@ -272,7 +627,7 @@ const styles = StyleSheet.create({
},
toolbarButtonLabel: {
color: '#f6f1e7',
fontSize: 12,
fontSize: 10,
fontWeight: '800',
letterSpacing: 0,
textAlign: 'center',
@@ -285,6 +640,8 @@ const styles = StyleSheet.create({
},
toolbarGroup: {
flexDirection: 'row',
flexShrink: 1,
flexWrap: 'wrap',
},
});