feat(gameplay): scaffold phase flow and saves
Lint and Build Checks / Lint, Test, and Build (push) Failing after 560h41m34s

This commit is contained in:
2026-06-16 10:56:06 -05:00
parent d294f057cd
commit 13aa686fa8
16 changed files with 1616 additions and 5 deletions
+133
View File
@@ -0,0 +1,133 @@
jest.mock('@react-native-async-storage/async-storage', () => {
const storage = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn((key: string) => Promise.resolve(storage.get(key) ?? null)),
removeItem: jest.fn((key: string) => {
storage.delete(key);
return Promise.resolve();
}),
setItem: jest.fn((key: string, value: string) => {
storage.set(key, value);
return Promise.resolve();
}),
},
};
});
import { act } from 'react-test-renderer';
import { useGameplayStore, useSaveGameStore } from '../src/state';
describe('useGameplayStore', () => {
beforeEach(() => {
act(() => {
useGameplayStore.setState({ activeRun: null });
useSaveGameStore.setState({ saveSlots: [] });
});
});
it('starts a new game on day one at the daily seed phase', () => {
act(() => {
useGameplayStore.getState().startNewGame();
});
expect(useGameplayStore.getState().activeRun).toEqual(
expect.objectContaining({
currentPhaseId: 'dailySeed',
runName: 'Ossuary Dominion Run',
turn: 1,
}),
);
});
it('moves forward and backward through the fixed phase order', () => {
act(() => {
useGameplayStore.getState().startNewGame();
useGameplayStore.getState().goToNextPhase();
});
expect(useGameplayStore.getState().activeRun?.currentPhaseId).toBe(
'throneRoom',
);
act(() => {
useGameplayStore.getState().goToNextPhase();
});
expect(useGameplayStore.getState().activeRun?.currentPhaseId).toBe(
'worldMap',
);
act(() => {
useGameplayStore.getState().goToPreviousPhase();
});
expect(useGameplayStore.getState().activeRun?.currentPhaseId).toBe(
'throneRoom',
);
});
it('increments the day when ending the daily summary phase', () => {
act(() => {
useGameplayStore.getState().startNewGame();
useGameplayStore.getState().goToNextPhase();
useGameplayStore.getState().goToNextPhase();
useGameplayStore.getState().goToNextPhase();
useGameplayStore.getState().goToNextPhase();
});
expect(useGameplayStore.getState().activeRun).toEqual(
expect.objectContaining({
currentPhaseId: 'dailySeed',
turn: 2,
}),
);
});
it('saves and loads a run through save slots', () => {
act(() => {
useGameplayStore.getState().startNewGame();
useGameplayStore.getState().goToNextPhase();
useGameplayStore.getState().saveCurrentRun();
});
const saveSlot = useSaveGameStore.getState().saveSlots[0];
expect(saveSlot).toEqual(
expect.objectContaining({
phaseId: 'throneRoom',
turn: 1,
}),
);
act(() => {
useGameplayStore.getState().goToNextPhase();
useGameplayStore.getState().loadGame(saveSlot.id);
});
expect(useGameplayStore.getState().activeRun).toEqual(
expect.objectContaining({
currentPhaseId: 'throneRoom',
gameId: saveSlot.id,
turn: 1,
}),
);
});
it('keeps the active run available after exiting to home', () => {
act(() => {
useGameplayStore.getState().startNewGame();
});
const gameId = useGameplayStore.getState().activeRun?.gameId;
act(() => {
useGameplayStore.getState().exitToHome();
});
expect(useGameplayStore.getState().activeRun?.gameId).toBe(gameId);
});
});
+1
View File
@@ -19,6 +19,7 @@ describe('routeDefinitions', () => {
'policiesTerms',
'policiesCookies',
'credits',
'gameplay',
'toolkit',
'toolkitOpening',
'toolkitPetitioners',
+149 -1
View File
@@ -10,15 +10,19 @@ import {
toolkitRoutes,
} from '../src/navigation/routes';
import { defaultVolumeSettings } from '../src/settings/gameSettings';
import { GameplayScreen } from '../src/screens/GameplayScreen';
import { HomeScreen } from '../src/screens/HomeScreen';
import { PageScreen } from '../src/screens/PageScreen';
import { PoliciesScreen } from '../src/screens/PoliciesScreen';
import { SavesScreen } from '../src/screens/SavesScreen';
import { SettingsScreen } from '../src/screens/SettingsScreen';
import { ToolkitScreen } from '../src/screens/ToolkitScreen';
import {
useGameSettingsStore,
useToolkitContentStore,
useGameplayStore,
useNavigationStore,
useSaveGameStore,
useToolkitSocketStore,
} from '../src/state';
import { fallbackCreditsContent } from '../src/data';
@@ -71,9 +75,22 @@ function render(element: ReactElement) {
}
function getRenderedText(renderer: ReactTestRenderer) {
function flattenText(children: unknown): string {
if (Array.isArray(children)) {
return children.map(flattenText).join('');
}
if (children == null || typeof children === 'boolean') {
return '';
}
return String(children);
}
return renderer.root
.findAllByType(Text)
.map(instance => instance.props.children)
.map(flattenText)
.join(' ');
}
@@ -107,10 +124,16 @@ describe('screen scaffold', () => {
resolutionId: 'iphoneDefault',
volumes: defaultVolumeSettings,
});
useGameplayStore.setState({
activeRun: null,
});
useNavigationStore.setState({
currentRouteName: 'home',
pendingRouteName: null,
});
useSaveGameStore.setState({
saveSlots: [],
});
useToolkitSocketStore.setState({
lastConnectedAt: null,
socketId: null,
@@ -159,6 +182,21 @@ describe('screen scaffold', () => {
expect(renderer.root.findAllByProps({ testID: 'menu-toolkit' })).toHaveLength(0);
});
it('starts a new game and navigates to gameplay from the home menu', () => {
const navigate = jest.fn();
const renderer = render(<HomeScreen navigate={navigate} />);
pressByTestID(renderer, 'menu-newGame');
expect(navigate).toHaveBeenCalledWith('gameplay');
expect(useGameplayStore.getState().activeRun).toEqual(
expect.objectContaining({
currentPhaseId: 'dailySeed',
turn: 1,
}),
);
});
it('shows the toolkit home entry as a distinct button only in browser sizing', () => {
act(() => {
useGameSettingsStore.setState({
@@ -220,6 +258,114 @@ describe('screen scaffold', () => {
expect(renderer.root.findByProps({ testID: 'back-home-button' })).toBeTruthy();
});
it('renders gameplay phases and moves through the turn scaffold', () => {
act(() => {
useGameplayStore.getState().startNewGame();
});
const renderer = render(<GameplayScreen navigate={jest.fn()} />);
let text = getRenderedText(renderer);
expect(text).toContain('Daily Seed / Start Day');
expect(text).toContain('Day 1');
expect(text).toContain('Phase 1 of 4');
expect(text).toContain('Resource HUD');
expect(text).toContain('Daily Seed');
expect(text).toContain('Throne Room');
expect(text).toContain('World Map');
expect(text).toContain('Daily Summary');
expect(renderer.root.findByProps({ testID: 'gameplay-prior-phase' }).props).toMatchObject(
{
disabled: true,
},
);
pressByTestID(renderer, 'gameplay-next-phase');
text = getRenderedText(renderer);
expect(text).toContain('Throne Room Petitioners');
expect(text).toContain('Phase 2 of 4');
pressByTestID(renderer, 'gameplay-next-phase');
pressByTestID(renderer, 'gameplay-next-phase');
text = getRenderedText(renderer);
expect(text).toContain('Daily Summary');
expect(text).toContain('End Turn');
pressByTestID(renderer, 'gameplay-next-phase');
text = getRenderedText(renderer);
expect(text).toContain('Day 2');
expect(text).toContain('Daily Seed / Start Day');
});
it('opens the escape menu and exposes save load sound and exit controls', () => {
const navigate = jest.fn();
act(() => {
useGameplayStore.getState().startNewGame();
});
const renderer = render(<GameplayScreen navigate={navigate} />);
pressByTestID(renderer, 'gameplay-open-escape-menu');
let text = getRenderedText(renderer);
expect(text).toContain('Escape Menu');
expect(text).toContain('Save');
expect(text).toContain('Load');
expect(text).toContain('Sound');
expect(text).toContain('Exit Game');
expect(renderer.root.findByProps({ testID: 'escape-mute' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'escape-volume-master' })).toBeTruthy();
pressByTestID(renderer, 'escape-save');
text = getRenderedText(renderer);
expect(text).toContain('Saved Ossuary Dominion Run.');
expect(useSaveGameStore.getState().saveSlots).toHaveLength(1);
});
it('renders saved games and loads one into gameplay', () => {
const navigate = jest.fn();
act(() => {
useGameplayStore.getState().startNewGame();
useGameplayStore.getState().goToNextPhase();
useGameplayStore.getState().saveCurrentRun();
useGameplayStore.getState().goToNextPhase();
});
const saveSlot = useSaveGameStore.getState().saveSlots[0];
const renderer = render(
<SavesScreen goHome={jest.fn()} navigate={navigate} />,
);
const text = getRenderedText(renderer);
expect(text).toContain('Ossuary Dominion Run');
expect(text).toContain('Day 1');
expect(text).toContain('Throne Room');
pressByTestID(renderer, `load-save-${saveSlot.id}`);
expect(navigate).toHaveBeenCalledWith('gameplay');
expect(useGameplayStore.getState().activeRun?.currentPhaseId).toBe(
'throneRoom',
);
});
it('renders an empty saves state', () => {
const renderer = render(
<SavesScreen goHome={jest.fn()} navigate={jest.fn()} />,
);
const text = getRenderedText(renderer);
expect(text).toContain('No saved games yet');
expect(renderer.root.findByProps({ testID: 'saves-empty' })).toBeTruthy();
});
it('renders all policy page links from the policies index', () => {
const renderer = render(
<PoliciesScreen goHome={jest.fn()} navigate={jest.fn()} />,
@@ -473,8 +619,10 @@ describe('screen scaffold', () => {
it('does not use scroll containers for fixed landscape screens', () => {
const screens = [
<HomeScreen key="home" navigate={jest.fn()} />,
<GameplayScreen key="gameplay" navigate={jest.fn()} />,
<PageScreen key="page" goHome={jest.fn()} routeName="newGame" />,
<PoliciesScreen key="policies" goHome={jest.fn()} navigate={jest.fn()} />,
<SavesScreen key="saves" goHome={jest.fn()} navigate={jest.fn()} />,
<SettingsScreen key="settings" goHome={jest.fn()} />,
<ToolkitScreen
key="toolkit"
@@ -494,7 +642,7 @@ describe('screen scaffold', () => {
const renderer = render(<AppNavigator />);
for (const routeName of primaryMenuRoutes) {
if (routeName === 'toolkit') {
if (routeName === 'newGame' || routeName === 'toolkit') {
continue;
}
+93
View File
@@ -0,0 +1,93 @@
export const gameplayPhaseDefinitions = {
dailySeed: {
inputLabels: [
'Daily seed placeholder',
'Eligible petitioner pool placeholder',
'Resource shortage modifiers placeholder',
],
label: 'Daily Seed',
purpose:
'Prepare the day, show the current kingdom state, and reserve space for seeded petitioner selection.',
stateLabels: [
'Day seed not generated',
'No petitioner pool calculated',
'No history written for this day',
],
title: 'Daily Seed / Start Day',
},
throneRoom: {
inputLabels: [
'Petitioner case slot 1',
'Petitioner case slot 2',
'Petitioner case slot 3',
],
label: 'Throne Room',
purpose:
'Present daily petitioners and reserve space for BLB rulings before consequence logic exists.',
stateLabels: [
'No active petitioner selected',
'No ruling choices loaded',
'No ruling result applied',
],
title: 'Throne Room Petitioners',
},
worldMap: {
inputLabels: [
'Map action placeholder',
'Settlement status placeholder',
'Minion assignment placeholder',
],
label: 'World Map',
purpose:
'Reserve the consequence layer where resources, settlements, factions, and assignments will be managed.',
stateLabels: [
'No settlement selected',
'No map action queued',
'No resource cost preview calculated',
],
title: 'World Map Action',
},
dailySummary: {
inputLabels: [
'Resource changes placeholder',
'Settlement changes placeholder',
'Future flags placeholder',
],
label: 'Daily Summary',
purpose:
'Summarize the day and provide the turn boundary before the next daily seed begins.',
stateLabels: [
'No summary generated',
'No delayed consequences recorded',
'No next-day warnings available',
],
title: 'Daily Summary',
},
} as const;
export type GameplayPhaseId = keyof typeof gameplayPhaseDefinitions;
export const gameplayPhaseOrder = [
'dailySeed',
'throneRoom',
'worldMap',
'dailySummary',
] as const satisfies readonly GameplayPhaseId[];
export function getGameplayPhaseIndex(phaseId: GameplayPhaseId) {
return gameplayPhaseOrder.indexOf(phaseId);
}
export function getNextGameplayPhaseId(phaseId: GameplayPhaseId) {
const phaseIndex = getGameplayPhaseIndex(phaseId);
return gameplayPhaseOrder[
Math.min(phaseIndex + 1, gameplayPhaseOrder.length - 1)
];
}
export function getPreviousGameplayPhaseId(phaseId: GameplayPhaseId) {
const phaseIndex = getGameplayPhaseIndex(phaseId);
return gameplayPhaseOrder[Math.max(phaseIndex - 1, 0)];
}
+23
View File
@@ -0,0 +1,23 @@
import type { GameplayPhaseId } from './phases';
export type GameplayPhaseTextState = Record<GameplayPhaseId, string[]>;
export type GameplayRun = {
createdAt: string;
currentPhaseId: GameplayPhaseId;
gameId: string;
phaseInputs: GameplayPhaseTextState;
phaseStates: GameplayPhaseTextState;
runName: string;
turn: number;
updatedAt: string;
};
export type SaveSlot = {
id: string;
name: string;
phaseId: GameplayPhaseId;
run: GameplayRun;
savedAt: string;
turn: number;
};
+3
View File
@@ -0,0 +1,3 @@
export function useEscapeKey(_onEscape: () => void) {
return undefined;
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
type KeyboardHost = typeof globalThis & {
addEventListener?: (
type: 'keydown',
listener: (event: KeyboardEvent) => void,
) => void;
removeEventListener?: (
type: 'keydown',
listener: (event: KeyboardEvent) => void,
) => void;
};
const keyboardHost = globalThis as KeyboardHost;
export function useEscapeKey(onEscape: () => void) {
useEffect(() => {
if (
keyboardHost.addEventListener == null ||
keyboardHost.removeEventListener == null
) {
return undefined;
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onEscape();
}
}
keyboardHost.addEventListener('keydown', handleKeyDown);
return () => {
keyboardHost.removeEventListener?.('keydown', handleKeyDown);
};
}, [onEscape]);
}
+8
View File
@@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef } from 'react';
import { Animated, StyleSheet } from 'react-native';
import { useToolkitSocketConnection } from '../hooks/useToolkitSocketConnection';
import { GameplayScreen } from '../screens/GameplayScreen';
import { HomeScreen } from '../screens/HomeScreen';
import { PageScreen } from '../screens/PageScreen';
import { PoliciesScreen } from '../screens/PoliciesScreen';
import { SavesScreen } from '../screens/SavesScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { ToolkitScreen } from '../screens/ToolkitScreen';
import { useNavigationStore } from '../state';
@@ -110,9 +112,15 @@ function renderRoute(
if (routeName === 'home') {
return <HomeScreen navigate={navigate} />;
}
if (routeName === 'gameplay') {
return <GameplayScreen navigate={navigate} />;
}
if (routeName === 'policies') {
return <PoliciesScreen goHome={goHome} navigate={navigate} />;
}
if (routeName === 'saves') {
return <SavesScreen goHome={goHome} navigate={navigate} />;
}
if (routeName === 'settings') {
return <SettingsScreen goHome={goHome} />;
}
+5
View File
@@ -39,6 +39,10 @@ export const routeDefinitions = {
label: 'Credits',
title: 'Credits',
},
gameplay: {
label: 'Gameplay',
title: 'Gameplay',
},
toolkit: {
label: 'Toolkit',
title: 'Toolkit',
@@ -168,6 +172,7 @@ export const requiredPageRoutes = [
'policiesTerms',
'policiesCookies',
'credits',
'gameplay',
'toolkit',
'toolkitOpening',
'toolkitPetitioners',
+741
View File
@@ -0,0 +1,741 @@
import { useCallback, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
gameplayPhaseDefinitions,
gameplayPhaseOrder,
getGameplayPhaseIndex,
} from '../gameplay/phases';
import { useEscapeKey } from '../hooks/useEscapeKey';
import type { RouteName } from '../navigation/routes';
import { clampVolume, volumeSettings, type VolumeSettingKey } from '../settings/gameSettings';
import {
useGameSettingsStore,
useGameplayStore,
useSaveGameStore,
} from '../state';
import { defaultTextStyle, displayTextStyle } from '../styles/typography';
type GameplayScreenProps = Readonly<{
navigate: (routeName: RouteName) => void;
}>;
export function GameplayScreen({ navigate }: GameplayScreenProps) {
const safeAreaInsets = useSafeAreaInsets();
const [isEscapeMenuOpen, setIsEscapeMenuOpen] = useState(false);
const activeRun = useGameplayStore(state => state.activeRun);
const goToNextPhase = useGameplayStore(state => state.goToNextPhase);
const goToPreviousPhase = useGameplayStore(state => state.goToPreviousPhase);
const startNewGame = useGameplayStore(state => state.startNewGame);
const openEscapeMenu = useCallback(() => {
setIsEscapeMenuOpen(true);
}, []);
useEscapeKey(openEscapeMenu);
if (activeRun == null) {
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom + 16,
paddingLeft: safeAreaInsets.left + 18,
paddingRight: safeAreaInsets.right + 18,
paddingTop: safeAreaInsets.top + 16,
},
]}>
<View style={styles.noRunPanel}>
<Text style={styles.eyebrow}>Bone Lord Bob</Text>
<Text style={styles.title}>No Active Run</Text>
<Text style={styles.copyText}>
Start a new run to enter the gameplay scaffold.
</Text>
<ActionButton
label="Start New Game"
onPress={startNewGame}
testID="gameplay-start-run"
/>
</View>
</View>
);
}
const phase = gameplayPhaseDefinitions[activeRun.currentPhaseId];
const phaseIndex = getGameplayPhaseIndex(activeRun.currentPhaseId);
const isFirstPhase = phaseIndex === 0;
const isSummaryPhase = activeRun.currentPhaseId === 'dailySummary';
const nextLabel = isSummaryPhase ? 'End Turn' : 'Next Phase';
return (
<View
style={[
styles.container,
{
paddingBottom: safeAreaInsets.bottom + 12,
paddingLeft: safeAreaInsets.left + 14,
paddingRight: safeAreaInsets.right + 14,
paddingTop: safeAreaInsets.top + 12,
},
]}
testID="gameplay-screen">
<View style={styles.header}>
<View>
<Text style={styles.eyebrow}>Milkbone Court</Text>
<Text style={styles.title}>{phase.title}</Text>
</View>
<View style={styles.headerStats}>
<Text style={styles.dayCounter} testID="gameplay-day">
Day {activeRun.turn}
</Text>
<Text style={styles.phaseCounter} testID="gameplay-phase-counter">
Phase {phaseIndex + 1} of {gameplayPhaseOrder.length}
</Text>
<ActionButton
label="Menu"
onPress={openEscapeMenu}
testID="gameplay-open-escape-menu"
/>
</View>
</View>
<View style={styles.main}>
<View style={styles.sidebar}>
<ResourceHud />
<View style={styles.phaseRail} testID="gameplay-phase-rail">
{gameplayPhaseOrder.map((phaseId, index) => (
<View
key={phaseId}
style={[
styles.phaseRailItem,
phaseId === activeRun.currentPhaseId
? styles.phaseRailItemActive
: null,
]}>
<Text style={styles.phaseRailNumber}>{index + 1}</Text>
<Text style={styles.phaseRailLabel}>
{gameplayPhaseDefinitions[phaseId].label}
</Text>
</View>
))}
</View>
</View>
<View style={styles.phasePanel}>
<Text style={styles.sectionTitle}>{phase.label}</Text>
<Text style={styles.copyText}>{phase.purpose}</Text>
<View style={styles.phaseColumns}>
<PlaceholderColumn
items={activeRun.phaseInputs[activeRun.currentPhaseId]}
label="Phase Inputs"
testID="gameplay-phase-inputs"
/>
<PlaceholderColumn
items={activeRun.phaseStates[activeRun.currentPhaseId]}
label="Current State"
testID="gameplay-phase-states"
/>
</View>
<View style={styles.controls}>
<ActionButton
disabled={isFirstPhase}
label="Prior Phase"
onPress={goToPreviousPhase}
testID="gameplay-prior-phase"
/>
<ActionButton
label={nextLabel}
onPress={goToNextPhase}
testID="gameplay-next-phase"
tone={isSummaryPhase ? 'gold' : 'default'}
/>
</View>
</View>
</View>
<EscapeMenu
isVisible={isEscapeMenuOpen}
navigate={navigate}
onClose={() => setIsEscapeMenuOpen(false)}
/>
</View>
);
}
function ResourceHud() {
return (
<View style={styles.resourceHud} testID="gameplay-resource-hud">
<Text style={styles.sectionTitle}>Resource HUD</Text>
<View style={styles.resourceGrid}>
<ResourceValue label="Bones" value="--" />
<ResourceValue label="Minions" value="--" />
<ResourceValue label="Milk" value="--" />
<ResourceValue label="Stability" value="--" />
</View>
</View>
);
}
type ResourceValueProps = Readonly<{
label: string;
value: string;
}>;
function ResourceValue({ label, value }: ResourceValueProps) {
return (
<View style={styles.resourceValue}>
<Text style={styles.resourceLabel}>{label}</Text>
<Text style={styles.resourceNumber}>{value}</Text>
</View>
);
}
type PlaceholderColumnProps = Readonly<{
items: string[];
label: string;
testID: string;
}>;
function PlaceholderColumn({ items, label, testID }: PlaceholderColumnProps) {
return (
<View style={styles.placeholderColumn} testID={testID}>
<Text style={styles.placeholderTitle}>{label}</Text>
{items.map(item => (
<Text key={item} style={styles.placeholderItem}>
{item}
</Text>
))}
</View>
);
}
type EscapeMenuProps = Readonly<{
isVisible: boolean;
navigate: (routeName: RouteName) => void;
onClose: () => void;
}>;
function EscapeMenu({ isVisible, navigate, onClose }: EscapeMenuProps) {
const [menuMessage, setMenuMessage] = useState<string | null>(null);
const audioMuted = useGameSettingsStore(state => state.audioMuted);
const exitToHome = useGameplayStore(state => state.exitToHome);
const loadGame = useGameplayStore(state => state.loadGame);
const saveCurrentRun = useGameplayStore(state => state.saveCurrentRun);
const saveSlots = useSaveGameStore(state => state.saveSlots);
const setVolume = useGameSettingsStore(state => state.setVolume);
const toggleAudioMuted = useGameSettingsStore(state => state.toggleAudioMuted);
const volumes = useGameSettingsStore(state => state.volumes);
if (!isVisible) {
return null;
}
function handleSave() {
const saveSlot = saveCurrentRun();
setMenuMessage(
saveSlot == null ? 'No active run to save.' : `Saved ${saveSlot.name}.`,
);
}
function handleLoad(saveId: string) {
if (loadGame(saveId)) {
setMenuMessage('Save loaded.');
onClose();
}
}
function handleExitGame() {
exitToHome();
onClose();
navigate('home');
}
return (
<View style={styles.escapeBackdrop} testID="escape-menu">
<View style={styles.escapePanel}>
<View style={styles.escapeHeader}>
<View>
<Text style={styles.eyebrow}>Paused</Text>
<Text style={styles.escapeTitle}>Escape Menu</Text>
</View>
<ActionButton label="Close" onPress={onClose} testID="escape-close" />
</View>
<View style={styles.escapeGrid}>
<View style={styles.escapeSection}>
<Text style={styles.sectionTitle}>Run</Text>
<ActionButton
label="Save"
onPress={handleSave}
testID="escape-save"
tone="gold"
/>
<ActionButton
label="Exit Game"
onPress={handleExitGame}
testID="escape-exit"
/>
{menuMessage == null ? null : (
<Text style={styles.menuMessage} testID="escape-message">
{menuMessage}
</Text>
)}
</View>
<View style={styles.escapeSection}>
<Text style={styles.sectionTitle}>Load</Text>
{saveSlots.length === 0 ? (
<Text style={styles.mutedText} testID="escape-load-empty">
No saves yet.
</Text>
) : (
saveSlots.map(saveSlot => (
<Pressable
key={saveSlot.id}
accessibilityRole="button"
onPress={() => handleLoad(saveSlot.id)}
style={styles.loadRow}
testID={`escape-load-${saveSlot.id}`}>
<Text style={styles.loadTitle}>{saveSlot.name}</Text>
<Text style={styles.loadMeta}>Day {saveSlot.turn}</Text>
</Pressable>
))
)}
</View>
<View style={styles.escapeSection}>
<Text style={styles.sectionTitle}>Sound</Text>
<Pressable
accessibilityRole="checkbox"
accessibilityState={{ checked: audioMuted }}
onPress={toggleAudioMuted}
style={styles.muteToggle}
testID="escape-mute">
<Text style={styles.muteToggleText}>
{audioMuted ? '✓ Muted' : 'Mute'}
</Text>
</Pressable>
{volumeSettings.map(setting => (
<EscapeVolumeControl
key={setting.key}
label={setting.label}
onChange={nextVolume => setVolume(setting.key, nextVolume)}
settingKey={setting.key}
value={volumes[setting.key]}
/>
))}
</View>
</View>
</View>
</View>
);
}
type EscapeVolumeControlProps = Readonly<{
label: string;
onChange: (volume: number) => void;
settingKey: VolumeSettingKey;
value: number;
}>;
function EscapeVolumeControl({
label,
onChange,
settingKey,
value,
}: EscapeVolumeControlProps) {
return (
<View style={styles.escapeVolumeRow}>
<Text style={styles.escapeVolumeLabel}>{label}</Text>
<ActionButton
label="-"
onPress={() => onChange(clampVolume(value - 10))}
testID={`escape-volume-${settingKey}-down`}
/>
<Text style={styles.escapeVolumeValue} testID={`escape-volume-${settingKey}`}>
{value}
</Text>
<ActionButton
label="+"
onPress={() => onChange(clampVolume(value + 10))}
testID={`escape-volume-${settingKey}-up`}
/>
</View>
);
}
type ActionButtonProps = Readonly<{
disabled?: boolean;
label: string;
onPress: () => void;
testID: string;
tone?: 'default' | 'gold';
}>;
function ActionButton({
disabled = false,
label,
onPress,
testID,
tone = 'default',
}: ActionButtonProps) {
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.actionButton,
tone === 'gold' ? styles.actionButtonGold : null,
disabled ? styles.actionButtonDisabled : null,
pressed ? styles.actionButtonPressed : null,
]}
testID={testID}>
<Text
style={[
styles.actionButtonText,
tone === 'gold' ? styles.actionButtonGoldText : null,
disabled ? styles.actionButtonDisabledText : null,
]}>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
actionButton: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 6,
borderWidth: 1,
justifyContent: 'center',
minHeight: 32,
paddingHorizontal: 10,
},
actionButtonDisabled: {
backgroundColor: '#1a1c1f',
borderColor: '#2b2d30',
opacity: 0.5,
},
actionButtonDisabledText: {
color: '#80796c',
},
actionButtonGold: {
backgroundColor: '#3a2918',
borderColor: '#d6b25e',
},
actionButtonGoldText: {
color: '#f5d98d',
},
actionButtonPressed: {
backgroundColor: '#33302a',
},
actionButtonText: {
...displayTextStyle,
color: '#f6f1e7',
fontSize: 12,
letterSpacing: 0,
},
container: {
backgroundColor: '#111315',
flex: 1,
},
controls: {
flexDirection: 'row',
gap: 10,
justifyContent: 'flex-end',
marginTop: 'auto',
},
copyText: {
...defaultTextStyle,
color: '#c9c1b3',
fontSize: 12,
lineHeight: 17,
},
dayCounter: {
...displayTextStyle,
color: '#f5d98d',
fontSize: 18,
letterSpacing: 0,
textAlign: 'right',
},
escapeBackdrop: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
backgroundColor: 'rgba(5, 6, 7, 0.72)',
justifyContent: 'center',
padding: 18,
},
escapeGrid: {
flexDirection: 'row',
gap: 10,
},
escapeHeader: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
},
escapePanel: {
backgroundColor: '#131518',
borderColor: '#d6b25e',
borderRadius: 8,
borderWidth: 1,
gap: 12,
maxWidth: 760,
padding: 14,
width: '92%',
},
escapeSection: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 6,
borderWidth: 1,
flex: 1,
gap: 8,
padding: 10,
},
escapeTitle: {
...displayTextStyle,
color: '#f6f1e7',
fontSize: 22,
letterSpacing: 0,
},
escapeVolumeLabel: {
...defaultTextStyle,
color: '#f6f1e7',
flex: 1,
fontSize: 11,
fontWeight: '700',
},
escapeVolumeRow: {
alignItems: 'center',
flexDirection: 'row',
gap: 5,
},
escapeVolumeValue: {
...defaultTextStyle,
color: '#d6b25e',
fontSize: 11,
fontWeight: '800',
textAlign: 'center',
width: 26,
},
eyebrow: {
...defaultTextStyle,
color: '#d6b25e',
fontSize: 10,
fontWeight: '800',
letterSpacing: 0,
textTransform: 'uppercase',
},
header: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 10,
},
headerStats: {
alignItems: 'flex-end',
gap: 5,
},
loadMeta: {
...defaultTextStyle,
color: '#d6b25e',
fontSize: 10,
},
loadRow: {
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 5,
borderWidth: 1,
gap: 2,
padding: 7,
},
loadTitle: {
...displayTextStyle,
color: '#f6f1e7',
fontSize: 12,
letterSpacing: 0,
},
main: {
flex: 1,
flexDirection: 'row',
gap: 12,
},
menuMessage: {
...defaultTextStyle,
color: '#f5d98d',
fontSize: 11,
lineHeight: 15,
},
mutedText: {
...defaultTextStyle,
color: '#80796c',
fontSize: 12,
lineHeight: 16,
},
muteToggle: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 5,
borderWidth: 1,
minHeight: 30,
justifyContent: 'center',
},
muteToggleText: {
...displayTextStyle,
color: '#f6f1e7',
fontSize: 12,
letterSpacing: 0,
},
noRunPanel: {
alignSelf: 'center',
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
gap: 10,
margin: 'auto',
padding: 18,
width: 340,
},
phaseColumns: {
flex: 1,
flexDirection: 'row',
gap: 10,
marginTop: 10,
},
phaseCounter: {
...defaultTextStyle,
color: '#c9c1b3',
fontSize: 11,
lineHeight: 14,
textAlign: 'right',
textTransform: 'uppercase',
},
phasePanel: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
flex: 1,
gap: 4,
padding: 12,
},
phaseRail: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
gap: 7,
padding: 9,
},
phaseRailItem: {
alignItems: 'center',
borderColor: '#2d2f32',
borderRadius: 6,
borderWidth: 1,
flexDirection: 'row',
gap: 7,
minHeight: 32,
paddingHorizontal: 8,
},
phaseRailItemActive: {
backgroundColor: '#2a261d',
borderColor: '#d6b25e',
},
phaseRailLabel: {
...defaultTextStyle,
color: '#f6f1e7',
fontSize: 11,
fontWeight: '700',
},
phaseRailNumber: {
...displayTextStyle,
color: '#d6b25e',
fontSize: 13,
letterSpacing: 0,
width: 16,
},
placeholderColumn: {
backgroundColor: '#131518',
borderColor: '#2d2f32',
borderRadius: 6,
borderWidth: 1,
flex: 1,
gap: 7,
padding: 10,
},
placeholderItem: {
...defaultTextStyle,
color: '#c9c1b3',
fontSize: 12,
lineHeight: 16,
},
placeholderTitle: {
...defaultTextStyle,
color: '#d6b25e',
fontSize: 10,
fontWeight: '800',
textTransform: 'uppercase',
},
resourceGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
resourceHud: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
gap: 8,
padding: 9,
},
resourceLabel: {
...defaultTextStyle,
color: '#80796c',
fontSize: 10,
lineHeight: 13,
},
resourceNumber: {
...displayTextStyle,
color: '#f5d98d',
fontSize: 16,
letterSpacing: 0,
},
resourceValue: {
backgroundColor: '#131518',
borderColor: '#2d2f32',
borderRadius: 5,
borderWidth: 1,
padding: 7,
width: 76,
},
sectionTitle: {
...defaultTextStyle,
color: '#d6b25e',
fontSize: 11,
fontWeight: '800',
letterSpacing: 0,
textTransform: 'uppercase',
},
sidebar: {
gap: 10,
width: 180,
},
title: {
...displayTextStyle,
color: '#f6f1e7',
fontSize: 28,
letterSpacing: 0,
lineHeight: 32,
},
});
+17 -2
View File
@@ -10,7 +10,11 @@ import {
routeDefinitions,
type RouteName,
} from '../navigation/routes';
import { useGameSettingsStore, useToolkitSocketStore } from '../state';
import {
useGameSettingsStore,
useGameplayStore,
useToolkitSocketStore,
} from '../state';
import { defaultTextStyle, displayTextStyle } from '../styles/typography';
type PrimaryMenuRouteName = (typeof primaryMenuRoutes)[number];
@@ -31,11 +35,22 @@ const menuIcons: Record<PrimaryMenuRouteName, string> = {
export function HomeScreen({ navigate }: HomeScreenProps) {
const safeAreaInsets = useSafeAreaInsets();
const resolutionId = useGameSettingsStore(state => state.resolutionId);
const startNewGame = useGameplayStore(state => state.startNewGame);
const toolkitSocketStatus = useToolkitSocketStore(state => state.status);
const visibleMenuRoutes = primaryMenuRoutes.filter(
routeName => routeName !== 'toolkit' || resolutionId === 'browser',
);
function handleMenuPress(routeName: PrimaryMenuRouteName) {
if (routeName === 'newGame') {
startNewGame();
navigate('gameplay');
return;
}
navigate(routeName);
}
return (
<ImageBackground
imageStyle={styles.backgroundImage}
@@ -75,7 +90,7 @@ export function HomeScreen({ navigate }: HomeScreenProps) {
}
icon={menuIcons[routeName]}
label={routeDefinitions[routeName].label}
onPress={() => navigate(routeName)}
onPress={() => handleMenuPress(routeName)}
testID={`menu-${routeName}`}
tone={routeName === 'toolkit' ? 'toolkit' : 'default'}
/>
+7 -2
View File
@@ -11,7 +11,13 @@ import { defaultTextStyle } from '../styles/typography';
type PageRouteName = Exclude<
RouteName,
'home' | 'policies' | 'settings' | 'toolkit' | ToolkitRouteName
| 'gameplay'
| 'home'
| 'policies'
| 'saves'
| 'settings'
| 'toolkit'
| ToolkitRouteName
>;
type PageScreenProps = Readonly<{
@@ -26,7 +32,6 @@ const pageDescriptions: Record<PageRouteName, string> = {
policiesLegal: 'Legal policy content will live here.',
policiesPrivacy: 'Privacy policy content will live here.',
policiesTerms: 'Terms and conditions content will live here.',
saves: 'Saved games will be listed here.',
};
export function PageScreen({ goHome, routeName }: PageScreenProps) {
+172
View File
@@ -0,0 +1,172 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { BackHomeButton } from '../components/BackHomeButton';
import { ScreenLayout } from '../components/ScreenLayout';
import { gameplayPhaseDefinitions } from '../gameplay/phases';
import type { RouteName } from '../navigation/routes';
import { useGameplayStore, useSaveGameStore } from '../state';
import { defaultTextStyle, displayTextStyle } from '../styles/typography';
type SavesScreenProps = Readonly<{
goHome: () => void;
navigate: (routeName: RouteName) => void;
}>;
function formatSaveDate(savedAt: string) {
return new Date(savedAt).toLocaleString();
}
export function SavesScreen({ goHome, navigate }: SavesScreenProps) {
const deleteSave = useSaveGameStore(state => state.deleteSave);
const loadGame = useGameplayStore(state => state.loadGame);
const saveSlots = useSaveGameStore(state => state.saveSlots);
function handleLoad(saveId: string) {
if (loadGame(saveId)) {
navigate('gameplay');
}
}
return (
<ScreenLayout title="Saves">
<View style={styles.panel} testID="saves-panel">
{saveSlots.length === 0 ? (
<Text style={styles.emptyText} testID="saves-empty">
No saved games yet. Start a new game, open the menu, and save the
current run.
</Text>
) : (
saveSlots.map(saveSlot => (
<View
key={saveSlot.id}
style={styles.saveCard}
testID={`save-slot-${saveSlot.id}`}>
<View style={styles.saveCopy}>
<Text style={styles.saveTitle}>{saveSlot.name}</Text>
<Text style={styles.saveMeta}>
Day {saveSlot.turn} ·{' '}
{gameplayPhaseDefinitions[saveSlot.phaseId].label}
</Text>
<Text style={styles.saveDate}>
Saved {formatSaveDate(saveSlot.savedAt)}
</Text>
</View>
<View style={styles.saveActions}>
<SmallButton
label="Load"
onPress={() => handleLoad(saveSlot.id)}
testID={`load-save-${saveSlot.id}`}
/>
<SmallButton
label="Delete"
onPress={() => deleteSave(saveSlot.id)}
testID={`delete-save-${saveSlot.id}`}
/>
</View>
</View>
))
)}
</View>
<View style={styles.homeAction}>
<BackHomeButton onPress={goHome} />
</View>
</ScreenLayout>
);
}
type SmallButtonProps = Readonly<{
label: string;
onPress: () => void;
testID: string;
}>;
function SmallButton({ label, onPress, testID }: SmallButtonProps) {
return (
<Pressable
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.smallButton,
pressed ? styles.smallButtonPressed : null,
]}
testID={testID}>
<Text style={styles.smallButtonLabel}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
emptyText: {
...defaultTextStyle,
color: '#c9c1b3',
fontSize: 13,
lineHeight: 18,
textAlign: 'center',
},
homeAction: {
marginTop: 10,
},
panel: {
backgroundColor: '#191b1f',
borderColor: '#37342d',
borderRadius: 8,
borderWidth: 1,
gap: 8,
padding: 10,
},
saveActions: {
gap: 6,
width: 76,
},
saveCard: {
backgroundColor: '#131518',
borderColor: '#4d4432',
borderRadius: 6,
borderWidth: 1,
flexDirection: 'row',
gap: 8,
padding: 9,
},
saveCopy: {
flex: 1,
minWidth: 0,
},
saveDate: {
...defaultTextStyle,
color: '#80796c',
fontSize: 10,
lineHeight: 13,
},
saveMeta: {
...defaultTextStyle,
color: '#d6b25e',
fontSize: 11,
fontWeight: '700',
lineHeight: 14,
},
saveTitle: {
...displayTextStyle,
color: '#f6f1e7',
fontSize: 14,
letterSpacing: 0,
},
smallButton: {
alignItems: 'center',
backgroundColor: '#24272b',
borderColor: '#4d4432',
borderRadius: 5,
borderWidth: 1,
minHeight: 28,
justifyContent: 'center',
paddingHorizontal: 8,
},
smallButtonLabel: {
...displayTextStyle,
color: '#f6f1e7',
fontSize: 11,
letterSpacing: 0,
},
smallButtonPressed: {
backgroundColor: '#33302a',
},
});
+159
View File
@@ -0,0 +1,159 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import {
gameplayPhaseDefinitions,
gameplayPhaseOrder,
getNextGameplayPhaseId,
getPreviousGameplayPhaseId,
type GameplayPhaseId,
} from '../gameplay/phases';
import type { GameplayPhaseTextState, GameplayRun, SaveSlot } from '../gameplay/types';
import { createPersistOptions } from './createPersistOptions';
import { useSaveGameStore } from './saveGameStore';
type GameplayState = {
activeRun: GameplayRun | null;
exitToHome: () => void;
goToNextPhase: () => void;
goToPreviousPhase: () => void;
loadGame: (saveId: string) => boolean;
saveCurrentRun: () => SaveSlot | null;
startNewGame: () => GameplayRun;
};
type PersistedGameplayState = {
activeRun: GameplayRun | null;
};
function createDefaultPhaseTextState(
field: 'inputLabels' | 'stateLabels',
): GameplayPhaseTextState {
return gameplayPhaseOrder.reduce<GameplayPhaseTextState>(
(phaseState, phaseId) => ({
...phaseState,
[phaseId]: [...gameplayPhaseDefinitions[phaseId][field]],
}),
{
dailySeed: [],
dailySummary: [],
throneRoom: [],
worldMap: [],
},
);
}
function createGameId() {
return `run_${Date.now().toString(36)}`;
}
function createNewGameplayRun(): GameplayRun {
const now = new Date().toISOString();
return {
createdAt: now,
currentPhaseId: 'dailySeed',
gameId: createGameId(),
phaseInputs: createDefaultPhaseTextState('inputLabels'),
phaseStates: createDefaultPhaseTextState('stateLabels'),
runName: 'Ossuary Dominion Run',
turn: 1,
updatedAt: now,
};
}
function updateRunPhase(run: GameplayRun, currentPhaseId: GameplayPhaseId) {
return {
...run,
currentPhaseId,
updatedAt: new Date().toISOString(),
};
}
export const useGameplayStore = create<GameplayState>()(
persist(
(set, get) => ({
activeRun: null,
exitToHome: () => undefined,
goToNextPhase: () => {
const activeRun = get().activeRun;
if (activeRun == null) {
return;
}
if (activeRun.currentPhaseId === 'dailySummary') {
set({
activeRun: {
...activeRun,
currentPhaseId: 'dailySeed',
turn: activeRun.turn + 1,
updatedAt: new Date().toISOString(),
},
});
return;
}
set({
activeRun: updateRunPhase(
activeRun,
getNextGameplayPhaseId(activeRun.currentPhaseId),
),
});
},
goToPreviousPhase: () => {
const activeRun = get().activeRun;
if (activeRun == null || activeRun.currentPhaseId === 'dailySeed') {
return;
}
set({
activeRun: updateRunPhase(
activeRun,
getPreviousGameplayPhaseId(activeRun.currentPhaseId),
),
});
},
loadGame: (saveId: string) => {
const saveRun = useSaveGameStore.getState().loadSave(saveId);
if (saveRun == null) {
return false;
}
set({
activeRun: {
...saveRun,
updatedAt: new Date().toISOString(),
},
});
return true;
},
saveCurrentRun: () => {
const activeRun = get().activeRun;
if (activeRun == null) {
return null;
}
return useSaveGameStore.getState().upsertSaveFromRun(activeRun);
},
startNewGame: () => {
const activeRun = createNewGameplayRun();
set({ activeRun });
return activeRun;
},
}),
createPersistOptions<GameplayState, PersistedGameplayState>({
name: 'blb-gameplay',
partialize: state => ({
activeRun: state.activeRun,
}),
version: 1,
}),
),
);
+2
View File
@@ -2,9 +2,11 @@ export {
createPersistOptions,
type PersistedStateOptions,
} from './createPersistOptions';
export { useGameplayStore } from './gameplayStore';
export { useGameSettingsStore } from './gameSettingsStore';
export { createMemoryStateStorage } from './memoryStateStorage';
export { useNavigationStore } from './navigationStore';
export { useSaveGameStore } from './saveGameStore';
export {
useToolkitContentStore,
type ToolkitContentStatus,
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { GameplayRun, SaveSlot } from '../gameplay/types';
import { createPersistOptions } from './createPersistOptions';
type SaveGameState = {
deleteSave: (saveId: string) => void;
loadSave: (saveId: string) => GameplayRun | null;
saveSlots: SaveSlot[];
upsertSaveFromRun: (run: GameplayRun) => SaveSlot;
};
type PersistedSaveGameState = {
saveSlots: SaveSlot[];
};
function createSaveSlot(run: GameplayRun): SaveSlot {
const savedAt = new Date().toISOString();
return {
id: run.gameId,
name: run.runName,
phaseId: run.currentPhaseId,
run: {
...run,
updatedAt: savedAt,
},
savedAt,
turn: run.turn,
};
}
export const useSaveGameStore = create<SaveGameState>()(
persist(
(set, get) => ({
deleteSave: (saveId: string) => {
set(state => ({
saveSlots: state.saveSlots.filter(saveSlot => saveSlot.id !== saveId),
}));
},
loadSave: (saveId: string) =>
get().saveSlots.find(saveSlot => saveSlot.id === saveId)?.run ?? null,
saveSlots: [],
upsertSaveFromRun: (run: GameplayRun) => {
const saveSlot = createSaveSlot(run);
set(state => ({
saveSlots: [
saveSlot,
...state.saveSlots.filter(existingSlot => existingSlot.id !== run.gameId),
],
}));
return saveSlot;
},
}),
createPersistOptions<SaveGameState, PersistedSaveGameState>({
name: 'blb-save-games',
partialize: state => ({
saveSlots: state.saveSlots,
}),
version: 1,
}),
),
);