sandhill-road
Version:
A narrative-driven startup simulation game where you guide a founder from garage to exit
387 lines (326 loc) • 11.6 kB
text/typescript
// Narrative Engine for Sandhill Road
// Handles events, choices, and outcomes
import {
GameState,
getGameState,
updateGameState,
advanceWeek,
GameStage
} from './gameState';
import { calculateRevenueFromUsers } from '../utils/revenueUtils';
export type EventChoice = {
id: string;
text: string;
requires?: Partial<Record<string, number>>;
result: Partial<Record<string, number>>;
resultText: string;
nextEvent?: string;
};
export type GameEvent = {
id: string;
title: string;
description: string;
image?: string;
choices: EventChoice[];
requirements?: Partial<Record<string, number>>;
stage: GameStage | GameStage[];
repeatable?: boolean;
weight?: number; // Higher weight = more likely to be selected
exclusiveGroup?: string;
};
// Event database
let eventsDatabase: GameEvent[] = [];
// Current event being shown to the player
let currentEvent: GameEvent | null = null;
// Load all events from JSON
export const loadEvents = async (): Promise<void> => {
try {
// In a browser environment, we'd fetch from a URL
// In Node, we could use fs module
let eventsData;
let moreEventsData;
if (typeof window !== 'undefined') {
// Browser environment
const response = await fetch('/data/events.json');
eventsData = await response.json();
const moreResponse = await fetch('/data/more-events.json');
moreEventsData = await moreResponse.json();
} else {
// Node environment - using dynamic import instead of require
const fs = await import('fs/promises');
const path = await import('path');
const filePath = path.join(process.cwd(), 'src/data/events.json');
const fileData = await fs.readFile(filePath, 'utf-8');
eventsData = JSON.parse(fileData);
const moreFilePath = path.join(process.cwd(), 'src/data/more-events.json');
const moreFileData = await fs.readFile(moreFilePath, 'utf-8');
moreEventsData = JSON.parse(moreFileData);
}
// Combine both event arrays
eventsDatabase = [...eventsData, ...moreEventsData];
return;
} catch (error) {
console.error("Failed to load events:", error);
throw new Error("Failed to load game events");
}
};
// Filter events based on current game state and requirements
export const getAvailableEvents = (): GameEvent[] => {
const state = getGameState();
const currentStage = state.stageProgress.currentStage;
const completedEvents = state.stageProgress.completedEvents;
const completedExclusiveGroups = state.stageProgress.completedExclusiveGroups;
return eventsDatabase.filter(event => {
// Check if event is for the current stage
const validStage = Array.isArray(event.stage)
? event.stage.includes(currentStage)
: event.stage === currentStage;
if (!validStage) return false;
// Check if event was already completed and is not repeatable
if (!event.repeatable && completedEvents.includes(event.id)) {
return false;
}
// Check if event's exclusive group has already been completed
if (event.exclusiveGroup && completedExclusiveGroups.includes(event.exclusiveGroup)) {
return false;
}
// Check requirements
if (event.requirements) {
for (const [stat, value] of Object.entries(event.requirements)) {
// Check if the requirement is met
const statPath = stat.split('.');
let currentValue: any = state;
for (const path of statPath) {
if (currentValue && currentValue[path] !== undefined) {
currentValue = currentValue[path];
} else {
return false; // Required stat not found
}
}
// Compare the value based on type
if (typeof currentValue === 'number' && typeof value === 'number') {
if (currentValue < value) {
return false; // Requirement not met
}
} else if (typeof currentValue === 'boolean' && typeof value === 'boolean') {
if (currentValue !== value) {
return false; // Requirement not met
}
}
}
}
return true;
});
};
// Select a random event from available ones, weighted by their weights
export const selectRandomEvent = (): GameEvent | null => {
const availableEvents = getAvailableEvents();
if (availableEvents.length === 0) {
return null;
}
// Calculate total weight
const totalWeight = availableEvents.reduce((sum, event) =>
sum + (event.weight || 1), 0);
// Select random event based on weight
let randomValue = Math.random() * totalWeight;
for (const event of availableEvents) {
const weight = event.weight || 1;
if (randomValue <= weight) {
return event;
}
randomValue -= weight;
}
// Fallback in case of rounding errors
return availableEvents[0];
};
// Get the current event
export const getCurrentEvent = (): GameEvent | null => {
return currentEvent;
};
// Start a new event
export const startEvent = (eventId?: string): GameEvent | null => {
let event: GameEvent | null = null;
if (eventId) {
// Find specific event
event = eventsDatabase.find(e => e.id === eventId) || null;
} else {
// Pick a random event
event = selectRandomEvent();
}
if (!event) {
return null;
}
currentEvent = event;
return event;
};
// Make a choice and handle the outcome
export const makeChoice = (choiceId: string): {
success: boolean;
resultText: string;
nextEvent?: string;
} => {
if (!currentEvent) {
return {
success: false,
resultText: "No active event."
};
}
const choice = currentEvent.choices.find(c => c.id === choiceId);
if (!choice) {
return {
success: false,
resultText: "Invalid choice."
};
}
// Check if the choice has requirements
if (choice.requires) {
const state = getGameState();
for (const [stat, value] of Object.entries(choice.requires)) {
// Check if the requirement is met
const statPath = stat.split('.');
let currentValue: any = state;
for (const path of statPath) {
if (currentValue && currentValue[path] !== undefined) {
currentValue = currentValue[path];
} else {
return {
success: false,
resultText: "You don't meet the requirements for this choice."
};
}
}
// Compare the value based on type
if (typeof currentValue === 'number' && typeof value === 'number') {
if (currentValue < value) {
return {
success: false,
resultText: `Your ${stat} (${currentValue}) is too low. Need at least ${value}.`
};
}
} else if (typeof currentValue === 'boolean' && typeof value === 'boolean') {
if (currentValue !== value) {
return {
success: false,
resultText: `Requirement not met: ${stat} must be ${value}.`
};
}
}
}
}
// Apply the results of the choice
updateGameState(state => {
// Create a copy to modify
let newState = { ...state };
// Mark the event as completed
if (currentEvent) {
newState.stageProgress.completedEvents = [
...newState.stageProgress.completedEvents,
currentEvent.id
];
if (currentEvent.exclusiveGroup) {
newState.stageProgress.completedExclusiveGroups = [
...newState.stageProgress.completedExclusiveGroups,
currentEvent.exclusiveGroup
];
}
}
// Apply the choice results
for (const [stat, value] of Object.entries(choice.result)) {
// Handle special cases
if (stat === 'weeksLost' && typeof value === 'number') {
// Add multiple weeks to the counter
for (let i = 0; i < value; i++) {
advanceWeek();
}
continue;
}
if (stat === 'gameOver' && typeof value === 'boolean' && value === true) {
newState.gameOver = true;
continue;
}
if (stat === 'gameOverReason' && typeof value === 'string') {
newState.gameOverReason = value;
continue;
}
// Handle nested stats (including companyFlags)
const statPath = stat.split('.');
if (statPath.length === 2) {
const [category, attribute] = statPath;
if (newState[category as keyof GameState] &&
typeof newState[category as keyof GameState] === 'object') {
const categoryObj = newState[category as keyof GameState] as Record<string, any>;
if (categoryObj && categoryObj[attribute] !== undefined) {
if (typeof categoryObj[attribute] === 'number' && typeof value === 'number') {
if (category === 'companyStats' && attribute === 'users' && value > 0) {
const currentUsers = categoryObj[attribute];
const newUsers = Math.max(0, currentUsers + value);
categoryObj[attribute] = newUsers;
const newRevenue = calculateRevenueFromUsers(newUsers);
categoryObj['revenue'] = newRevenue;
} else {
categoryObj[attribute] = Math.max(0, categoryObj[attribute] + value);
}
} else {
// For booleans, strings, and direct number assignments
categoryObj[attribute] = value;
}
}
}
}
}
return newState;
});
// Advance time by one week unless the choice specified a different time span
if (!choice.result.weeksLost) {
advanceWeek();
}
// Return the result and the next event if specified
return {
success: true,
resultText: choice.resultText,
nextEvent: choice.nextEvent
};
};
// Clear the current event
export const clearCurrentEvent = (): void => {
currentEvent = null;
};
// Helper functions for the narrative engine
export const getStageDescription = (stage: GameStage): string => {
const descriptions: Record<GameStage, string> = {
[GameStage.Garage]: "You're working from your garage, trying to build an MVP.",
[GameStage.DemoDay]: "It's time to present your startup at a demo day.",
[GameStage.Fundraising]: "You're meeting with investors to raise your seed round.",
[GameStage.PMF]: "You're iterating on your product to find product-market fit.",
[GameStage.Scaling]: "Your product is gaining traction, and it's time to scale.",
[GameStage.Crisis]: "Your startup is facing a major crisis.",
[GameStage.Exit]: "You're exploring exit opportunities for your startup."
};
return descriptions[stage] || "Unknown stage";
};
// Progress to the next stage
export const progressToNextStage = (): GameStage => {
const stageOrder = [
GameStage.Garage,
GameStage.DemoDay,
GameStage.Fundraising,
GameStage.PMF,
GameStage.Scaling,
GameStage.Crisis,
GameStage.Exit
];
const state = getGameState();
const currentStageIndex = stageOrder.indexOf(state.stageProgress.currentStage);
if (currentStageIndex < 0 || currentStageIndex >= stageOrder.length - 1) {
return state.stageProgress.currentStage;
}
const nextStage = stageOrder[currentStageIndex + 1];
updateGameState(state => ({
...state,
stageProgress: {
...state.stageProgress,
currentStage: nextStage
}
}));
return nextStage;
};