From d1d3eb4bf21ba22f3c028d07e929f7205d4c1b9b Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 24 Apr 2026 17:17:58 -0400 Subject: [PATCH] Polish Week 1: tooltips, save import, game balance tuning Add reusable Tooltip component and rich tooltips on all TopBar KPIs (cash breakdown, compute utilization, reputation context). Add save import button to Settings page. Fix game balance: reduce GPU maintenance 100x, increase organic API demand 200x, accelerate subscription revenue timescale, boost early subscriber seeding, use sqrt scaling for model compute factor, simplify deploy to activate all product lines at once. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/common/Tooltip.tsx | 42 ++++++++++++ apps/web/src/components/layout/TopBar.tsx | 64 ++++++++++++++++--- apps/web/src/pages/ModelsPage.tsx | 23 +++---- apps/web/src/pages/SettingsPage.tsx | 40 ++++++++++++ apps/web/src/store/index.ts | 10 +-- .../game-engine/src/systems/marketSystem.ts | 8 +-- .../game-engine/src/systems/modelSystem.ts | 2 +- packages/shared/src/constants/gameBalance.ts | 2 +- 8 files changed, 156 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/components/common/Tooltip.tsx diff --git a/apps/web/src/components/common/Tooltip.tsx b/apps/web/src/components/common/Tooltip.tsx new file mode 100644 index 0000000..1bfcb96 --- /dev/null +++ b/apps/web/src/components/common/Tooltip.tsx @@ -0,0 +1,42 @@ +import { useState, useRef, type ReactNode } from 'react'; + +interface TooltipProps { + content: ReactNode; + children: ReactNode; + position?: 'top' | 'bottom' | 'left' | 'right'; +} + +export function Tooltip({ content, children, position = 'bottom' }: TooltipProps) { + const [visible, setVisible] = useState(false); + const timeoutRef = useRef>(undefined); + + const show = () => { + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setVisible(true), 300); + }; + + const hide = () => { + clearTimeout(timeoutRef.current); + setVisible(false); + }; + + const positionClasses = { + top: 'bottom-full left-1/2 -translate-x-1/2 mb-2', + bottom: 'top-full left-1/2 -translate-x-1/2 mt-2', + left: 'right-full top-1/2 -translate-y-1/2 mr-2', + right: 'left-full top-1/2 -translate-y-1/2 ml-2', + }; + + return ( +
+ {children} + {visible && ( +
+
+ {content} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/TopBar.tsx b/apps/web/src/components/layout/TopBar.tsx index a9ba0cb..3cc92c0 100644 --- a/apps/web/src/components/layout/TopBar.tsx +++ b/apps/web/src/components/layout/TopBar.tsx @@ -1,15 +1,19 @@ -import { Pause, Play, Bell, Zap } from 'lucide-react'; +import { type ReactNode } from 'react'; +import { Pause, Play, Bell } from 'lucide-react'; import { useGameStore } from '@/store'; -import { formatMoney, formatNumber, formatDuration } from '@ai-tycoon/shared'; +import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared'; import type { GameSpeed } from '@ai-tycoon/shared'; +import { Tooltip } from '@/components/common/Tooltip'; const SPEEDS: GameSpeed[] = [1, 2, 5]; export function TopBar() { const money = useGameStore((s) => s.economy.money); const revenuePerTick = useGameStore((s) => s.economy.revenuePerTick); + const expensesPerTick = useGameStore((s) => s.economy.expensesPerTick); const reputation = useGameStore((s) => s.reputation.score); const totalFlops = useGameStore((s) => s.infrastructure.totalFlops); + const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization); const isPaused = useGameStore((s) => s.meta.isPaused); const gameSpeed = useGameStore((s) => s.meta.gameSpeed); const tickCount = useGameStore((s) => s.meta.tickCount); @@ -18,14 +22,44 @@ export function TopBar() { const notifications = useGameStore((s) => s.notifications); const unreadCount = notifications.filter(n => !n.read).length; + const netIncome = revenuePerTick - expensesPerTick; + return (
- 0 ? 'up' : 'neutral'} /> - - - - + 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'} + tooltip={
+
Revenue: {formatMoney(revenuePerTick)}/s
+
Expenses: {formatMoney(expensesPerTick)}/s
+
Net: = 0 ? 'text-success' : 'text-danger'}>{formatMoney(netIncome)}/s
+
} + /> + + +
Total compute from all healthy GPUs
+
Utilization: {formatPercent(inferenceUtil)}
+
} + /> + +
@@ -65,9 +99,14 @@ export function TopBar() { ); } -function KPI({ label, value, trend }: { label: string; value: string; trend?: 'up' | 'down' | 'neutral' }) { - return ( -
+function KPI({ label, value, trend, tooltip }: { + label: string; + value: string; + trend?: 'up' | 'down' | 'neutral'; + tooltip?: ReactNode; +}) { + const content = ( +
{label}
); + + if (tooltip) { + return {content}; + } + return content; } diff --git a/apps/web/src/pages/ModelsPage.tsx b/apps/web/src/pages/ModelsPage.tsx index 5806d0b..82a52bb 100644 --- a/apps/web/src/pages/ModelsPage.tsx +++ b/apps/web/src/pages/ModelsPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Brain, Play, Rocket, Settings2 } from 'lucide-react'; +import { Brain, Play, Rocket } from 'lucide-react'; import { useGameStore } from '@/store'; import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared'; @@ -18,7 +18,7 @@ export function ModelsPage() { const trainingFlops = totalFlops * trainingAlloc; const estimatedTicks = trainingFlops > 0 ? Math.max(30, Math.ceil(120 / (1 + trainingFlops * 0.1))) : Infinity; - const estimatedCapability = Math.min(100, Math.log(1 + trainingFlops * 0.5) * 10 + Math.log(1 + totalData / 1e9) * 5); + const estimatedCapability = Math.min(95, Math.sqrt(trainingFlops) * 5 + Math.log10(1 + totalData / 1e8) * 10); const handleStartTraining = () => { if (activeTraining || trainingFlops === 0) return; @@ -135,18 +135,13 @@ export function ModelsPage() { {model.isDeployed ? ( Deployed ) : ( - <> - {productLines.filter(pl => pl.type === 'text-api' || pl.type === 'chat-product').map(pl => ( - - ))} - + )}
diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index ce2ed45..ecab786 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -1,8 +1,10 @@ +import { useRef } from 'react'; import { useGameStore } from '@/store'; export function SettingsPage() { const settings = useGameStore((s) => s.meta.settings); const companyName = useGameStore((s) => s.meta.companyName); + const fileInputRef = useRef(null); const handleReset = () => { if (confirm('Are you sure you want to reset all progress? This cannot be undone.')) { @@ -23,6 +25,31 @@ export function SettingsPage() { URL.revokeObjectURL(url); }; + const handleImport = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const data = JSON.parse(event.target?.result as string); + if (!data.meta?.companyName) { + alert('Invalid save file: missing company data.'); + 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(); + } catch { + alert('Failed to read save file. Make sure it is a valid AI Tycoon export.'); + } + }; + reader.readAsText(file); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + return (

Settings

@@ -56,6 +83,19 @@ export function SettingsPage() { > Export Save + +