From f9f6233b69a414e06f34bac692a0172809ca0e1e Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 24 Apr 2026 21:44:18 -0400 Subject: [PATCH] Comprehensive UX polish: fix 19 friction points across all pages 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 --- .../src/components/common/ConfirmModal.tsx | 56 +++++++++++++ .../components/common/NotificationPanel.tsx | 78 +++++++++++++++++++ apps/web/src/components/layout/MainLayout.tsx | 4 + apps/web/src/components/layout/Sidebar.tsx | 33 ++++---- apps/web/src/components/layout/TopBar.tsx | 31 +++++--- apps/web/src/hooks/useHashRouter.ts | 38 +++++++++ apps/web/src/hooks/useKeyboardShortcuts.ts | 34 ++++++++ apps/web/src/pages/CompetitorsPage.tsx | 29 ++++--- apps/web/src/pages/DashboardPage.tsx | 12 ++- apps/web/src/pages/DataPage.tsx | 19 +++-- apps/web/src/pages/FinancePage.tsx | 74 ++++++++++++------ apps/web/src/pages/InfrastructurePage.tsx | 65 ++++++++++++---- apps/web/src/pages/LeaderboardPage.tsx | 11 ++- apps/web/src/pages/MarketPage.tsx | 56 ++++++++++--- apps/web/src/pages/ModelsPage.tsx | 38 ++++++--- apps/web/src/pages/ResearchPage.tsx | 8 ++ apps/web/src/pages/SettingsPage.tsx | 51 ++++++++---- apps/web/src/pages/TalentPage.tsx | 19 +++-- apps/web/src/store/index.ts | 5 ++ 19 files changed, 540 insertions(+), 121 deletions(-) create mode 100644 apps/web/src/components/common/ConfirmModal.tsx create mode 100644 apps/web/src/components/common/NotificationPanel.tsx create mode 100644 apps/web/src/hooks/useHashRouter.ts create mode 100644 apps/web/src/hooks/useKeyboardShortcuts.ts diff --git a/apps/web/src/components/common/ConfirmModal.tsx b/apps/web/src/components/common/ConfirmModal.tsx new file mode 100644 index 0000000..fc4bb73 --- /dev/null +++ b/apps/web/src/components/common/ConfirmModal.tsx @@ -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(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 ( +
+
e.stopPropagation()}> +
+ {danger && } +

{title}

+
+

{message}

+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/common/NotificationPanel.tsx b/apps/web/src/components/common/NotificationPanel.tsx new file mode 100644 index 0000000..4a7e155 --- /dev/null +++ b/apps/web/src/components/common/NotificationPanel.tsx @@ -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(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 ( +
+
+

Notifications

+ +
+
+ {notifications.length === 0 ? ( +
+ +

No notifications yet

+
+ ) : ( + notifications.map((n: GameNotification) => { + const { icon: Icon, color } = ICON_MAP[n.type] ?? ICON_MAP.info; + const ticksAgo = currentTick - n.tick; + return ( +
+
+ +
+
{n.title}
+
{n.message}
+
{formatDuration(ticksAgo)} ago
+
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 0d46b60..a97a657 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -2,6 +2,8 @@ import { Sidebar } from './Sidebar'; import { TopBar } from './TopBar'; import { ToastContainer } from '@/components/common/ToastContainer'; import { useGameStore } from '@/store'; +import { useHashRouter } from '@/hooks/useHashRouter'; +import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { DashboardPage } from '@/pages/DashboardPage'; import { InfrastructurePage } from '@/pages/InfrastructurePage'; import { ResearchPage } from '@/pages/ResearchPage'; @@ -16,6 +18,8 @@ import { AchievementsPage } from '@/pages/AchievementsPage'; import { LeaderboardPage } from '@/pages/LeaderboardPage'; export function MainLayout() { + useHashRouter(); + useKeyboardShortcuts(); const activePage = useGameStore((s) => s.activePage); return ( diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 3550c4e..04a0a16 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -70,22 +70,25 @@ export function Sidebar() { const isActive = activePage === page; const isNew = newPages.has(page); + const showDivider = page === 'talent' || page === 'achievements'; return ( - +
+ {showDivider &&
} + +
); })} diff --git a/apps/web/src/components/layout/TopBar.tsx b/apps/web/src/components/layout/TopBar.tsx index 78f48e4..c5e2061 100644 --- a/apps/web/src/components/layout/TopBar.tsx +++ b/apps/web/src/components/layout/TopBar.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useState } from 'react'; import { Pause, Play, Bell, Share2 } from 'lucide-react'; import { CompanyStatsCard } from '@/components/game/CompanyStatsCard'; +import { NotificationPanel } from '@/components/common/NotificationPanel'; import { useGameStore } from '@/store'; import { formatMoney, formatNumber, formatDuration, formatPercent } 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 unreadCount = notifications.filter(n => !n.read).length; const [showStats, setShowStats] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); const netIncome = revenuePerTick - expensesPerTick; @@ -65,11 +67,16 @@ export function TopBar() {
+ {isPaused && ( + + PAUSED + + )}
@@ -96,14 +103,20 @@ export function TopBar() { - +
+ + {showNotifications && setShowNotifications(false)} />} +
{showStats && setShowStats(false)} />} diff --git a/apps/web/src/hooks/useHashRouter.ts b/apps/web/src/hooks/useHashRouter.ts new file mode 100644 index 0000000..78aae0d --- /dev/null +++ b/apps/web/src/hooks/useHashRouter.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { useGameStore, type ActivePage } from '@/store'; + +const VALID_PAGES = new Set([ + '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]); +} diff --git a/apps/web/src/hooks/useKeyboardShortcuts.ts b/apps/web/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..00982a0 --- /dev/null +++ b/apps/web/src/hooks/useKeyboardShortcuts.ts @@ -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); + }, []); +} diff --git a/apps/web/src/pages/CompetitorsPage.tsx b/apps/web/src/pages/CompetitorsPage.tsx index a100593..16a9178 100644 --- a/apps/web/src/pages/CompetitorsPage.tsx +++ b/apps/web/src/pages/CompetitorsPage.tsx @@ -81,17 +81,24 @@ export function CompetitorsPage() {
- {canAcquire(era) && rival.status === 'active' && ( - - )} + {canAcquire(era) && rival.status === 'active' && (() => { + const cost = rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000; + return ( +
+ + {money < cost && ( +
Need {formatMoney(cost - money)} more
+ )} +
+ ); + })()} = 0 ? '+' : ''}${formatMoney(netIncome)}/s`} trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'} color="text-green-400" + onClick={() => useGameStore.getState().setActivePage('finance')} /> useGameStore.getState().setActivePage('infrastructure')} /> useGameStore.getState().setActivePage('models')} /> useGameStore.getState().setActivePage('market')} />
@@ -209,7 +213,7 @@ export function DashboardPage() { } function StatCard({ - icon: Icon, label, value, subValue, trend, color, + icon: Icon, label, value, subValue, trend, color, onClick, }: { icon: typeof DollarSign; label: string; @@ -217,9 +221,13 @@ function StatCard({ subValue?: string; trend?: 'up' | 'down' | 'neutral'; color?: string; + onClick?: () => void; }) { return ( -
+
{label} diff --git a/apps/web/src/pages/DataPage.tsx b/apps/web/src/pages/DataPage.tsx index b4add53..32b10e1 100644 --- a/apps/web/src/pages/DataPage.tsx +++ b/apps/web/src/pages/DataPage.tsx @@ -145,13 +145,18 @@ export function DataPage() { {owned ? ( Owned ) : ( - +
+ + {money < item.price && ( +
Need {formatMoney(item.price - money)} more
+ )} +
)}
diff --git a/apps/web/src/pages/FinancePage.tsx b/apps/web/src/pages/FinancePage.tsx index 64394a3..448573b 100644 --- a/apps/web/src/pages/FinancePage.tsx +++ b/apps/web/src/pages/FinancePage.tsx @@ -1,8 +1,8 @@ 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 { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket } from 'lucide-react'; -import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line } from 'recharts'; +import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket, Check, X as XIcon } from 'lucide-react'; +import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line, Tooltip } from 'recharts'; import { canRaiseFunding } from '@ai-tycoon/game-engine'; import type { GameState } from '@ai-tycoon/shared'; @@ -15,6 +15,9 @@ export function FinancePage() { const infrastructure = useGameStore((s) => s.infrastructure); const talent = useGameStore((s) => s.talent); 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 gameStateForFunding: GameState = { @@ -81,12 +84,22 @@ export function FinancePage() {
-

Revenue vs Expenses

+
+

Revenue vs Expenses

+
+ Revenue + Expenses +
+
{history.length > 1 ? ( + [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']} + /> @@ -143,26 +156,43 @@ export function FinancePage() { )}
- {fundingStatus.nextRound && ( -
-
-

- {fundingStatus.nextRound === 'ipo' ? 'IPO' : fundingStatus.nextRound.replace('series', 'Series ')} -

- {fundingStatus.canRaise ? ( - - ) : ( - {String(fundingStatus.reason ?? '')} + {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 ( +
+
+

+ {fundingStatus.nextRound === 'ipo' ? 'IPO' : fundingStatus.nextRound.replace('series', 'Series ')} +

+ {fundingStatus.canRaise && ( + + )} +
+ {checks.length > 0 && ( +
+ {checks.map((c, i) => ( +
+ {c.met ? : } + {c.label} +
+ ))} +
)}
-
- )} + ); + })()} {funding.completedRounds.length === 0 ? (

No funding rounds completed yet.

diff --git a/apps/web/src/pages/InfrastructurePage.tsx b/apps/web/src/pages/InfrastructurePage.tsx index 23e3727..34c794f 100644 --- a/apps/web/src/pages/InfrastructurePage.tsx +++ b/apps/web/src/pages/InfrastructurePage.tsx @@ -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 { TutorialHint } from '@/components/game/TutorialHint'; import { useGameStore } from '@/store'; import { useShallow } from 'zustand/shallow'; import { @@ -9,6 +10,7 @@ import { import type { DCTier, RackSkuId, LocationId, RackOrder, PipelineStage, Era } from '@ai-tycoon/shared'; const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi']; +const collapsedDCs = new Set(); const STAGE_LABELS: Record = { ordered: 'Ordered', @@ -113,7 +115,17 @@ function DataCenterCard({ dcId }: { dcId: string }) { const orderRack = useGameStore((s) => s.orderRack); const decommissionRack = useGameStore((s) => s.decommissionRack); const upgradeDataCenter = useGameStore((s) => s.upgradeDataCenter); - const [expanded, setExpanded] = useState(true); + const [expanded, setExpanded] = useState(!collapsedDCs.has(dcId)); + const [confirmDecom, setConfirmDecom] = useState(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 currentEraIdx = ERA_ORDER.indexOf(era); @@ -166,7 +178,7 @@ function DataCenterCard({ dcId }: { dcId: string }) { Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s
- @@ -190,15 +202,37 @@ function DataCenterCard({ dcId }: { dcId: string }) { ? 'bg-surface-800 border-surface-600' : 'bg-danger/10 border-danger/30' }`}> - -
{sku.name}
-
{formatNumber(sku.flopsPerRack)} FLOPS
+ {confirmDecom === rack.id ? ( +
+ Remove? +
+ + +
+
+ ) : ( + <> + +
{sku.name}
+
{formatNumber(sku.flopsPerRack)} FLOPS
+ + )} ); })} @@ -214,6 +248,7 @@ function DataCenterCard({ dcId }: { dcId: string }) { const hasSlot = liveUsedSlots < tierConfig.rackSlots; const hasPower = liveUsedPower + sku.powerDrawKW <= tierConfig.powerBudgetKW; const disabled = !canAfford || !hasSlot || !hasPower; + const reason = !canAfford ? `Need ${formatMoney(sku.baseCost)}` : !hasSlot ? 'No slots available' : !hasPower ? 'Exceeds power budget' : ''; return ( ); })} @@ -364,6 +399,10 @@ export function InfrastructurePage() { + + 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. + + {showNewDC && setShowNewDC(false)} />} diff --git a/apps/web/src/pages/LeaderboardPage.tsx b/apps/web/src/pages/LeaderboardPage.tsx index aa6f353..7ec6401 100644 --- a/apps/web/src/pages/LeaderboardPage.tsx +++ b/apps/web/src/pages/LeaderboardPage.tsx @@ -88,7 +88,16 @@ export function LeaderboardPage() {
{loading ? ( -
Loading...
+
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+ ))} +
) : entries.length === 0 ? (
diff --git a/apps/web/src/pages/MarketPage.tsx b/apps/web/src/pages/MarketPage.tsx index 43fda68..ad142e1 100644 --- a/apps/web/src/pages/MarketPage.tsx +++ b/apps/web/src/pages/MarketPage.tsx @@ -1,9 +1,32 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; import { useGameStore } from '@/store'; import { formatNumber, formatMoney, formatPercent, MARKET_SIZE_CAP, MARKET_CAP_QUALITY_BONUS, MARKET_CAP_REPUTATION_BONUS, } 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>(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 ( + + Applied + + ); +} export function MarketPage() { const consumers = useGameStore((s) => s.market.consumers); @@ -21,6 +44,8 @@ export function MarketPage() { }); const setProductPricing = useGameStore((s) => s.setProductPricing); const setOverloadPolicy = useGameStore((s) => s.setOverloadPolicy); + const pricingFeedback = useAppliedFeedback(); + const policyFeedback = useAppliedFeedback(); const eraCapBase = MARKET_SIZE_CAP[currentEra] ?? 100_000_000; const effectiveCap = eraCapBase * (1 + bestQuality * MARKET_CAP_QUALITY_BONUS) @@ -34,6 +59,10 @@ export function MarketPage() {

Market

+ + Adjust pricing to balance growth and revenue. Watch customer satisfaction — low scores increase churn. High system load means you need more inference capacity. + +
@@ -97,13 +126,15 @@ export function MarketPage() {
- +
$ 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" min={0} step={5} @@ -124,11 +155,11 @@ export function MarketPage() {
- + 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" min={0} step={0.5} @@ -139,7 +170,7 @@ export function MarketPage() { 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" min={0} step={0.5} @@ -154,6 +185,7 @@ export function MarketPage() {

Overload Policy +

@@ -161,22 +193,24 @@ export function MarketPage() { 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" min={10} step={10} /> +

Higher = more latency tolerance, lower satisfaction

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" min={100} step={100} /> +

Lower = less compute per user, serves more customers

@@ -184,19 +218,21 @@ export function MarketPage() { setOverloadPolicy({ degradeQualityUnderLoad: e.target.checked })} + onChange={(e) => { setOverloadPolicy({ degradeQualityUnderLoad: e.target.checked }); policyFeedback.trigger(); }} className="accent-accent" /> Degrade quality under load + Reduces quality to maintain throughput
diff --git a/apps/web/src/pages/ModelsPage.tsx b/apps/web/src/pages/ModelsPage.tsx index 3541c78..767a43b 100644 --- a/apps/web/src/pages/ModelsPage.tsx +++ b/apps/web/src/pages/ModelsPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Brain, Play, Rocket, Globe, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react'; +import { TutorialHint } from '@/components/game/TutorialHint'; import { useGameStore } from '@/store'; import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared'; import type { TuningPreset } from '@ai-tycoon/shared'; @@ -45,6 +46,10 @@ export function ModelsPage() {

Models

+ + Split compute between training (building new models) and inference (serving customers). Deploy trained models to start earning revenue. + +

Compute Allocation

@@ -114,14 +119,22 @@ export function ModelsPage() {
Estimated capability score: {estimatedCapability.toFixed(1)}/100
- +
+ + {trainingFlops === 0 && totalFlops === 0 && ( +

Build a data center and order racks first

+ )} + {trainingFlops === 0 && totalFlops > 0 && ( +

Allocate compute to training above

+ )} +
)}
@@ -137,8 +150,13 @@ export function ModelsPage() {
-

{model.name}

diff --git a/apps/web/src/pages/ResearchPage.tsx b/apps/web/src/pages/ResearchPage.tsx index 4bc5bc2..9c77661 100644 --- a/apps/web/src/pages/ResearchPage.tsx +++ b/apps/web/src/pages/ResearchPage.tsx @@ -1,4 +1,5 @@ import { FlaskConical, Lock, Check, Play } from 'lucide-react'; +import { TutorialHint } from '@/components/game/TutorialHint'; import { useGameStore } from '@/store'; import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared'; import { TECH_TREE, getAvailableResearch } from '@ai-tycoon/game-engine'; @@ -58,6 +59,10 @@ export function ResearchPage() {
+ + Only one research project can run at a time. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure. + + {activeResearch && (
@@ -127,6 +132,9 @@ export function ResearchPage() { Start )} + {isAvailable && activeResearch && ( + Queue after current + )}
{node.prerequisites.length > 0 && isLocked && (
diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index e093b0d..d6eee89 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -1,11 +1,15 @@ -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { useGameStore } from '@/store'; +import { ConfirmModal } from '@/components/common/ConfirmModal'; export function SettingsPage() { const settings = useGameStore((s) => s.meta.settings); const companyName = useGameStore((s) => s.meta.companyName); const updateState = useGameStore((s) => s.updateState); + const addNotification = useGameStore((s) => s.addNotification); const fileInputRef = useRef(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null); const toggleSound = () => { updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } }); @@ -16,10 +20,8 @@ export function SettingsPage() { }; const handleReset = () => { - if (confirm('Are you sure you want to reset all progress? This cannot be undone.')) { - localStorage.removeItem('ai-tycoon-save'); - window.location.reload(); - } + localStorage.removeItem('ai-tycoon-save'); + window.location.reload(); }; const handleExport = () => { @@ -43,22 +45,24 @@ export function SettingsPage() { try { const data = JSON.parse(event.target?.result as string); 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; } - if (!confirm(`Import save for "${data.meta.companyName}"? This will replace your current game.`)) { - return; - } - localStorage.setItem('ai-tycoon-save', JSON.stringify({ state: data })); - window.location.reload(); + setImportData({ data, name: data.meta.companyName }); } 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); 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 (

Settings

@@ -113,13 +117,34 @@ export function SettingsPage() { className="hidden" />
+ + {showResetConfirm && ( + setShowResetConfirm(false)} + /> + )} + + {importData && ( + setImportData(null)} + /> + )}
); } diff --git a/apps/web/src/pages/TalentPage.tsx b/apps/web/src/pages/TalentPage.tsx index 109cfcb..f6863ff 100644 --- a/apps/web/src/pages/TalentPage.tsx +++ b/apps/web/src/pages/TalentPage.tsx @@ -72,14 +72,17 @@ export function TalentPage() { Budget: {formatMoney(dept.budget)}/mo - +
+ + {!canHire &&
Insufficient funds
} +
))} diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 2bed087..d908d41 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -45,6 +45,7 @@ interface Actions { setActivePage: (page: ActivePage) => void; addNotification: (n: Omit) => void; dismissNotification: (id: string) => void; + markAllNotificationsRead: () => void; startNewGame: (companyName: string) => void; setGameSpeed: (speed: GameSpeed) => void; togglePause: () => void; @@ -118,6 +119,10 @@ export const useGameStore = create()( ), })), + markAllNotificationsRead: () => set((s) => ({ + notifications: s.notifications.map(n => ({ ...n, read: true })), + })), + startNewGame: (companyName) => set({ ...initialGameState, meta: {