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 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:17:58 -04:00
parent 9a48c188ad
commit d1d3eb4bf2
8 changed files with 156 additions and 35 deletions
@@ -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<ReturnType<typeof setTimeout>>(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 (
<div className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
{children}
{visible && (
<div className={`absolute z-50 ${positionClasses[position]} pointer-events-none`}>
<div className="bg-surface-800 border border-surface-600 rounded-lg px-3 py-2 text-xs text-surface-200 shadow-xl whitespace-nowrap max-w-xs">
{content}
</div>
</div>
)}
</div>
);
}
+54 -10
View File
@@ -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 (
<header className="h-14 bg-surface-900 border-b border-surface-700 flex items-center justify-between px-4">
<div className="flex items-center gap-6">
<KPI label="Cash" value={formatMoney(money)} trend={revenuePerTick > 0 ? 'up' : 'neutral'} />
<KPI label="Revenue/s" value={formatMoney(revenuePerTick)} />
<KPI label="Compute" value={`${formatNumber(totalFlops)} FLOPS`} />
<KPI label="Reputation" value={`${reputation}/100`} />
<KPI label="Time" value={formatDuration(tickCount)} />
<KPI
label="Cash"
value={formatMoney(money)}
trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'}
tooltip={<div className="space-y-1">
<div>Revenue: <span className="text-success">{formatMoney(revenuePerTick)}/s</span></div>
<div>Expenses: <span className="text-danger">{formatMoney(expensesPerTick)}/s</span></div>
<div>Net: <span className={netIncome >= 0 ? 'text-success' : 'text-danger'}>{formatMoney(netIncome)}/s</span></div>
</div>}
/>
<KPI
label="Revenue/s"
value={formatMoney(revenuePerTick)}
tooltip="Total revenue per second from API calls and subscriptions"
/>
<KPI
label="Compute"
value={`${formatNumber(totalFlops)} FLOPS`}
tooltip={<div className="space-y-1">
<div>Total compute from all healthy GPUs</div>
<div>Utilization: {formatPercent(inferenceUtil)}</div>
</div>}
/>
<KPI
label="Reputation"
value={`${reputation}/100`}
tooltip="Affects talent acquisition, user trust, and investor confidence"
/>
<KPI
label="Time"
value={formatDuration(tickCount)}
tooltip={`${formatNumber(tickCount)} ticks elapsed`}
/>
</div>
<div className="flex items-center gap-3">
@@ -65,9 +99,14 @@ export function TopBar() {
);
}
function KPI({ label, value, trend }: { label: string; value: string; trend?: 'up' | 'down' | 'neutral' }) {
return (
<div className="flex flex-col">
function KPI({ label, value, trend, tooltip }: {
label: string;
value: string;
trend?: 'up' | 'down' | 'neutral';
tooltip?: ReactNode;
}) {
const content = (
<div className="flex flex-col cursor-default">
<span className="text-xs text-surface-400">{label}</span>
<span className={`text-sm font-mono font-semibold ${
trend === 'up' ? 'text-success' : trend === 'down' ? 'text-danger' : 'text-surface-100'
@@ -76,4 +115,9 @@ function KPI({ label, value, trend }: { label: string; value: string; trend?: 'u
</span>
</div>
);
if (tooltip) {
return <Tooltip content={tooltip}>{content}</Tooltip>;
}
return content;
}
+9 -14
View File
@@ -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 ? (
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
) : (
<>
{productLines.filter(pl => pl.type === 'text-api' || pl.type === 'chat-product').map(pl => (
<button
key={pl.id}
onClick={() => deployModel(model.id, pl.id)}
className="flex items-center gap-1 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded px-3 py-1.5 text-xs"
>
<Rocket size={12} />
Deploy to {pl.name}
</button>
))}
</>
<button
onClick={() => deployModel(model.id)}
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs"
>
<Rocket size={12} />
Deploy
</button>
)}
</div>
</div>
+40
View File
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="space-y-6 max-w-2xl">
<h2 className="text-2xl font-bold">Settings</h2>
@@ -56,6 +83,19 @@ export function SettingsPage() {
>
Export Save
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm"
>
Import Save
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
<button
onClick={handleReset}
className="px-4 py-2 rounded bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger text-sm"
+5 -5
View File
@@ -45,7 +45,7 @@ interface Actions {
buyGpu: (dataCenterId: string, gpuType: GpuType, count: number) => void;
buildDataCenter: (name: string, location: DataCenter['location']) => void;
startTraining: (job: Omit<TrainingJob, 'progressTicks'>) => void;
deployModel: (modelId: string, productLineId: string) => void;
deployModel: (modelId: string) => void;
setProductPricing: (productLineId: string, field: string, value: number) => void;
toggleProductLine: (productLineId: string) => void;
updateState: (partial: Partial<GameState>) => void;
@@ -186,15 +186,15 @@ export const useGameStore = create<Store>()(
},
})),
deployModel: (modelId, productLineId) => set((s) => ({
deployModel: (modelId) => set((s) => ({
models: {
...s.models,
trainedModels: s.models.trainedModels.map(m =>
m.id === modelId ? { ...m, isDeployed: true } : m,
),
productLines: s.models.productLines.map(pl =>
pl.id === productLineId ? { ...pl, modelId, isActive: true } : pl,
),
productLines: s.models.productLines.map(pl => ({
...pl, modelId, isActive: true,
})),
},
})),