Comprehensive UX polish: fix 19 friction points across all pages
CI / build-and-push (push) Successful in 33s

Addresses broken interactions (notification bell, browser dialogs),
missing feedback states (disabled buttons, pricing changes, paused
indicator), unclear affordances (research queue, model tuning, funding
requirements), and navigation gaps (hash routing, keyboard shortcuts,
clickable dashboard cards, sidebar grouping, tutorial hints).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 21:44:18 -04:00
parent d25dfe0435
commit f9f6233b69
19 changed files with 540 additions and 121 deletions
@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { AlertTriangle } from 'lucide-react';
export function ConfirmModal({ title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }: {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelRef.current?.focus();
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onCancel]);
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-sm w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-3">
{danger && <AlertTriangle size={20} className="text-danger shrink-0" />}
<h3 className="text-lg font-bold">{title}</h3>
</div>
<p className="text-sm text-surface-400 mb-6">{message}</p>
<div className="flex justify-end gap-2">
<button
ref={cancelRef}
onClick={onCancel}
className="px-4 py-2 rounded text-sm text-surface-400 hover:text-surface-200 hover:bg-surface-800"
>
Cancel
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded text-sm font-medium ${
danger
? 'bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger'
: 'bg-accent hover:bg-accent-dark text-white'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,78 @@
import { useEffect, useRef } from 'react';
import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell } from 'lucide-react';
import { useGameStore, type GameNotification } from '@/store';
import { formatDuration } from '@ai-tycoon/shared';
const ICON_MAP = {
success: { icon: CheckCircle, color: 'text-success' },
warning: { icon: AlertTriangle, color: 'text-warning' },
danger: { icon: AlertCircle, color: 'text-danger' },
info: { icon: Info, color: 'text-accent-light' },
} as const;
export function NotificationPanel({ onClose }: { onClose: () => void }) {
const notifications = useGameStore((s) => s.notifications);
const markAllRead = useGameStore((s) => s.markAllNotificationsRead);
const currentTick = useGameStore((s) => s.meta.tickCount);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
markAllRead();
}, [markAllRead]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
onClose();
}
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keyHandler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keyHandler);
};
}, [onClose]);
return (
<div
ref={panelRef}
className="absolute top-12 right-0 w-80 max-h-96 bg-surface-900 border border-surface-700 rounded-xl shadow-2xl z-50 overflow-hidden flex flex-col"
>
<div className="px-4 py-3 border-b border-surface-700 flex items-center justify-between shrink-0">
<h3 className="text-sm font-semibold">Notifications</h3>
<button onClick={onClose} className="text-surface-400 hover:text-surface-200">
<X size={14} />
</button>
</div>
<div className="overflow-y-auto flex-1">
{notifications.length === 0 ? (
<div className="p-6 text-center text-surface-500">
<Bell size={24} className="mx-auto mb-2 opacity-50" />
<p className="text-sm">No notifications yet</p>
</div>
) : (
notifications.map((n: GameNotification) => {
const { icon: Icon, color } = ICON_MAP[n.type] ?? ICON_MAP.info;
const ticksAgo = currentTick - n.tick;
return (
<div key={n.id} className="px-4 py-3 border-b border-surface-800 last:border-0 hover:bg-surface-800/50">
<div className="flex items-start gap-2">
<Icon size={14} className={`${color} mt-0.5 shrink-0`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{n.title}</div>
<div className="text-xs text-surface-400">{n.message}</div>
<div className="text-xs text-surface-600 mt-1">{formatDuration(ticksAgo)} ago</div>
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -2,6 +2,8 @@ import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar'; import { TopBar } from './TopBar';
import { ToastContainer } from '@/components/common/ToastContainer'; import { ToastContainer } from '@/components/common/ToastContainer';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { useHashRouter } from '@/hooks/useHashRouter';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { DashboardPage } from '@/pages/DashboardPage'; import { DashboardPage } from '@/pages/DashboardPage';
import { InfrastructurePage } from '@/pages/InfrastructurePage'; import { InfrastructurePage } from '@/pages/InfrastructurePage';
import { ResearchPage } from '@/pages/ResearchPage'; import { ResearchPage } from '@/pages/ResearchPage';
@@ -16,6 +18,8 @@ import { AchievementsPage } from '@/pages/AchievementsPage';
import { LeaderboardPage } from '@/pages/LeaderboardPage'; import { LeaderboardPage } from '@/pages/LeaderboardPage';
export function MainLayout() { export function MainLayout() {
useHashRouter();
useKeyboardShortcuts();
const activePage = useGameStore((s) => s.activePage); const activePage = useGameStore((s) => s.activePage);
return ( return (
+4 -1
View File
@@ -70,9 +70,11 @@ export function Sidebar() {
const isActive = activePage === page; const isActive = activePage === page;
const isNew = newPages.has(page); const isNew = newPages.has(page);
const showDivider = page === 'talent' || page === 'achievements';
return ( return (
<div key={page}>
{showDivider && <div className="border-t border-surface-700 my-1 mx-4" />}
<button <button
key={page}
onClick={() => handleNavClick(page)} onClick={() => handleNavClick(page)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive isActive
@@ -86,6 +88,7 @@ export function Sidebar() {
<span className="ml-auto text-[10px] font-bold bg-accent text-white px-1.5 py-0.5 rounded">NEW</span> <span className="ml-auto text-[10px] font-bold bg-accent text-white px-1.5 py-0.5 rounded">NEW</span>
)} )}
</button> </button>
</div>
); );
})} })}
</nav> </nav>
+15 -2
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useState } from 'react'; import { type ReactNode, useState } from 'react';
import { Pause, Play, Bell, Share2 } from 'lucide-react'; import { Pause, Play, Bell, Share2 } from 'lucide-react';
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard'; import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
import { NotificationPanel } from '@/components/common/NotificationPanel';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared'; import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared';
import type { GameSpeed } from '@ai-tycoon/shared'; import type { GameSpeed } from '@ai-tycoon/shared';
@@ -23,6 +24,7 @@ export function TopBar() {
const notifications = useGameStore((s) => s.notifications); const notifications = useGameStore((s) => s.notifications);
const unreadCount = notifications.filter(n => !n.read).length; const unreadCount = notifications.filter(n => !n.read).length;
const [showStats, setShowStats] = useState(false); const [showStats, setShowStats] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const netIncome = revenuePerTick - expensesPerTick; const netIncome = revenuePerTick - expensesPerTick;
@@ -65,11 +67,16 @@ export function TopBar() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{isPaused && (
<span className="text-xs font-semibold text-warning bg-warning/10 border border-warning/30 px-2.5 py-1 rounded-lg animate-pulse">
PAUSED
</span>
)}
<div className="flex items-center gap-1 bg-surface-800 rounded-lg p-1"> <div className="flex items-center gap-1 bg-surface-800 rounded-lg p-1">
<button <button
onClick={togglePause} onClick={togglePause}
className="p-1.5 rounded hover:bg-surface-700 transition-colors" className="p-1.5 rounded hover:bg-surface-700 transition-colors"
title={isPaused ? 'Resume' : 'Pause'} title={isPaused ? 'Resume (Space)' : 'Pause (Space)'}
> >
{isPaused ? <Play size={16} /> : <Pause size={16} />} {isPaused ? <Play size={16} /> : <Pause size={16} />}
</button> </button>
@@ -96,7 +103,11 @@ export function TopBar() {
<Share2 size={18} /> <Share2 size={18} />
</button> </button>
<button className="relative p-2 rounded hover:bg-surface-800 transition-colors"> <div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 rounded hover:bg-surface-800 transition-colors"
>
<Bell size={18} /> <Bell size={18} />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-danger text-white text-xs rounded-full w-4 h-4 flex items-center justify-center"> <span className="absolute -top-1 -right-1 bg-danger text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
@@ -104,6 +115,8 @@ export function TopBar() {
</span> </span>
)} )}
</button> </button>
{showNotifications && <NotificationPanel onClose={() => setShowNotifications(false)} />}
</div>
</div> </div>
{showStats && <CompanyStatsCard onClose={() => setShowStats(false)} />} {showStats && <CompanyStatsCard onClose={() => setShowStats(false)} />}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useGameStore, type ActivePage } from '@/store';
const VALID_PAGES = new Set<ActivePage>([
'dashboard', 'infrastructure', 'research', 'models',
'market', 'talent', 'data', 'competitors',
'finance', 'achievements', 'leaderboard', 'settings',
]);
export function useHashRouter() {
const activePage = useGameStore((s) => s.activePage);
const setActivePage = useGameStore((s) => s.setActivePage);
useEffect(() => {
const hash = window.location.hash.slice(1) as ActivePage;
if (hash && VALID_PAGES.has(hash) && hash !== activePage) {
setActivePage(hash);
}
}, []);
useEffect(() => {
const current = window.location.hash.slice(1);
if (current !== activePage) {
window.history.pushState(null, '', `#${activePage}`);
}
}, [activePage]);
useEffect(() => {
const handler = () => {
const hash = window.location.hash.slice(1) as ActivePage;
if (hash && VALID_PAGES.has(hash)) {
setActivePage(hash);
}
};
window.addEventListener('hashchange', handler);
return () => window.removeEventListener('hashchange', handler);
}, [setActivePage]);
}
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useGameStore } from '@/store';
import type { GameSpeed } from '@ai-tycoon/shared';
export function useKeyboardShortcuts() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
const store = useGameStore.getState();
switch (e.key) {
case ' ':
e.preventDefault();
store.togglePause();
break;
case '1':
store.setGameSpeed(1 as GameSpeed);
break;
case '2':
store.setGameSpeed(2 as GameSpeed);
break;
case '3':
store.setGameSpeed(5 as GameSpeed);
break;
case 'Escape':
break;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
}
+11 -4
View File
@@ -81,17 +81,24 @@ export function CompetitorsPage() {
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canAcquire(era) && rival.status === 'active' && ( {canAcquire(era) && rival.status === 'active' && (() => {
const cost = rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000;
return (
<div className="text-right">
<button <button
onClick={() => acquireCompetitor(rival.id)} onClick={() => acquireCompetitor(rival.id)}
disabled={money < rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000} disabled={money < cost}
className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed" className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed"
title={`Cost: ${formatMoney(rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000)}`}
> >
<ShoppingCart size={12} /> <ShoppingCart size={12} />
Acquire Acquire ({formatMoney(cost)})
</button> </button>
{money < cost && (
<div className="text-[10px] text-warning mt-0.5">Need {formatMoney(cost - money)} more</div>
)} )}
</div>
);
})()}
<span className={`text-xs px-2 py-1 rounded-full ${ <span className={`text-xs px-2 py-1 rounded-full ${
rival.status === 'active' ? 'bg-success/20 text-success' : rival.status === 'active' ? 'bg-success/20 text-success' :
rival.status === 'acquired' ? 'bg-blue-500/20 text-blue-400' : rival.status === 'acquired' ? 'bg-blue-500/20 text-blue-400' :
+10 -2
View File
@@ -53,6 +53,7 @@ export function DashboardPage() {
subValue={`${netIncome >= 0 ? '+' : ''}${formatMoney(netIncome)}/s`} subValue={`${netIncome >= 0 ? '+' : ''}${formatMoney(netIncome)}/s`}
trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'} trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'}
color="text-green-400" color="text-green-400"
onClick={() => useGameStore.getState().setActivePage('finance')}
/> />
<StatCard <StatCard
icon={Server} icon={Server}
@@ -60,6 +61,7 @@ export function DashboardPage() {
value={dataCenters.length.toString()} value={dataCenters.length.toString()}
subValue={`${formatNumber(totalFlops)} FLOPS`} subValue={`${formatNumber(totalFlops)} FLOPS`}
color="text-blue-400" color="text-blue-400"
onClick={() => useGameStore.getState().setActivePage('infrastructure')}
/> />
<StatCard <StatCard
icon={Brain} icon={Brain}
@@ -67,6 +69,7 @@ export function DashboardPage() {
value={trainedModels.length.toString()} value={trainedModels.length.toString()}
subValue={activeTraining ? `Training: ${Math.floor((activeTraining.progressTicks / activeTraining.totalTicks) * 100)}%` : 'Idle'} subValue={activeTraining ? `Training: ${Math.floor((activeTraining.progressTicks / activeTraining.totalTicks) * 100)}%` : 'Idle'}
color="text-purple-400" color="text-purple-400"
onClick={() => useGameStore.getState().setActivePage('models')}
/> />
<StatCard <StatCard
icon={Users} icon={Users}
@@ -74,6 +77,7 @@ export function DashboardPage() {
value={formatNumber(subscribers)} value={formatNumber(subscribers)}
subValue={`Satisfaction: ${formatPercent(useGameStore.getState().market.consumers.satisfaction)}`} subValue={`Satisfaction: ${formatPercent(useGameStore.getState().market.consumers.satisfaction)}`}
color="text-orange-400" color="text-orange-400"
onClick={() => useGameStore.getState().setActivePage('market')}
/> />
</div> </div>
@@ -209,7 +213,7 @@ export function DashboardPage() {
} }
function StatCard({ function StatCard({
icon: Icon, label, value, subValue, trend, color, icon: Icon, label, value, subValue, trend, color, onClick,
}: { }: {
icon: typeof DollarSign; icon: typeof DollarSign;
label: string; label: string;
@@ -217,9 +221,13 @@ function StatCard({
subValue?: string; subValue?: string;
trend?: 'up' | 'down' | 'neutral'; trend?: 'up' | 'down' | 'neutral';
color?: string; color?: string;
onClick?: () => void;
}) { }) {
return ( return (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4"> <div
className={`bg-surface-900 border border-surface-700 rounded-xl p-4 ${onClick ? 'cursor-pointer hover:border-accent/30 transition-colors' : ''}`}
onClick={onClick}
>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Icon size={16} className={color ?? 'text-surface-400'} /> <Icon size={16} className={color ?? 'text-surface-400'} />
<span className="text-xs text-surface-400 uppercase tracking-wider">{label}</span> <span className="text-xs text-surface-400 uppercase tracking-wider">{label}</span>
+5
View File
@@ -145,6 +145,7 @@ export function DataPage() {
{owned ? ( {owned ? (
<span className="text-xs text-surface-500">Owned</span> <span className="text-xs text-surface-500">Owned</span>
) : ( ) : (
<div className="text-right">
<button <button
onClick={() => handlePurchase(item)} onClick={() => handlePurchase(item)}
disabled={money < item.price} disabled={money < item.price}
@@ -152,6 +153,10 @@ export function DataPage() {
> >
{formatMoney(item.price)} {formatMoney(item.price)}
</button> </button>
{money < item.price && (
<div className="text-[10px] text-warning mt-0.5">Need {formatMoney(item.price - money)} more</div>
)}
</div>
)} )}
</div> </div>
</div> </div>
+39 -9
View File
@@ -1,8 +1,8 @@
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatMoney, formatPercent, FUNDING_ROUNDS } from '@ai-tycoon/shared'; import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@ai-tycoon/shared';
import type { FundingRoundType } from '@ai-tycoon/shared'; import type { FundingRoundType } from '@ai-tycoon/shared';
import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket } from 'lucide-react'; import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket, Check, X as XIcon } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line } from 'recharts'; import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line, Tooltip } from 'recharts';
import { canRaiseFunding } from '@ai-tycoon/game-engine'; import { canRaiseFunding } from '@ai-tycoon/game-engine';
import type { GameState } from '@ai-tycoon/shared'; import type { GameState } from '@ai-tycoon/shared';
@@ -15,6 +15,9 @@ export function FinancePage() {
const infrastructure = useGameStore((s) => s.infrastructure); const infrastructure = useGameStore((s) => s.infrastructure);
const talent = useGameStore((s) => s.talent); const talent = useGameStore((s) => s.talent);
const raiseFunding = useGameStore((s) => s.raiseFunding); const raiseFunding = useGameStore((s) => s.raiseFunding);
const totalRevenue = useGameStore((s) => s.economy.totalRevenue);
const subscribers = useGameStore((s) => s.market.consumers.totalSubscribers);
const reputationScore = useGameStore((s) => s.reputation.score);
const state = useGameStore.getState(); const state = useGameStore.getState();
const gameStateForFunding: GameState = { const gameStateForFunding: GameState = {
@@ -81,12 +84,22 @@ export function FinancePage() {
</div> </div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-surface-400 mb-4">Revenue vs Expenses</h3> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-surface-400">Revenue vs Expenses</h3>
<div className="flex items-center gap-4 text-xs">
<span className="flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full bg-success inline-block" />Revenue</span>
<span className="flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full bg-danger inline-block" />Expenses</span>
</div>
</div>
{history.length > 1 ? ( {history.length > 1 ? (
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>
<LineChart data={history}> <LineChart data={history}>
<XAxis dataKey="tick" hide /> <XAxis dataKey="tick" hide />
<YAxis hide /> <YAxis hide />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
formatter={(value: number, name: string) => [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']}
/>
<Line type="monotone" dataKey="revenue" stroke="#22c55e" dot={false} strokeWidth={2} /> <Line type="monotone" dataKey="revenue" stroke="#22c55e" dot={false} strokeWidth={2} />
<Line type="monotone" dataKey="expenses" stroke="#ef4444" dot={false} strokeWidth={2} /> <Line type="monotone" dataKey="expenses" stroke="#ef4444" dot={false} strokeWidth={2} />
</LineChart> </LineChart>
@@ -143,26 +156,43 @@ export function FinancePage() {
)} )}
</div> </div>
{fundingStatus.nextRound && ( {fundingStatus.nextRound && (() => {
const roundConfig = FUNDING_ROUNDS[fundingStatus.nextRound as FundingRoundType];
const reqs = roundConfig.requirements;
const checks = [
...(reqs.minRevenue ? [{ label: `Total Revenue: ${formatMoney(totalRevenue)} / ${formatMoney(reqs.minRevenue)}`, met: totalRevenue >= reqs.minRevenue }] : []),
...(reqs.minUsers ? [{ label: `Subscribers: ${formatNumber(subscribers)} / ${formatNumber(reqs.minUsers)}`, met: subscribers >= reqs.minUsers }] : []),
...(reqs.minReputation ? [{ label: `Reputation: ${reputationScore} / ${reqs.minReputation}`, met: reputationScore >= reqs.minReputation }] : []),
];
return (
<div className="bg-surface-800 rounded-lg p-4 mb-4 border border-surface-600"> <div className="bg-surface-800 rounded-lg p-4 mb-4 border border-surface-600">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-sm capitalize"> <h4 className="font-semibold text-sm capitalize">
{fundingStatus.nextRound === 'ipo' ? 'IPO' : fundingStatus.nextRound.replace('series', 'Series ')} {fundingStatus.nextRound === 'ipo' ? 'IPO' : fundingStatus.nextRound.replace('series', 'Series ')}
</h4> </h4>
{fundingStatus.canRaise ? ( {fundingStatus.canRaise && (
<button <button
onClick={() => raiseFunding(fundingStatus.nextRound!)} onClick={() => raiseFunding(fundingStatus.nextRound!)}
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium" className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium"
> >
<Rocket size={14} /> <Rocket size={14} />
Raise {formatMoney(FUNDING_ROUNDS[fundingStatus.nextRound! as FundingRoundType].amount)} Raise {formatMoney(roundConfig.amount)}
</button> </button>
) : (
<span className="text-xs text-warning">{String(fundingStatus.reason ?? '')}</span>
)} )}
</div> </div>
{checks.length > 0 && (
<div className="space-y-1.5 mt-2">
{checks.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
{c.met ? <Check size={12} className="text-success shrink-0" /> : <XIcon size={12} className="text-danger shrink-0" />}
<span className={c.met ? 'text-surface-400' : 'text-surface-200'}>{c.label}</span>
</div>
))}
</div> </div>
)} )}
</div>
);
})()}
{funding.completedRounds.length === 0 ? ( {funding.completedRounds.length === 0 ? (
<p className="text-sm text-surface-500">No funding rounds completed yet.</p> <p className="text-sm text-surface-500">No funding rounds completed yet.</p>
+44 -5
View File
@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useCallback } from 'react';
import { Plus, Server, MapPin, Zap, HardDrive, Wrench, ChevronDown, ChevronUp, Thermometer, Shield, X } from 'lucide-react'; import { Plus, Server, MapPin, Zap, HardDrive, Wrench, ChevronDown, ChevronUp, Thermometer, Shield, X } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { useShallow } from 'zustand/shallow'; import { useShallow } from 'zustand/shallow';
import { import {
@@ -9,6 +10,7 @@ import {
import type { DCTier, RackSkuId, LocationId, RackOrder, PipelineStage, Era } from '@ai-tycoon/shared'; import type { DCTier, RackSkuId, LocationId, RackOrder, PipelineStage, Era } from '@ai-tycoon/shared';
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi']; const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
const collapsedDCs = new Set<string>();
const STAGE_LABELS: Record<PipelineStage, string> = { const STAGE_LABELS: Record<PipelineStage, string> = {
ordered: 'Ordered', ordered: 'Ordered',
@@ -113,7 +115,17 @@ function DataCenterCard({ dcId }: { dcId: string }) {
const orderRack = useGameStore((s) => s.orderRack); const orderRack = useGameStore((s) => s.orderRack);
const decommissionRack = useGameStore((s) => s.decommissionRack); const decommissionRack = useGameStore((s) => s.decommissionRack);
const upgradeDataCenter = useGameStore((s) => s.upgradeDataCenter); const upgradeDataCenter = useGameStore((s) => s.upgradeDataCenter);
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(!collapsedDCs.has(dcId));
const [confirmDecom, setConfirmDecom] = useState<string | null>(null);
const toggleExpanded = useCallback(() => {
setExpanded(prev => {
const next = !prev;
if (next) collapsedDCs.delete(dcId);
else collapsedDCs.add(dcId);
return next;
});
}, [dcId]);
const tierConfig = DC_TIER_CONFIGS[dc.tier]; const tierConfig = DC_TIER_CONFIGS[dc.tier];
const currentEraIdx = ERA_ORDER.indexOf(era); const currentEraIdx = ERA_ORDER.indexOf(era);
@@ -166,7 +178,7 @@ function DataCenterCard({ dcId }: { dcId: string }) {
<span className="text-danger">Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span> <span className="text-danger">Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span>
</div> </div>
</div> </div>
<button onClick={() => setExpanded(!expanded)} className="text-surface-400 hover:text-surface-200"> <button onClick={toggleExpanded} className="text-surface-400 hover:text-surface-200">
{expanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />} {expanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</button> </button>
</div> </div>
@@ -190,8 +202,28 @@ function DataCenterCard({ dcId }: { dcId: string }) {
? 'bg-surface-800 border-surface-600' ? 'bg-surface-800 border-surface-600'
: 'bg-danger/10 border-danger/30' : 'bg-danger/10 border-danger/30'
}`}> }`}>
{confirmDecom === rack.id ? (
<div className="flex items-center justify-between gap-1">
<span className="text-danger text-[10px]">Remove?</span>
<div className="flex gap-0.5">
<button <button
onClick={() => decommissionRack(dc.id, rack.id)} onClick={() => { decommissionRack(dc.id, rack.id); setConfirmDecom(null); }}
className="px-1 py-0.5 rounded bg-danger/20 text-danger hover:bg-danger/30 text-[10px]"
>
Yes
</button>
<button
onClick={() => setConfirmDecom(null)}
className="px-1 py-0.5 rounded hover:bg-surface-700 text-surface-400 text-[10px]"
>
No
</button>
</div>
</div>
) : (
<>
<button
onClick={() => setConfirmDecom(rack.id)}
className="absolute top-0.5 right-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-danger/20 text-surface-400 hover:text-danger transition-all" className="absolute top-0.5 right-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-danger/20 text-surface-400 hover:text-danger transition-all"
title="Decommission rack" title="Decommission rack"
> >
@@ -199,6 +231,8 @@ function DataCenterCard({ dcId }: { dcId: string }) {
</button> </button>
<div className="font-medium truncate">{sku.name}</div> <div className="font-medium truncate">{sku.name}</div>
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS</div> <div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS</div>
</>
)}
</div> </div>
); );
})} })}
@@ -214,6 +248,7 @@ function DataCenterCard({ dcId }: { dcId: string }) {
const hasSlot = liveUsedSlots < tierConfig.rackSlots; const hasSlot = liveUsedSlots < tierConfig.rackSlots;
const hasPower = liveUsedPower + sku.powerDrawKW <= tierConfig.powerBudgetKW; const hasPower = liveUsedPower + sku.powerDrawKW <= tierConfig.powerBudgetKW;
const disabled = !canAfford || !hasSlot || !hasPower; const disabled = !canAfford || !hasSlot || !hasPower;
const reason = !canAfford ? `Need ${formatMoney(sku.baseCost)}` : !hasSlot ? 'No slots available' : !hasPower ? 'Exceeds power budget' : '';
return ( return (
<button <button
@@ -221,10 +256,10 @@ function DataCenterCard({ dcId }: { dcId: string }) {
onClick={() => orderRack(dc.id, sku.id)} onClick={() => orderRack(dc.id, sku.id)}
disabled={disabled} disabled={disabled}
className="bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-left" className="bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-left"
title={!hasSlot ? 'No slots available' : !hasPower ? 'Exceeds power budget' : ''}
> >
<div className="font-medium">{sku.name}</div> <div className="font-medium">{sku.name}</div>
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS · {sku.powerDrawKW}kW · {formatMoney(sku.baseCost)}</div> <div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS · {sku.powerDrawKW}kW · {formatMoney(sku.baseCost)}</div>
{disabled && reason && <div className="text-warning mt-0.5">{reason}</div>}
</button> </button>
); );
})} })}
@@ -364,6 +399,10 @@ export function InfrastructurePage() {
</button> </button>
</div> </div>
<TutorialHint id="infra-intro">
Choose a location and tier for your data center, then order GPU racks to add compute capacity. Racks go through a build pipeline before they come online.
</TutorialHint>
{showNewDC && <BuildDCPanel onClose={() => setShowNewDC(false)} />} {showNewDC && <BuildDCPanel onClose={() => setShowNewDC(false)} />}
<PipelineKanban /> <PipelineKanban />
+10 -1
View File
@@ -88,7 +88,16 @@ export function LeaderboardPage() {
<div className="bg-surface-900 border border-surface-700 rounded-xl overflow-hidden"> <div className="bg-surface-900 border border-surface-700 rounded-xl overflow-hidden">
{loading ? ( {loading ? (
<div className="p-8 text-center text-surface-500">Loading...</div> <div className="p-4 space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4 animate-pulse">
<div className="w-8 h-4 bg-surface-800 rounded" />
<div className="flex-1 h-4 bg-surface-800 rounded" />
<div className="w-16 h-4 bg-surface-800 rounded" />
<div className="w-12 h-4 bg-surface-800 rounded" />
</div>
))}
</div>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<div className="p-8 text-center text-surface-500"> <div className="p-8 text-center text-surface-500">
<Trophy size={48} className="mx-auto mb-4 opacity-50" /> <Trophy size={48} className="mx-auto mb-4 opacity-50" />
+46 -10
View File
@@ -1,9 +1,32 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { import {
formatNumber, formatMoney, formatPercent, formatNumber, formatMoney, formatPercent,
MARKET_SIZE_CAP, MARKET_CAP_QUALITY_BONUS, MARKET_CAP_REPUTATION_BONUS, MARKET_SIZE_CAP, MARKET_CAP_QUALITY_BONUS, MARKET_CAP_REPUTATION_BONUS,
} from '@ai-tycoon/shared'; } from '@ai-tycoon/shared';
import { Users, Zap, Shield, TrendingUp, Settings2 } from 'lucide-react'; import { Users, Zap, Shield, TrendingUp, Settings2, Check } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
function useAppliedFeedback() {
const [show, setShow] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const trigger = useCallback(() => {
setShow(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setShow(false), 1200);
}, []);
useEffect(() => () => clearTimeout(timerRef.current), []);
return { show, trigger };
}
function AppliedBadge({ visible }: { visible: boolean }) {
if (!visible) return null;
return (
<span className="inline-flex items-center gap-1 text-[10px] text-success ml-2 animate-pulse">
<Check size={10} /> Applied
</span>
);
}
export function MarketPage() { export function MarketPage() {
const consumers = useGameStore((s) => s.market.consumers); const consumers = useGameStore((s) => s.market.consumers);
@@ -21,6 +44,8 @@ export function MarketPage() {
}); });
const setProductPricing = useGameStore((s) => s.setProductPricing); const setProductPricing = useGameStore((s) => s.setProductPricing);
const setOverloadPolicy = useGameStore((s) => s.setOverloadPolicy); const setOverloadPolicy = useGameStore((s) => s.setOverloadPolicy);
const pricingFeedback = useAppliedFeedback();
const policyFeedback = useAppliedFeedback();
const eraCapBase = MARKET_SIZE_CAP[currentEra] ?? 100_000_000; const eraCapBase = MARKET_SIZE_CAP[currentEra] ?? 100_000_000;
const effectiveCap = eraCapBase const effectiveCap = eraCapBase
* (1 + bestQuality * MARKET_CAP_QUALITY_BONUS) * (1 + bestQuality * MARKET_CAP_QUALITY_BONUS)
@@ -34,6 +59,10 @@ export function MarketPage() {
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-2xl font-bold">Market</h2> <h2 className="text-2xl font-bold">Market</h2>
<TutorialHint id="market-intro">
Adjust pricing to balance growth and revenue. Watch customer satisfaction low scores increase churn. High system load means you need more inference capacity.
</TutorialHint>
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
@@ -97,13 +126,15 @@ export function MarketPage() {
</span> </span>
</div> </div>
<div> <div>
<label className="block text-xs text-surface-400 mb-1">Monthly Subscription Price</label> <label className="block text-xs text-surface-400 mb-1">
Monthly Subscription Price <AppliedBadge visible={pricingFeedback.show} />
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm">$</span> <span className="text-sm">$</span>
<input <input
type="number" type="number"
value={chatProduct.pricing.subscriptionPrice} value={chatProduct.pricing.subscriptionPrice}
onChange={(e) => setProductPricing(chatProduct.id, 'subscriptionPrice', Number(e.target.value))} onChange={(e) => { setProductPricing(chatProduct.id, 'subscriptionPrice', Number(e.target.value)); pricingFeedback.trigger(); }}
className="w-24 bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-24 bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={0} min={0}
step={5} step={5}
@@ -124,11 +155,11 @@ export function MarketPage() {
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs text-surface-400 mb-1">Input ($/M tokens)</label> <label className="block text-xs text-surface-400 mb-1">Input ($/M tokens) <AppliedBadge visible={pricingFeedback.show} /></label>
<input <input
type="number" type="number"
value={textApi.pricing.inputTokenPrice} value={textApi.pricing.inputTokenPrice}
onChange={(e) => setProductPricing(textApi.id, 'inputTokenPrice', Number(e.target.value))} onChange={(e) => { setProductPricing(textApi.id, 'inputTokenPrice', Number(e.target.value)); pricingFeedback.trigger(); }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={0} min={0}
step={0.5} step={0.5}
@@ -139,7 +170,7 @@ export function MarketPage() {
<input <input
type="number" type="number"
value={textApi.pricing.outputTokenPrice} value={textApi.pricing.outputTokenPrice}
onChange={(e) => setProductPricing(textApi.id, 'outputTokenPrice', Number(e.target.value))} onChange={(e) => { setProductPricing(textApi.id, 'outputTokenPrice', Number(e.target.value)); pricingFeedback.trigger(); }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={0} min={0}
step={0.5} step={0.5}
@@ -154,6 +185,7 @@ export function MarketPage() {
<h3 className="font-semibold flex items-center gap-2"> <h3 className="font-semibold flex items-center gap-2">
<Settings2 size={16} /> <Settings2 size={16} />
Overload Policy Overload Policy
<AppliedBadge visible={policyFeedback.show} />
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
@@ -161,22 +193,24 @@ export function MarketPage() {
<input <input
type="number" type="number"
value={overloadPolicy.maxQueueDepth} value={overloadPolicy.maxQueueDepth}
onChange={(e) => setOverloadPolicy({ maxQueueDepth: Number(e.target.value) })} onChange={(e) => { setOverloadPolicy({ maxQueueDepth: Number(e.target.value) }); policyFeedback.trigger(); }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={10} min={10}
step={10} step={10}
/> />
<p className="text-[10px] text-surface-500 mt-0.5">Higher = more latency tolerance, lower satisfaction</p>
</div> </div>
<div> <div>
<label className="block text-xs text-surface-400 mb-1">Rate Limit / Customer (tok/s)</label> <label className="block text-xs text-surface-400 mb-1">Rate Limit / Customer (tok/s)</label>
<input <input
type="number" type="number"
value={overloadPolicy.rateLimitPerCustomer} value={overloadPolicy.rateLimitPerCustomer}
onChange={(e) => setOverloadPolicy({ rateLimitPerCustomer: Number(e.target.value) })} onChange={(e) => { setOverloadPolicy({ rateLimitPerCustomer: Number(e.target.value) }); policyFeedback.trigger(); }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50" className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={100} min={100}
step={100} step={100}
/> />
<p className="text-[10px] text-surface-500 mt-0.5">Lower = less compute per user, serves more customers</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
@@ -184,19 +218,21 @@ export function MarketPage() {
<input <input
type="checkbox" type="checkbox"
checked={overloadPolicy.degradeQualityUnderLoad} checked={overloadPolicy.degradeQualityUnderLoad}
onChange={(e) => setOverloadPolicy({ degradeQualityUnderLoad: e.target.checked })} onChange={(e) => { setOverloadPolicy({ degradeQualityUnderLoad: e.target.checked }); policyFeedback.trigger(); }}
className="accent-accent" className="accent-accent"
/> />
<span className="text-surface-300">Degrade quality under load</span> <span className="text-surface-300">Degrade quality under load</span>
<span className="text-[10px] text-surface-500">Reduces quality to maintain throughput</span>
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={overloadPolicy.prioritizeEnterprise} checked={overloadPolicy.prioritizeEnterprise}
onChange={(e) => setOverloadPolicy({ prioritizeEnterprise: e.target.checked })} onChange={(e) => { setOverloadPolicy({ prioritizeEnterprise: e.target.checked }); policyFeedback.trigger(); }}
className="accent-accent" className="accent-accent"
/> />
<span className="text-surface-300">Prioritize enterprise</span> <span className="text-surface-300">Prioritize enterprise</span>
<span className="text-[10px] text-surface-500">Enterprise SLAs before consumer traffic</span>
</label> </label>
</div> </div>
</div> </div>
+20 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Brain, Play, Rocket, Globe, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react'; import { Brain, Play, Rocket, Globe, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared'; import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
import type { TuningPreset } from '@ai-tycoon/shared'; import type { TuningPreset } from '@ai-tycoon/shared';
@@ -45,6 +46,10 @@ export function ModelsPage() {
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-2xl font-bold">Models</h2> <h2 className="text-2xl font-bold">Models</h2>
<TutorialHint id="models-intro">
Split compute between training (building new models) and inference (serving customers). Deploy trained models to start earning revenue.
</TutorialHint>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="font-semibold mb-3">Compute Allocation</h3> <h3 className="font-semibold mb-3">Compute Allocation</h3>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -114,6 +119,7 @@ export function ModelsPage() {
<div className="text-sm text-surface-400"> <div className="text-sm text-surface-400">
Estimated capability score: <span className="text-accent-light font-mono">{estimatedCapability.toFixed(1)}/100</span> Estimated capability score: <span className="text-accent-light font-mono">{estimatedCapability.toFixed(1)}/100</span>
</div> </div>
<div>
<button <button
onClick={handleStartTraining} onClick={handleStartTraining}
disabled={trainingFlops === 0} disabled={trainingFlops === 0}
@@ -122,6 +128,13 @@ export function ModelsPage() {
<Play size={16} /> <Play size={16} />
Start Training Start Training
</button> </button>
{trainingFlops === 0 && totalFlops === 0 && (
<p className="text-xs text-warning mt-1">Build a data center and order racks first</p>
)}
{trainingFlops === 0 && totalFlops > 0 && (
<p className="text-xs text-warning mt-1">Allocate compute to training above</p>
)}
</div>
</div> </div>
)} )}
</div> </div>
@@ -137,8 +150,13 @@ export function ModelsPage() {
<div key={model.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4"> <div key={model.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => setExpandedModel(isExpanded ? null : model.id)} className="text-surface-400 hover:text-surface-200"> <button
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} onClick={() => setExpandedModel(isExpanded ? null : model.id)}
className="flex items-center gap-1 text-xs text-surface-400 hover:text-surface-200"
>
<SlidersHorizontal size={12} />
<span>{isExpanded ? 'Hide' : 'Tune'}</span>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button> </button>
<div> <div>
<h4 className="font-medium">{model.name}</h4> <h4 className="font-medium">{model.name}</h4>
+8
View File
@@ -1,4 +1,5 @@
import { FlaskConical, Lock, Check, Play } from 'lucide-react'; import { FlaskConical, Lock, Check, Play } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared'; import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared';
import { TECH_TREE, getAvailableResearch } from '@ai-tycoon/game-engine'; import { TECH_TREE, getAvailableResearch } from '@ai-tycoon/game-engine';
@@ -58,6 +59,10 @@ export function ResearchPage() {
</div> </div>
</div> </div>
<TutorialHint id="research-intro">
Only one research project can run at a time. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure.
</TutorialHint>
{activeResearch && ( {activeResearch && (
<div className="bg-surface-900 border border-accent/30 rounded-xl p-4"> <div className="bg-surface-900 border border-accent/30 rounded-xl p-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -127,6 +132,9 @@ export function ResearchPage() {
Start Start
</button> </button>
)} )}
{isAvailable && activeResearch && (
<span className="text-xs text-surface-500 italic">Queue after current</span>
)}
</div> </div>
{node.prerequisites.length > 0 && isLocked && ( {node.prerequisites.length > 0 && isLocked && (
<div className="text-xs text-surface-600 mt-2"> <div className="text-xs text-surface-600 mt-2">
+36 -11
View File
@@ -1,11 +1,15 @@
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal';
export function SettingsPage() { export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings); const settings = useGameStore((s) => s.meta.settings);
const companyName = useGameStore((s) => s.meta.companyName); const companyName = useGameStore((s) => s.meta.companyName);
const updateState = useGameStore((s) => s.updateState); const updateState = useGameStore((s) => s.updateState);
const addNotification = useGameStore((s) => s.addNotification);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null);
const toggleSound = () => { const toggleSound = () => {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } }); updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } });
@@ -16,10 +20,8 @@ export function SettingsPage() {
}; };
const handleReset = () => { const handleReset = () => {
if (confirm('Are you sure you want to reset all progress? This cannot be undone.')) {
localStorage.removeItem('ai-tycoon-save'); localStorage.removeItem('ai-tycoon-save');
window.location.reload(); window.location.reload();
}
}; };
const handleExport = () => { const handleExport = () => {
@@ -43,22 +45,24 @@ export function SettingsPage() {
try { try {
const data = JSON.parse(event.target?.result as string); const data = JSON.parse(event.target?.result as string);
if (!data.meta?.companyName) { if (!data.meta?.companyName) {
alert('Invalid save file: missing company data.'); addNotification({ title: 'Import Failed', message: 'Invalid save file: missing company data.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
return; return;
} }
if (!confirm(`Import save for "${data.meta.companyName}"? This will replace your current game.`)) { setImportData({ data, name: data.meta.companyName });
return;
}
localStorage.setItem('ai-tycoon-save', JSON.stringify({ state: data }));
window.location.reload();
} catch { } catch {
alert('Failed to read save file. Make sure it is a valid AI Tycoon export.'); addNotification({ title: 'Import Failed', message: 'Could not read save file. Make sure it is a valid AI Tycoon export.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
} }
}; };
reader.readAsText(file); reader.readAsText(file);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
}; };
const confirmImport = () => {
if (!importData) return;
localStorage.setItem('ai-tycoon-save', JSON.stringify({ state: importData.data }));
window.location.reload();
};
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-6 max-w-2xl">
<h2 className="text-2xl font-bold">Settings</h2> <h2 className="text-2xl font-bold">Settings</h2>
@@ -113,13 +117,34 @@ export function SettingsPage() {
className="hidden" className="hidden"
/> />
<button <button
onClick={handleReset} onClick={() => setShowResetConfirm(true)}
className="px-4 py-2 rounded bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger text-sm" className="px-4 py-2 rounded bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger text-sm"
> >
Reset Progress Reset Progress
</button> </button>
</div> </div>
</div> </div>
{showResetConfirm && (
<ConfirmModal
title="Reset All Progress"
message="This will permanently delete your save data and start a new game. This cannot be undone."
confirmLabel="Reset Everything"
danger
onConfirm={handleReset}
onCancel={() => setShowResetConfirm(false)}
/>
)}
{importData && (
<ConfirmModal
title="Import Save"
message={`Import save for "${importData.name}"? This will replace your current game.`}
confirmLabel="Import"
onConfirm={confirmImport}
onCancel={() => setImportData(null)}
/>
)}
</div> </div>
); );
} }
+3
View File
@@ -72,6 +72,7 @@ export function TalentPage() {
<span className="text-xs text-surface-400"> <span className="text-xs text-surface-400">
Budget: {formatMoney(dept.budget)}/mo Budget: {formatMoney(dept.budget)}/mo
</span> </span>
<div className="text-right">
<button <button
onClick={() => hireDepartment(id, 1)} onClick={() => hireDepartment(id, 1)}
disabled={!canHire} disabled={!canHire}
@@ -80,6 +81,8 @@ export function TalentPage() {
<Plus size={12} /> <Plus size={12} />
Hire ({formatMoney(hiringCost)}) Hire ({formatMoney(hiringCost)})
</button> </button>
{!canHire && <div className="text-[10px] text-warning mt-0.5">Insufficient funds</div>}
</div>
</div> </div>
</div> </div>
))} ))}
+5
View File
@@ -45,6 +45,7 @@ interface Actions {
setActivePage: (page: ActivePage) => void; setActivePage: (page: ActivePage) => void;
addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void; addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void;
dismissNotification: (id: string) => void; dismissNotification: (id: string) => void;
markAllNotificationsRead: () => void;
startNewGame: (companyName: string) => void; startNewGame: (companyName: string) => void;
setGameSpeed: (speed: GameSpeed) => void; setGameSpeed: (speed: GameSpeed) => void;
togglePause: () => void; togglePause: () => void;
@@ -118,6 +119,10 @@ export const useGameStore = create<Store>()(
), ),
})), })),
markAllNotificationsRead: () => set((s) => ({
notifications: s.notifications.map(n => ({ ...n, read: true })),
})),
startNewGame: (companyName) => set({ startNewGame: (companyName) => set({
...initialGameState, ...initialGameState,
meta: { meta: {