From 13aa686fa8dedfd0f56fe2402a02e2dde316188d Mon Sep 17 00:00:00 2001 From: Jacob Mathison Date: Tue, 16 Jun 2026 10:56:06 -0500 Subject: [PATCH] feat(gameplay): scaffold phase flow and saves --- __tests__/gameplayStore.test.ts | 133 ++++++ __tests__/navigationRoutes.test.ts | 1 + __tests__/screenScaffold.test.tsx | 150 +++++- src/gameplay/phases.ts | 93 ++++ src/gameplay/types.ts | 23 + src/hooks/useEscapeKey.ts | 3 + src/hooks/useEscapeKey.web.ts | 37 ++ src/navigation/AppNavigator.tsx | 8 + src/navigation/routes.ts | 5 + src/screens/GameplayScreen.tsx | 741 +++++++++++++++++++++++++++++ src/screens/HomeScreen.tsx | 19 +- src/screens/PageScreen.tsx | 9 +- src/screens/SavesScreen.tsx | 172 +++++++ src/state/gameplayStore.ts | 159 +++++++ src/state/index.ts | 2 + src/state/saveGameStore.ts | 66 +++ 16 files changed, 1616 insertions(+), 5 deletions(-) create mode 100644 __tests__/gameplayStore.test.ts create mode 100644 src/gameplay/phases.ts create mode 100644 src/gameplay/types.ts create mode 100644 src/hooks/useEscapeKey.ts create mode 100644 src/hooks/useEscapeKey.web.ts create mode 100644 src/screens/GameplayScreen.tsx create mode 100644 src/screens/SavesScreen.tsx create mode 100644 src/state/gameplayStore.ts create mode 100644 src/state/saveGameStore.ts diff --git a/__tests__/gameplayStore.test.ts b/__tests__/gameplayStore.test.ts new file mode 100644 index 0000000..0e56213 --- /dev/null +++ b/__tests__/gameplayStore.test.ts @@ -0,0 +1,133 @@ +jest.mock('@react-native-async-storage/async-storage', () => { + const storage = new Map(); + + 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); + }); +}); diff --git a/__tests__/navigationRoutes.test.ts b/__tests__/navigationRoutes.test.ts index 4650861..e1baf19 100644 --- a/__tests__/navigationRoutes.test.ts +++ b/__tests__/navigationRoutes.test.ts @@ -19,6 +19,7 @@ describe('routeDefinitions', () => { 'policiesTerms', 'policiesCookies', 'credits', + 'gameplay', 'toolkit', 'toolkitOpening', 'toolkitPetitioners', diff --git a/__tests__/screenScaffold.test.tsx b/__tests__/screenScaffold.test.tsx index 0a88455..628b4ef 100644 --- a/__tests__/screenScaffold.test.tsx +++ b/__tests__/screenScaffold.test.tsx @@ -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(); + + 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(); + 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(); + + 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( + , + ); + 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( + , + ); + 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( , @@ -473,8 +619,10 @@ describe('screen scaffold', () => { it('does not use scroll containers for fixed landscape screens', () => { const screens = [ , + , , , + , , { const renderer = render(); for (const routeName of primaryMenuRoutes) { - if (routeName === 'toolkit') { + if (routeName === 'newGame' || routeName === 'toolkit') { continue; } diff --git a/src/gameplay/phases.ts b/src/gameplay/phases.ts new file mode 100644 index 0000000..3034f63 --- /dev/null +++ b/src/gameplay/phases.ts @@ -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)]; +} diff --git a/src/gameplay/types.ts b/src/gameplay/types.ts new file mode 100644 index 0000000..1b3cbe3 --- /dev/null +++ b/src/gameplay/types.ts @@ -0,0 +1,23 @@ +import type { GameplayPhaseId } from './phases'; + +export type GameplayPhaseTextState = Record; + +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; +}; diff --git a/src/hooks/useEscapeKey.ts b/src/hooks/useEscapeKey.ts new file mode 100644 index 0000000..ee03031 --- /dev/null +++ b/src/hooks/useEscapeKey.ts @@ -0,0 +1,3 @@ +export function useEscapeKey(_onEscape: () => void) { + return undefined; +} diff --git a/src/hooks/useEscapeKey.web.ts b/src/hooks/useEscapeKey.web.ts new file mode 100644 index 0000000..c775938 --- /dev/null +++ b/src/hooks/useEscapeKey.web.ts @@ -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]); +} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 1c8b07a..c59541c 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -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 ; } + if (routeName === 'gameplay') { + return ; + } if (routeName === 'policies') { return ; } + if (routeName === 'saves') { + return ; + } if (routeName === 'settings') { return ; } diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts index 02de5b0..4855d67 100644 --- a/src/navigation/routes.ts +++ b/src/navigation/routes.ts @@ -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', diff --git a/src/screens/GameplayScreen.tsx b/src/screens/GameplayScreen.tsx new file mode 100644 index 0000000..6a06407 --- /dev/null +++ b/src/screens/GameplayScreen.tsx @@ -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 ( + + + Bone Lord Bob + No Active Run + + Start a new run to enter the gameplay scaffold. + + + + + ); + } + + 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 ( + + + + Milkbone Court + {phase.title} + + + + Day {activeRun.turn} + + + Phase {phaseIndex + 1} of {gameplayPhaseOrder.length} + + + + + + + + + + {gameplayPhaseOrder.map((phaseId, index) => ( + + {index + 1} + + {gameplayPhaseDefinitions[phaseId].label} + + + ))} + + + + + {phase.label} + {phase.purpose} + + + + + + + + + + + + setIsEscapeMenuOpen(false)} + /> + + ); +} + +function ResourceHud() { + return ( + + Resource HUD + + + + + + + + ); +} + +type ResourceValueProps = Readonly<{ + label: string; + value: string; +}>; + +function ResourceValue({ label, value }: ResourceValueProps) { + return ( + + {label} + {value} + + ); +} + +type PlaceholderColumnProps = Readonly<{ + items: string[]; + label: string; + testID: string; +}>; + +function PlaceholderColumn({ items, label, testID }: PlaceholderColumnProps) { + return ( + + {label} + {items.map(item => ( + + {item} + + ))} + + ); +} + +type EscapeMenuProps = Readonly<{ + isVisible: boolean; + navigate: (routeName: RouteName) => void; + onClose: () => void; +}>; + +function EscapeMenu({ isVisible, navigate, onClose }: EscapeMenuProps) { + const [menuMessage, setMenuMessage] = useState(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 ( + + + + + Paused + Escape Menu + + + + + + + Run + + + {menuMessage == null ? null : ( + + {menuMessage} + + )} + + + + Load + {saveSlots.length === 0 ? ( + + No saves yet. + + ) : ( + saveSlots.map(saveSlot => ( + handleLoad(saveSlot.id)} + style={styles.loadRow} + testID={`escape-load-${saveSlot.id}`}> + {saveSlot.name} + Day {saveSlot.turn} + + )) + )} + + + + Sound + + + {audioMuted ? '✓ Muted' : 'Mute'} + + + {volumeSettings.map(setting => ( + setVolume(setting.key, nextVolume)} + settingKey={setting.key} + value={volumes[setting.key]} + /> + ))} + + + + + ); +} + +type EscapeVolumeControlProps = Readonly<{ + label: string; + onChange: (volume: number) => void; + settingKey: VolumeSettingKey; + value: number; +}>; + +function EscapeVolumeControl({ + label, + onChange, + settingKey, + value, +}: EscapeVolumeControlProps) { + return ( + + {label} + onChange(clampVolume(value - 10))} + testID={`escape-volume-${settingKey}-down`} + /> + + {value} + + onChange(clampVolume(value + 10))} + testID={`escape-volume-${settingKey}-up`} + /> + + ); +} + +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 ( + [ + styles.actionButton, + tone === 'gold' ? styles.actionButtonGold : null, + disabled ? styles.actionButtonDisabled : null, + pressed ? styles.actionButtonPressed : null, + ]} + testID={testID}> + + {label} + + + ); +} + +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, + }, +}); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index bda074f..18e19cc 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -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 = { 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 ( navigate(routeName)} + onPress={() => handleMenuPress(routeName)} testID={`menu-${routeName}`} tone={routeName === 'toolkit' ? 'toolkit' : 'default'} /> diff --git a/src/screens/PageScreen.tsx b/src/screens/PageScreen.tsx index 367da9d..fcf9f56 100644 --- a/src/screens/PageScreen.tsx +++ b/src/screens/PageScreen.tsx @@ -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 = { 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) { diff --git a/src/screens/SavesScreen.tsx b/src/screens/SavesScreen.tsx new file mode 100644 index 0000000..e2290fd --- /dev/null +++ b/src/screens/SavesScreen.tsx @@ -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 ( + + + {saveSlots.length === 0 ? ( + + No saved games yet. Start a new game, open the menu, and save the + current run. + + ) : ( + saveSlots.map(saveSlot => ( + + + {saveSlot.name} + + Day {saveSlot.turn} ·{' '} + {gameplayPhaseDefinitions[saveSlot.phaseId].label} + + + Saved {formatSaveDate(saveSlot.savedAt)} + + + + handleLoad(saveSlot.id)} + testID={`load-save-${saveSlot.id}`} + /> + deleteSave(saveSlot.id)} + testID={`delete-save-${saveSlot.id}`} + /> + + + )) + )} + + + + + + ); +} + +type SmallButtonProps = Readonly<{ + label: string; + onPress: () => void; + testID: string; +}>; + +function SmallButton({ label, onPress, testID }: SmallButtonProps) { + return ( + [ + styles.smallButton, + pressed ? styles.smallButtonPressed : null, + ]} + testID={testID}> + {label} + + ); +} + +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', + }, +}); diff --git a/src/state/gameplayStore.ts b/src/state/gameplayStore.ts new file mode 100644 index 0000000..98ae0d9 --- /dev/null +++ b/src/state/gameplayStore.ts @@ -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( + (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()( + 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({ + name: 'blb-gameplay', + partialize: state => ({ + activeRun: state.activeRun, + }), + version: 1, + }), + ), +); diff --git a/src/state/index.ts b/src/state/index.ts index 9908483..186c176 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -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, diff --git a/src/state/saveGameStore.ts b/src/state/saveGameStore.ts new file mode 100644 index 0000000..22a8f19 --- /dev/null +++ b/src/state/saveGameStore.ts @@ -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()( + 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({ + name: 'blb-save-games', + partialize: state => ({ + saveSlots: state.saveSlots, + }), + version: 1, + }), + ), +);