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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { 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 type { GameSpeed } from '@ai-tycoon/shared';
|
||||||
|
import { Tooltip } from '@/components/common/Tooltip';
|
||||||
|
|
||||||
const SPEEDS: GameSpeed[] = [1, 2, 5];
|
const SPEEDS: GameSpeed[] = [1, 2, 5];
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const money = useGameStore((s) => s.economy.money);
|
const money = useGameStore((s) => s.economy.money);
|
||||||
const revenuePerTick = useGameStore((s) => s.economy.revenuePerTick);
|
const revenuePerTick = useGameStore((s) => s.economy.revenuePerTick);
|
||||||
|
const expensesPerTick = useGameStore((s) => s.economy.expensesPerTick);
|
||||||
const reputation = useGameStore((s) => s.reputation.score);
|
const reputation = useGameStore((s) => s.reputation.score);
|
||||||
const totalFlops = useGameStore((s) => s.infrastructure.totalFlops);
|
const totalFlops = useGameStore((s) => s.infrastructure.totalFlops);
|
||||||
|
const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization);
|
||||||
const isPaused = useGameStore((s) => s.meta.isPaused);
|
const isPaused = useGameStore((s) => s.meta.isPaused);
|
||||||
const gameSpeed = useGameStore((s) => s.meta.gameSpeed);
|
const gameSpeed = useGameStore((s) => s.meta.gameSpeed);
|
||||||
const tickCount = useGameStore((s) => s.meta.tickCount);
|
const tickCount = useGameStore((s) => s.meta.tickCount);
|
||||||
@@ -18,14 +22,44 @@ 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 netIncome = revenuePerTick - expensesPerTick;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 bg-surface-900 border-b border-surface-700 flex items-center justify-between px-4">
|
<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">
|
<div className="flex items-center gap-6">
|
||||||
<KPI label="Cash" value={formatMoney(money)} trend={revenuePerTick > 0 ? 'up' : 'neutral'} />
|
<KPI
|
||||||
<KPI label="Revenue/s" value={formatMoney(revenuePerTick)} />
|
label="Cash"
|
||||||
<KPI label="Compute" value={`${formatNumber(totalFlops)} FLOPS`} />
|
value={formatMoney(money)}
|
||||||
<KPI label="Reputation" value={`${reputation}/100`} />
|
trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'}
|
||||||
<KPI label="Time" value={formatDuration(tickCount)} />
|
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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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' }) {
|
function KPI({ label, value, trend, tooltip }: {
|
||||||
return (
|
label: string;
|
||||||
<div className="flex flex-col">
|
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-xs text-surface-400">{label}</span>
|
||||||
<span className={`text-sm font-mono font-semibold ${
|
<span className={`text-sm font-mono font-semibold ${
|
||||||
trend === 'up' ? 'text-success' : trend === 'down' ? 'text-danger' : 'text-surface-100'
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
return <Tooltip content={tooltip}>{content}</Tooltip>;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Brain, Play, Rocket, Settings2 } from 'lucide-react';
|
import { Brain, Play, Rocket } from 'lucide-react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
|
import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export function ModelsPage() {
|
|||||||
|
|
||||||
const trainingFlops = totalFlops * trainingAlloc;
|
const trainingFlops = totalFlops * trainingAlloc;
|
||||||
const estimatedTicks = trainingFlops > 0 ? Math.max(30, Math.ceil(120 / (1 + trainingFlops * 0.1))) : Infinity;
|
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 = () => {
|
const handleStartTraining = () => {
|
||||||
if (activeTraining || trainingFlops === 0) return;
|
if (activeTraining || trainingFlops === 0) return;
|
||||||
@@ -135,18 +135,13 @@ export function ModelsPage() {
|
|||||||
{model.isDeployed ? (
|
{model.isDeployed ? (
|
||||||
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
|
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<button
|
||||||
{productLines.filter(pl => pl.type === 'text-api' || pl.type === 'chat-product').map(pl => (
|
onClick={() => deployModel(model.id)}
|
||||||
<button
|
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs"
|
||||||
key={pl.id}
|
>
|
||||||
onClick={() => deployModel(model.id, pl.id)}
|
<Rocket size={12} />
|
||||||
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"
|
Deploy
|
||||||
>
|
</button>
|
||||||
<Rocket size={12} />
|
|
||||||
Deploy to {pl.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
|
|
||||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (confirm('Are you sure you want to reset all progress? This cannot be undone.')) {
|
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);
|
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 (
|
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>
|
||||||
@@ -56,6 +83,19 @@ export function SettingsPage() {
|
|||||||
>
|
>
|
||||||
Export Save
|
Export Save
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
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"
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ interface Actions {
|
|||||||
buyGpu: (dataCenterId: string, gpuType: GpuType, count: number) => void;
|
buyGpu: (dataCenterId: string, gpuType: GpuType, count: number) => void;
|
||||||
buildDataCenter: (name: string, location: DataCenter['location']) => void;
|
buildDataCenter: (name: string, location: DataCenter['location']) => void;
|
||||||
startTraining: (job: Omit<TrainingJob, 'progressTicks'>) => 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;
|
setProductPricing: (productLineId: string, field: string, value: number) => void;
|
||||||
toggleProductLine: (productLineId: string) => void;
|
toggleProductLine: (productLineId: string) => void;
|
||||||
updateState: (partial: Partial<GameState>) => 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: {
|
models: {
|
||||||
...s.models,
|
...s.models,
|
||||||
trainedModels: s.models.trainedModels.map(m =>
|
trainedModels: s.models.trainedModels.map(m =>
|
||||||
m.id === modelId ? { ...m, isDeployed: true } : m,
|
m.id === modelId ? { ...m, isDeployed: true } : m,
|
||||||
),
|
),
|
||||||
productLines: s.models.productLines.map(pl =>
|
productLines: s.models.productLines.map(pl => ({
|
||||||
pl.id === productLineId ? { ...pl, modelId, isActive: true } : pl,
|
...pl, modelId, isActive: true,
|
||||||
),
|
})),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi
|
|||||||
const lostSubs = consumers.totalSubscribers * churnRate;
|
const lostSubs = consumers.totalSubscribers * churnRate;
|
||||||
consumers.totalSubscribers = Math.max(0, consumers.totalSubscribers + newSubs - lostSubs);
|
consumers.totalSubscribers = Math.max(0, consumers.totalSubscribers + newSubs - lostSubs);
|
||||||
|
|
||||||
if (consumers.totalSubscribers < 50 && modelQuality > 0.1) {
|
if (consumers.totalSubscribers < 100 && modelQuality > 0.1) {
|
||||||
consumers.totalSubscribers += 2 + modelQuality * 5;
|
consumers.totalSubscribers += 5 + modelQuality * 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPenalty = compute.inferenceUtilization > 0.9
|
const loadPenalty = compute.inferenceUtilization > 0.9
|
||||||
@@ -51,7 +51,7 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi
|
|||||||
|
|
||||||
consumers.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
|
consumers.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
|
||||||
|
|
||||||
subscriptionRevenue = consumers.totalSubscribers * (chatProduct.pricing.subscriptionPrice / 2592000);
|
subscriptionRevenue = consumers.totalSubscribers * (chatProduct.pricing.subscriptionPrice / 86400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- B2B API market (organic demand based on model quality + reputation) ---
|
// --- B2B API market (organic demand based on model quality + reputation) ---
|
||||||
@@ -65,7 +65,7 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi
|
|||||||
const priceFactor = Math.max(0.1, 1 - (textApi.pricing.outputTokenPrice / 20));
|
const priceFactor = Math.max(0.1, 1 - (textApi.pricing.outputTokenPrice / 20));
|
||||||
|
|
||||||
organicApiTokens = Math.floor(
|
organicApiTokens = Math.floor(
|
||||||
qualityFactor * reputationFactor * priceFactor * 50000 * (1 + state.meta.tickCount * 0.0001),
|
qualityFactor * reputationFactor * priceFactor * 10_000_000 * (1 + state.meta.tickCount * 0.0001),
|
||||||
);
|
);
|
||||||
|
|
||||||
let contractTokens = 0;
|
let contractTokens = 0;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function createTrainedModel(
|
|||||||
dataTokens: number,
|
dataTokens: number,
|
||||||
state: GameState,
|
state: GameState,
|
||||||
): TrainedModel {
|
): TrainedModel {
|
||||||
const computeFactor = Math.log10(1 + compute) * 15;
|
const computeFactor = Math.sqrt(compute) * 5;
|
||||||
const dataFactor = Math.log10(1 + dataTokens / 1e8) * 10;
|
const dataFactor = Math.log10(1 + dataTokens / 1e8) * 10;
|
||||||
const researchBonus = state.research.completedResearch.length * 3;
|
const researchBonus = state.research.completedResearch.length * 3;
|
||||||
const efficiencyBonus = state.research.completedResearch.filter(r => r.includes('efficiency')).length * 5;
|
const efficiencyBonus = state.research.completedResearch.filter(r => r.includes('efficiency')).length * 5;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const MAX_REPUTATION_HISTORY = 500;
|
|||||||
|
|
||||||
export const STARTING_MONEY = 50_000;
|
export const STARTING_MONEY = 50_000;
|
||||||
export const BASE_ENERGY_COST_PER_FLOP = 0.001;
|
export const BASE_ENERGY_COST_PER_FLOP = 0.001;
|
||||||
export const BASE_MAINTENANCE_PER_GPU = 0.5;
|
export const BASE_MAINTENANCE_PER_GPU = 0.005;
|
||||||
|
|
||||||
export const TRAINING_BASE_TICKS = 120;
|
export const TRAINING_BASE_TICKS = 120;
|
||||||
export const TRAINING_COMPUTE_MULTIPLIER = 0.8;
|
export const TRAINING_COMPUTE_MULTIPLIER = 0.8;
|
||||||
|
|||||||
Reference in New Issue
Block a user