feat(gameplay): scaffold phase flow and saves
Lint and Build Checks / Lint, Test, and Build (push) Failing after 560h41m34s
Lint and Build Checks / Lint, Test, and Build (push) Failing after 560h41m34s
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ describe('routeDefinitions', () => {
|
||||
'policiesTerms',
|
||||
'policiesCookies',
|
||||
'credits',
|
||||
'gameplay',
|
||||
'toolkit',
|
||||
'toolkitOpening',
|
||||
'toolkitPetitioners',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export function useEscapeKey(_onEscape: () => void) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user