Overhaul market system with shared TAM competition, multi-tier pricing, enterprise pipeline, and developer ecosystem
CI / build-and-push (push) Successful in 42s
CI / build-and-push (push) Successful in 42s
Replaces the simplified single-subscriber market with a full competitive simulation: shared TAM with softmax market shares across 4 segments, multi-tier consumer subscriptions (Free/Plus/Pro/Team) and API tiers (Free/PAYG/Scale/Enterprise), enterprise sales pipeline (Lead→Qualification→POC→Negotiation→Active→Renewal) with SLA tracking, developer ecosystem flywheel, technology obsolescence pressure, seasonal demand cycles, and two new product lines (Code Assistant, AI Agents Platform). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -90,9 +90,9 @@ function triggerMarketBoom(multiplier: number) {
|
||||
useGameStore.setState((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
consumers: {
|
||||
...s.market.consumers,
|
||||
totalSubscribers: Math.round(s.market.consumers.totalSubscribers * multiplier),
|
||||
consumerTiers: {
|
||||
...s.market.consumerTiers,
|
||||
totalUsers: Math.round(s.market.consumerTiers.totalUsers * multiplier),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -75,10 +75,9 @@ export function StateInspectionTab() {
|
||||
</Section>
|
||||
|
||||
<Section title="Market">
|
||||
<Stat label="Subscribers" value={formatNumber(market.consumers.totalSubscribers)} />
|
||||
<Stat label="Satisfaction" value={formatPercent(market.consumers.satisfaction)} />
|
||||
<Stat label="Growth/tick" value={market.consumers.growthRatePerTick.toFixed(4)} />
|
||||
<Stat label="Churn/tick" value={market.consumers.churnRatePerTick.toFixed(4)} />
|
||||
<Stat label="Subscribers" value={formatNumber(market.consumerTiers.totalUsers)} />
|
||||
<Stat label="Satisfaction" value={formatPercent(market.consumerTiers.satisfaction)} />
|
||||
<Stat label="Viral Coeff" value={market.consumerTiers.viralCoefficient.toFixed(4)} />
|
||||
<Stat label="Contracts" value={market.enterprise.activeContracts.length} />
|
||||
<Stat label="API tok/tick" value={formatNumber(market.enterprise.totalApiCallsPerTick)} />
|
||||
</Section>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
|
||||
const money = useGameStore((s) => s.economy.money);
|
||||
const totalRevenue = useGameStore((s) => s.economy.totalRevenue);
|
||||
const valuation = useGameStore((s) => s.economy.funding.valuation);
|
||||
const subscribers = useGameStore((s) => s.market.consumers.totalSubscribers);
|
||||
const subscribers = useGameStore((s) => s.market.consumerTiers.totalUsers);
|
||||
const models = useGameStore((s) => s.models.baseModels.length);
|
||||
const bestModel = useGameStore((s) => s.models.bestDeployedModelScore);
|
||||
const reputation = useGameStore((s) => s.reputation.score);
|
||||
|
||||
@@ -15,7 +15,7 @@ export function DashboardPage() {
|
||||
const totalDCs = useGameStore((s) => s.infrastructure.totalDataCenterCount);
|
||||
const baseModels = useGameStore((s) => s.models.baseModels);
|
||||
const activePipelines = useGameStore((s) => s.models.activeTrainingPipelines);
|
||||
const subscribers = useGameStore((s) => s.market.consumers.totalSubscribers);
|
||||
const subscribers = useGameStore((s) => s.market.consumerTiers.totalUsers);
|
||||
const reputation = useGameStore((s) => s.reputation.score);
|
||||
const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization);
|
||||
const financialHistory = useGameStore((s) => s.economy.financialHistory);
|
||||
@@ -75,7 +75,7 @@ export function DashboardPage() {
|
||||
icon={Users}
|
||||
label="Subscribers"
|
||||
value={formatNumber(subscribers)}
|
||||
subValue={`Satisfaction: ${formatPercent(useGameStore.getState().market.consumers.satisfaction)}`}
|
||||
subValue={`Satisfaction: ${formatPercent(useGameStore.getState().market.consumerTiers.satisfaction)}`}
|
||||
color="text-orange-400"
|
||||
onClick={() => useGameStore.getState().setActivePage('market')}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function FinancePage() {
|
||||
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 subscribers = useGameStore((s) => s.market.consumerTiers.totalUsers);
|
||||
const reputationScore = useGameStore((s) => s.reputation.score);
|
||||
|
||||
const state = useGameStore.getState();
|
||||
|
||||
@@ -2,10 +2,27 @@ 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, Check } from 'lucide-react';
|
||||
import { Users, Zap, Shield, Settings2, Check } from 'lucide-react';
|
||||
import { TutorialHint } from '@/components/game/TutorialHint';
|
||||
import { MarketOverviewPanel } from './market/MarketOverviewPanel';
|
||||
import { ConsumerTiersPanel } from './market/ConsumerTiersPanel';
|
||||
import { ApiTiersPanel } from './market/ApiTiersPanel';
|
||||
import { EnterprisePipelinePanel } from './market/EnterprisePipelinePanel';
|
||||
import { DeveloperEcosystemPanel } from './market/DeveloperEcosystemPanel';
|
||||
import { ProductLinesPanel } from './market/ProductLinesPanel';
|
||||
|
||||
type MarketTab = 'overview' | 'consumer' | 'api' | 'enterprise' | 'ecosystem' | 'products' | 'settings';
|
||||
|
||||
const TABS: { id: MarketTab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'consumer', label: 'Consumer' },
|
||||
{ id: 'api', label: 'API' },
|
||||
{ id: 'enterprise', label: 'Enterprise' },
|
||||
{ id: 'ecosystem', label: 'Dev Ecosystem' },
|
||||
{ id: 'products', label: 'Products' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
function useAppliedFeedback() {
|
||||
const [show, setShow] = useState(false);
|
||||
@@ -28,67 +45,21 @@ function AppliedBadge({ visible }: { visible: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketPage() {
|
||||
const consumers = useGameStore((s) => s.market.consumers);
|
||||
const enterprise = useGameStore((s) => s.market.enterprise);
|
||||
function SettingsPanel() {
|
||||
const overloadPolicy = useGameStore((s) => s.market.overloadPolicy);
|
||||
const productLines = useGameStore((s) => s.models.productLines);
|
||||
const inferenceUtil = useGameStore((s) => s.compute.inferenceUtilization);
|
||||
const tokensCapacity = useGameStore((s) => s.compute.tokensPerSecondCapacity);
|
||||
const tokensDemand = useGameStore((s) => s.compute.tokensPerSecondDemand);
|
||||
const currentEra = useGameStore((s) => s.meta.currentEra);
|
||||
const reputationScore = useGameStore((s) => s.reputation.score);
|
||||
const bestQuality = useGameStore((s) => s.models.bestDeployedModelScore / 100);
|
||||
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)
|
||||
* (1 + (reputationScore / 100) * MARKET_CAP_REPUTATION_BONUS);
|
||||
const saturation = effectiveCap > 0 ? consumers.totalSubscribers / effectiveCap : 0;
|
||||
|
||||
const chatProduct = productLines.find(p => p.type === 'chat-product');
|
||||
const textApi = productLines.find(p => p.type === 'text-api');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users size={16} className="text-orange-400" />
|
||||
<span className="text-xs text-surface-400 uppercase">Subscribers</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-mono">{formatNumber(consumers.totalSubscribers)}</div>
|
||||
<div className="text-xs text-surface-400 mt-1">
|
||||
Growth: {formatPercent(consumers.growthRatePerTick)}/s
|
||||
{' '}Churn: {formatPercent(consumers.churnRatePerTick)}/s
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield size={16} className="text-green-400" />
|
||||
<span className="text-xs text-surface-400 uppercase">Satisfaction</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-mono">{formatPercent(consumers.satisfaction)}</div>
|
||||
<div className="h-1.5 bg-surface-800 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${consumers.satisfaction > 0.7 ? 'bg-success' : consumers.satisfaction > 0.4 ? 'bg-warning' : 'bg-danger'}`}
|
||||
style={{ width: `${consumers.satisfaction * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap size={16} className="text-blue-400" />
|
||||
<span className="text-xs text-surface-400 uppercase">Load</span>
|
||||
<span className="text-xs text-surface-400 uppercase">Inference Load</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-mono">{formatPercent(inferenceUtil)}</div>
|
||||
<div className="text-xs text-surface-400 mt-1">
|
||||
@@ -97,85 +68,18 @@ export function MarketPage() {
|
||||
</div>
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp size={16} className="text-purple-400" />
|
||||
<span className="text-xs text-surface-400 uppercase">Market Saturation</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-mono">{formatPercent(saturation)}</div>
|
||||
<div className="text-xs text-surface-400 mt-1">
|
||||
Cap: {formatNumber(effectiveCap)} ({currentEra})
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-800 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${saturation > 0.9 ? 'bg-danger' : saturation > 0.7 ? 'bg-warning' : 'bg-accent'}`}
|
||||
style={{ width: `${Math.min(100, saturation * 100)}%` }}
|
||||
/>
|
||||
<Users size={16} className="text-orange-400" />
|
||||
<span className="text-xs text-surface-400 uppercase">Subscribers</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold font-mono">{formatNumber(useGameStore.getState().market.consumerTiers.totalUsers)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{chatProduct && (
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">Chat Product Pricing</h3>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${chatProduct.isActive ? 'bg-success/20 text-success' : 'bg-surface-700 text-surface-400'}`}>
|
||||
{chatProduct.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<span className="text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
value={chatProduct.pricing.subscriptionPrice}
|
||||
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}
|
||||
/>
|
||||
<span className="text-xs text-surface-400">/month</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield size={16} className="text-green-400" />
|
||||
<span className="text-xs text-surface-400 uppercase">Satisfaction</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{textApi && (
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">API Pricing</h3>
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${textApi.isActive ? 'bg-success/20 text-success' : 'bg-surface-700 text-surface-400'}`}>
|
||||
{textApi.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">Input ($/M tokens) <AppliedBadge visible={pricingFeedback.show} /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={textApi.pricing.inputTokenPrice}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">Output ($/M tokens)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={textApi.pricing.outputTokenPrice}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xl font-bold font-mono">{formatPercent(useGameStore.getState().market.consumerTiers.satisfaction)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-3">
|
||||
@@ -233,25 +137,44 @@ export function MarketPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-3">
|
||||
<h3 className="font-semibold">Enterprise Contracts</h3>
|
||||
{enterprise.activeContracts.length === 0 ? (
|
||||
<p className="text-sm text-surface-500">No enterprise contracts yet. Improve your model quality and reputation to attract enterprise customers.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{enterprise.activeContracts.map(c => (
|
||||
<div key={c.id} className="bg-surface-800 rounded-lg p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{c.customerName}</div>
|
||||
<div className="text-xs text-surface-400">{formatNumber(c.tokensPerTick)} tok/s · SLA: {formatPercent(c.slaUptime)}</div>
|
||||
</div>
|
||||
<div className="text-sm font-mono text-success">{formatMoney(c.pricePerMToken)}/M tok</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketPage() {
|
||||
const [activeTab, setActiveTab] = useState<MarketTab>('overview');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">Market</h2>
|
||||
|
||||
<TutorialHint id="market-intro">
|
||||
Manage your multi-tier pricing, enterprise pipeline, and developer ecosystem. Watch market share — you're competing against 3 AI rivals for customers across every segment.
|
||||
</TutorialHint>
|
||||
|
||||
<div className="flex gap-1 border-b border-surface-700 pb-px">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm rounded-t-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-surface-800 text-surface-100 border-b-2 border-accent'
|
||||
: 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && <MarketOverviewPanel />}
|
||||
{activeTab === 'consumer' && <ConsumerTiersPanel />}
|
||||
{activeTab === 'api' && <ApiTiersPanel />}
|
||||
{activeTab === 'enterprise' && <EnterprisePipelinePanel />}
|
||||
{activeTab === 'ecosystem' && <DeveloperEcosystemPanel />}
|
||||
{activeTab === 'products' && <ProductLinesPanel />}
|
||||
{activeTab === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { ApiTierId } from '@ai-tycoon/shared';
|
||||
import { Code, Check } from 'lucide-react';
|
||||
|
||||
const TIER_ORDER: ApiTierId[] = ['free', 'payg', 'scale', 'enterprise-api'];
|
||||
const TIER_COLORS: Record<ApiTierId, string> = {
|
||||
free: 'border-surface-500',
|
||||
payg: 'border-green-500',
|
||||
scale: 'border-blue-500',
|
||||
'enterprise-api': 'border-purple-500',
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function ApiTiersPanel() {
|
||||
const apiTiers = useGameStore((s) => s.market.apiTiers);
|
||||
const setApiTierPrice = useGameStore((s) => s.setApiTierPrice);
|
||||
const toggleApiTier = useGameStore((s) => s.toggleApiTier);
|
||||
const feedback = useAppliedFeedback();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code size={16} className="text-blue-400" />
|
||||
<span className="text-sm font-semibold">API Tiers</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-surface-400">
|
||||
<span>Developers: <span className="font-mono text-surface-200">{formatNumber(apiTiers.totalDevelopers)}</span></span>
|
||||
<span>Tokens/s: <span className="font-mono text-surface-200">{formatNumber(apiTiers.totalTokensPerTick)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{TIER_ORDER.map(tierId => {
|
||||
const tier = apiTiers.tiers[tierId];
|
||||
return (
|
||||
<div key={tierId} className={`bg-surface-900 border-t-2 ${TIER_COLORS[tierId]} border border-surface-700 rounded-xl p-4 space-y-3`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold text-sm">{tier.config.name}</h4>
|
||||
{tierId !== 'free' && (
|
||||
<button
|
||||
onClick={() => { toggleApiTier(tierId); feedback.trigger(); }}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full ${tier.config.isActive ? 'bg-success/20 text-success' : 'bg-surface-700 text-surface-400'}`}
|
||||
>
|
||||
{tier.config.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
)}
|
||||
{tierId === 'free' && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">Always On</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-2xl font-bold font-mono">{formatNumber(tier.developerCount)}</div>
|
||||
<div className="text-xs text-surface-400">developers</div>
|
||||
|
||||
{tierId !== 'free' ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-[10px] text-surface-400 mb-1">
|
||||
Monthly Fee ($)
|
||||
{feedback.show && <span className="inline-flex items-center gap-0.5 text-success ml-1 animate-pulse"><Check size={8} /></span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tier.config.monthlyFee}
|
||||
onChange={(e) => { setApiTierPrice(tierId, 'monthlyFee', Number(e.target.value)); feedback.trigger(); }}
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
min={0}
|
||||
step={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-[10px] text-surface-400 mb-1">In $/M</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tier.config.inputTokenPrice}
|
||||
onChange={(e) => { setApiTierPrice(tierId, 'inputTokenPrice', Number(e.target.value)); feedback.trigger(); }}
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-surface-400 mb-1">Out $/M</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tier.config.outputTokenPrice}
|
||||
onChange={(e) => { setApiTierPrice(tierId, 'outputTokenPrice', Number(e.target.value)); feedback.trigger(); }}
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-surface-500">Free tier attracts developers to your ecosystem</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs space-y-1 text-surface-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Rate Limit</span>
|
||||
<span className="font-mono">{formatNumber(tier.config.rateLimit)} req/min</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Tokens/s</span>
|
||||
<span className="font-mono">{formatNumber(tier.tokensPerTick)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { ConsumerTierId } from '@ai-tycoon/shared';
|
||||
import { Users, Check } from 'lucide-react';
|
||||
|
||||
const TIER_ORDER: ConsumerTierId[] = ['free', 'plus', 'pro', 'team'];
|
||||
const TIER_COLORS: Record<ConsumerTierId, string> = {
|
||||
free: 'border-surface-500',
|
||||
plus: 'border-blue-500',
|
||||
pro: 'border-purple-500',
|
||||
team: 'border-orange-500',
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function ConsumerTiersPanel() {
|
||||
const consumerTiers = useGameStore((s) => s.market.consumerTiers);
|
||||
const setConsumerTierPrice = useGameStore((s) => s.setConsumerTierPrice);
|
||||
const toggleConsumerTier = useGameStore((s) => s.toggleConsumerTier);
|
||||
const feedback = useAppliedFeedback();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={16} className="text-orange-400" />
|
||||
<span className="text-sm font-semibold">Consumer Subscriptions</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-surface-400">
|
||||
<span>Total: <span className="font-mono text-surface-200">{formatNumber(consumerTiers.totalUsers)}</span></span>
|
||||
<span>Satisfaction: <span className={`font-mono ${consumerTiers.satisfaction > 0.7 ? 'text-success' : consumerTiers.satisfaction > 0.4 ? 'text-warning' : 'text-danger'}`}>{formatPercent(consumerTiers.satisfaction)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{TIER_ORDER.map(tierId => {
|
||||
const tier = consumerTiers.tiers[tierId];
|
||||
const revenue = tierId === 'free' ? 0 : tier.userCount * tier.config.price / 86400;
|
||||
return (
|
||||
<div key={tierId} className={`bg-surface-900 border-t-2 ${TIER_COLORS[tierId]} border border-surface-700 rounded-xl p-4 space-y-3`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold text-sm">{tier.config.name}</h4>
|
||||
{tierId !== 'free' && (
|
||||
<button
|
||||
onClick={() => { toggleConsumerTier(tierId); feedback.trigger(); }}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full ${tier.config.isActive ? 'bg-success/20 text-success' : 'bg-surface-700 text-surface-400'}`}
|
||||
>
|
||||
{tier.config.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
)}
|
||||
{tierId === 'free' && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">Always On</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-2xl font-bold font-mono">{formatNumber(tier.userCount)}</div>
|
||||
<div className="text-xs text-surface-400">users</div>
|
||||
|
||||
{tierId !== 'free' && (
|
||||
<div>
|
||||
<label className="block text-[10px] text-surface-400 mb-1">
|
||||
Price ($/mo)
|
||||
{feedback.show && <span className="inline-flex items-center gap-0.5 text-success ml-1 animate-pulse"><Check size={8} /></span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tier.config.price}
|
||||
onChange={(e) => { setConsumerTierPrice(tierId, Number(e.target.value)); feedback.trigger(); }}
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
min={1}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs space-y-1 text-surface-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Tokens/mo</span>
|
||||
<span className="font-mono">{formatNumber(tier.config.tokenAllowance)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Churn</span>
|
||||
<span className="font-mono">{formatPercent(tier.churnRate)}/t</span>
|
||||
</div>
|
||||
{tierId !== 'free' && (
|
||||
<div className="flex justify-between">
|
||||
<span>Revenue/s</span>
|
||||
<span className="font-mono text-success">{formatMoney(revenue)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import { Boxes, Check } from 'lucide-react';
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function DeveloperEcosystemPanel() {
|
||||
const devEco = useGameStore((s) => s.market.developerEcosystem);
|
||||
const setDevRelSpending = useGameStore((s) => s.setDevRelSpending);
|
||||
const feedback = useAppliedFeedback();
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Community Size', value: formatNumber(devEco.communitySize), sub: `Growth: ${devEco.communityGrowthRate.toFixed(2)}/t` },
|
||||
{ label: 'Active Developers', value: formatNumber(devEco.activeDevelopers), sub: `${devEco.communitySize > 0 ? formatPercent(devEco.activeDevelopers / devEco.communitySize) : '0%'} active` },
|
||||
{ label: 'SDK Coverage', value: formatPercent(devEco.sdkCoverage), sub: 'Hire engineers to improve' },
|
||||
{ label: 'Doc Quality', value: formatPercent(devEco.documentationQuality), sub: 'Increase dev-rel spending' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes size={16} className="text-green-400" />
|
||||
<span className="text-sm font-semibold">Developer Ecosystem</span>
|
||||
</div>
|
||||
<div className="text-xs text-surface-400">
|
||||
Ecosystem Score: <span className="font-mono text-surface-200 text-sm">{devEco.ecosystemScore.toFixed(1)}</span>/100
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{metrics.map(m => (
|
||||
<div key={m.label} className="bg-surface-900 border border-surface-700 rounded-xl p-3">
|
||||
<div className="text-[10px] text-surface-400 uppercase mb-1">{m.label}</div>
|
||||
<div className="text-lg font-bold font-mono">{m.value}</div>
|
||||
<div className="text-[10px] text-surface-500 mt-0.5">{m.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<h4 className="text-sm font-semibold mb-3">Flywheel Effects</h4>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between text-surface-300">
|
||||
<span>Startups using your API</span>
|
||||
<span className="font-mono">{formatNumber(devEco.startupsAdopted)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-surface-300">
|
||||
<span>Enterprise referrals generated</span>
|
||||
<span className="font-mono">{formatNumber(devEco.enterpriseReferrals)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-surface-300">
|
||||
<span>Open-source contributions</span>
|
||||
<span className="font-mono">{devEco.openSourceContributions}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-2 bg-surface-800 rounded text-[10px] text-surface-400">
|
||||
Free tier generosity attracts devs, who build startups on your API, which generates enterprise referrals.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<h4 className="text-sm font-semibold mb-3">
|
||||
Dev-Rel Budget
|
||||
{feedback.show && <span className="inline-flex items-center gap-0.5 text-success ml-2 animate-pulse text-[10px]"><Check size={8} /> Applied</span>}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">Spending ($/tick)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={devEco.devRelSpending}
|
||||
onChange={(e) => { setDevRelSpending(Number(e.target.value)); feedback.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.1}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-surface-500 space-y-1">
|
||||
<p>Dev-rel spending improves documentation quality and community engagement.</p>
|
||||
<p>Current cost: <span className="font-mono text-surface-300">{formatMoney(devEco.devRelSpending)}/s</span></p>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-green-500"
|
||||
style={{ width: `${Math.min(100, devEco.ecosystemScore)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { EnterprisePipelineStage, EnterpriseSegment } from '@ai-tycoon/shared';
|
||||
import { Building2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const STAGE_ORDER: EnterprisePipelineStage[] = ['lead', 'qualification', 'poc', 'negotiation'];
|
||||
const STAGE_LABELS: Record<EnterprisePipelineStage, string> = {
|
||||
lead: 'Leads',
|
||||
qualification: 'Qualification',
|
||||
poc: 'POC',
|
||||
negotiation: 'Negotiation',
|
||||
};
|
||||
const STAGE_COLORS: Record<EnterprisePipelineStage, string> = {
|
||||
lead: 'bg-surface-600',
|
||||
qualification: 'bg-blue-600',
|
||||
poc: 'bg-purple-600',
|
||||
negotiation: 'bg-orange-600',
|
||||
};
|
||||
|
||||
const SEGMENT_BADGES: Record<EnterpriseSegment, { label: string; color: string }> = {
|
||||
startup: { label: 'Startup', color: 'bg-green-500/20 text-green-400' },
|
||||
'mid-market': { label: 'Mid-Market', color: 'bg-blue-500/20 text-blue-400' },
|
||||
enterprise: { label: 'Enterprise', color: 'bg-purple-500/20 text-purple-400' },
|
||||
government: { label: 'Gov', color: 'bg-yellow-500/20 text-yellow-400' },
|
||||
};
|
||||
|
||||
export function EnterprisePipelinePanel() {
|
||||
const enterprise = useGameStore((s) => s.market.enterprise);
|
||||
const tickCount = useGameStore((s) => s.meta.tickCount);
|
||||
|
||||
const leadsByStage = STAGE_ORDER.map(stage => ({
|
||||
stage,
|
||||
leads: enterprise.pipeline.filter(l => l.stage === stage),
|
||||
}));
|
||||
|
||||
const totalContractValue = enterprise.activeContracts.reduce((sum, c) => sum + c.pricePerMToken * c.tokensPerTick, 0);
|
||||
const totalSlaViolations = enterprise.activeContracts.reduce((sum, c) => sum + c.slaViolations, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 size={16} className="text-purple-400" />
|
||||
<span className="text-sm font-semibold">Enterprise Pipeline</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-surface-400">
|
||||
<span>Leads: <span className="font-mono text-surface-200">{enterprise.pipeline.length}</span></span>
|
||||
<span>Contracts: <span className="font-mono text-surface-200">{enterprise.activeContracts.length}</span></span>
|
||||
<span>Lead Rate: <span className="font-mono text-surface-200">{enterprise.leadGenerationRate.toFixed(3)}/t</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{leadsByStage.map(({ stage, leads }) => (
|
||||
<div key={stage} className="bg-surface-900 border border-surface-700 rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={`w-2 h-2 rounded-full ${STAGE_COLORS[stage]}`} />
|
||||
<span className="text-xs font-semibold">{STAGE_LABELS[stage]}</span>
|
||||
<span className="text-xs text-surface-500 ml-auto">{leads.length}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{leads.length === 0 && (
|
||||
<div className="text-xs text-surface-600 italic">No leads</div>
|
||||
)}
|
||||
{leads.map(lead => (
|
||||
<div key={lead.id} className="bg-surface-800 rounded px-2 py-1.5 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-surface-200 truncate">{lead.companyName}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${SEGMENT_BADGES[lead.segment].color}`}>
|
||||
{SEGMENT_BADGES[lead.segment].label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-surface-400 mt-0.5">
|
||||
<span>{formatMoney(lead.dealValue)}/yr</span>
|
||||
<span>Win: {formatPercent(lead.winProbability)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold">Active Contracts</span>
|
||||
{totalSlaViolations > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-warning">
|
||||
<AlertTriangle size={12} /> {totalSlaViolations} SLA violations
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{enterprise.activeContracts.length === 0 ? (
|
||||
<p className="text-xs text-surface-500">No active contracts. Build your sales team and model quality to attract enterprise customers.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-surface-400 border-b border-surface-700">
|
||||
<th className="text-left py-1.5 pr-3">Customer</th>
|
||||
<th className="text-left py-1.5 px-2">Segment</th>
|
||||
<th className="text-right py-1.5 px-2">Tokens/s</th>
|
||||
<th className="text-right py-1.5 px-2">$/M tok</th>
|
||||
<th className="text-right py-1.5 px-2">SLA</th>
|
||||
<th className="text-right py-1.5 px-2">Satisfaction</th>
|
||||
<th className="text-right py-1.5 px-2">Remaining</th>
|
||||
<th className="text-right py-1.5 pl-2">Renewal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enterprise.activeContracts.map(c => {
|
||||
const remaining = Math.max(0, c.startTick + c.durationTicks - tickCount);
|
||||
const uptime = c.totalTicks > 0 ? c.uptimeTicks / c.totalTicks : 1;
|
||||
return (
|
||||
<tr key={c.id} className="border-b border-surface-800">
|
||||
<td className="py-1.5 pr-3 font-medium text-surface-200">{c.customerName}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${SEGMENT_BADGES[c.segment].color}`}>
|
||||
{SEGMENT_BADGES[c.segment].label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-right py-1.5 px-2 font-mono">{formatNumber(c.tokensPerTick)}</td>
|
||||
<td className="text-right py-1.5 px-2 font-mono text-success">{formatMoney(c.pricePerMToken)}</td>
|
||||
<td className="text-right py-1.5 px-2 font-mono">
|
||||
<span className={uptime >= c.slaUptime ? 'text-success' : 'text-danger'}>
|
||||
{formatPercent(uptime)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-right py-1.5 px-2 font-mono">{formatPercent(c.satisfaction)}</td>
|
||||
<td className="text-right py-1.5 px-2 font-mono">{formatNumber(remaining)}t</td>
|
||||
<td className="text-right py-1.5 pl-2 font-mono">{formatPercent(c.renewalProbability)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { TAMSegmentId } from '@ai-tycoon/shared';
|
||||
import { Globe, TrendingUp, Clock, Thermometer } from 'lucide-react';
|
||||
|
||||
const SEGMENT_LABELS: Record<TAMSegmentId, string> = {
|
||||
consumer: 'Consumer',
|
||||
developer: 'Developer',
|
||||
enterprise: 'Enterprise',
|
||||
government: 'Government',
|
||||
};
|
||||
|
||||
const SEGMENT_COLORS: Record<TAMSegmentId, string> = {
|
||||
consumer: 'bg-orange-500',
|
||||
developer: 'bg-blue-500',
|
||||
enterprise: 'bg-purple-500',
|
||||
government: 'bg-green-500',
|
||||
};
|
||||
|
||||
const SEASON_LABELS: Record<string, string> = {
|
||||
q1: 'Q1 — Slow Start',
|
||||
q2: 'Q2 — Baseline',
|
||||
q3: 'Q3 — Summer Dip',
|
||||
q4: 'Q4 — Budget Surge',
|
||||
};
|
||||
|
||||
export function MarketOverviewPanel() {
|
||||
const tam = useGameStore((s) => s.market.tam);
|
||||
const seasonalPhase = useGameStore((s) => s.market.seasonalPhase);
|
||||
const seasonalMultiplier = useGameStore((s) => s.market.seasonalMultiplier);
|
||||
const obsolescence = useGameStore((s) => s.market.obsolescence);
|
||||
const bestScore = useGameStore((s) => s.models.bestDeployedModelScore);
|
||||
const competitors = useGameStore((s) => s.competitors.rivals);
|
||||
|
||||
const segments = Object.entries(tam.segments) as [TAMSegmentId, typeof tam.segments.consumer][];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe size={16} className="text-accent" />
|
||||
<span className="text-sm font-semibold">Market Share by Segment</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{segments.map(([id, seg]) => {
|
||||
const playerShare = seg.shares.find(s => s.playerId === 'player');
|
||||
const share = playerShare?.sharePercent ?? 0;
|
||||
return (
|
||||
<div key={id}>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-surface-300">{SEGMENT_LABELS[id]}</span>
|
||||
<span className="font-mono">
|
||||
{formatPercent(share)} · {formatNumber(playerShare?.customers ?? 0)} customers
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-surface-800 rounded-full overflow-hidden flex">
|
||||
{seg.shares
|
||||
.filter(s => s.sharePercent > 0.001)
|
||||
.sort((a, b) => b.sharePercent - a.sharePercent)
|
||||
.map((s, i) => (
|
||||
<div
|
||||
key={s.playerId}
|
||||
className={`h-full ${s.playerId === 'player' ? SEGMENT_COLORS[id] : `bg-surface-${600 - i * 100}`}`}
|
||||
style={{ width: `${s.sharePercent * 100}%` }}
|
||||
title={`${s.playerId}: ${formatPercent(s.sharePercent)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock size={16} className="text-yellow-400" />
|
||||
<span className="text-sm font-semibold">Season</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold">{SEASON_LABELS[seasonalPhase] ?? seasonalPhase}</div>
|
||||
<div className="text-xs text-surface-400 mt-1">
|
||||
Demand multiplier: {formatPercent(seasonalMultiplier)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Thermometer size={16} className="text-red-400" />
|
||||
<span className="text-sm font-semibold">Technology Pressure</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-surface-400">Market Quality Baseline</span>
|
||||
<span className="font-mono">{(obsolescence.marketQualityBaseline * 100).toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-surface-400">Your Best Model</span>
|
||||
<span className="font-mono">{bestScore.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-surface-400">Model Freshness</span>
|
||||
<span className="font-mono">{formatPercent(obsolescence.playerModelFreshness)}</span>
|
||||
</div>
|
||||
{obsolescence.newModelBoostRemaining > 0 && (
|
||||
<div className="text-xs text-success mt-1">New model boost active!</div>
|
||||
)}
|
||||
{bestScore / 100 < obsolescence.marketQualityBaseline && (
|
||||
<div className="text-xs text-danger mt-1">Below market baseline — losing attractiveness</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp size={16} className="text-purple-400" />
|
||||
<span className="text-sm font-semibold">Competitive Landscape</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-surface-400 border-b border-surface-700">
|
||||
<th className="text-left py-2 pr-4">Competitor</th>
|
||||
<th className="text-right py-2 px-2">Consumer</th>
|
||||
<th className="text-right py-2 px-2">Developer</th>
|
||||
<th className="text-right py-2 px-2">Enterprise</th>
|
||||
<th className="text-right py-2 px-2">Freshness</th>
|
||||
<th className="text-right py-2 pl-2">Dev Eco</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-surface-800 text-accent">
|
||||
<td className="py-2 pr-4 font-medium">You</td>
|
||||
<td className="text-right py-2 px-2 font-mono">
|
||||
{formatPercent(tam.segments.consumer.shares.find(s => s.playerId === 'player')?.sharePercent ?? 0)}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 font-mono">
|
||||
{formatPercent(tam.segments.developer.shares.find(s => s.playerId === 'player')?.sharePercent ?? 0)}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 font-mono">
|
||||
{formatPercent(tam.segments.enterprise.shares.find(s => s.playerId === 'player')?.sharePercent ?? 0)}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 font-mono">
|
||||
{formatPercent(obsolescence.playerModelFreshness)}
|
||||
</td>
|
||||
<td className="text-right py-2 pl-2 font-mono">—</td>
|
||||
</tr>
|
||||
{competitors.filter(r => r.status === 'active').map(r => (
|
||||
<tr key={r.id} className="border-b border-surface-800">
|
||||
<td className="py-2 pr-4 font-medium text-surface-300">{r.name}</td>
|
||||
<td className="text-right py-2 px-2 font-mono">{formatPercent(r.marketShares.consumer)}</td>
|
||||
<td className="text-right py-2 px-2 font-mono">{formatPercent(r.marketShares.developer)}</td>
|
||||
<td className="text-right py-2 px-2 font-mono">{formatPercent(r.marketShares.enterprise)}</td>
|
||||
<td className="text-right py-2 px-2 font-mono">{formatPercent(r.modelFreshness)}</td>
|
||||
<td className="text-right py-2 pl-2 font-mono">{r.developerEcosystemScore.toFixed(0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import { Wrench, Bot, Check, Lock } from 'lucide-react';
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function ProductLinesPanel() {
|
||||
const codeAssistant = useGameStore((s) => s.market.codeAssistant);
|
||||
const agentsPlatform = useGameStore((s) => s.market.agentsPlatform);
|
||||
const setCodeAssistantPrice = useGameStore((s) => s.setCodeAssistantPrice);
|
||||
const toggleCodeAssistant = useGameStore((s) => s.toggleCodeAssistant);
|
||||
const setAgentsPlatformPrice = useGameStore((s) => s.setAgentsPlatformPrice);
|
||||
const toggleAgentsPlatform = useGameStore((s) => s.toggleAgentsPlatform);
|
||||
const feedback = useAppliedFeedback();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<span className="text-sm font-semibold">Product Lines</span>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className={`bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-3 ${!codeAssistant.isUnlocked ? 'opacity-60' : ''}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench size={16} className="text-cyan-400" />
|
||||
<h4 className="font-semibold text-sm">Code Assistant</h4>
|
||||
</div>
|
||||
{codeAssistant.isUnlocked ? (
|
||||
<button
|
||||
onClick={() => { toggleCodeAssistant(); feedback.trigger(); }}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full ${codeAssistant.isActive ? 'bg-success/20 text-success' : 'bg-surface-700 text-surface-400'}`}
|
||||
>
|
||||
{codeAssistant.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-[10px] text-surface-500">
|
||||
<Lock size={10} /> Research Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{codeAssistant.isUnlocked ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-[10px] text-surface-400 uppercase">Seats</div>
|
||||
<div className="text-lg font-bold font-mono">{formatNumber(codeAssistant.seats)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-surface-400 uppercase">Quality</div>
|
||||
<div className="text-lg font-bold font-mono">{codeAssistant.qualityScore.toFixed(0)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-surface-400 uppercase">Revenue/s</div>
|
||||
<div className="text-lg font-bold font-mono text-success">
|
||||
{formatMoney(codeAssistant.seats * codeAssistant.pricePerSeat / 86400)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-surface-400 mb-1">
|
||||
Price ($/seat/mo)
|
||||
{feedback.show && <span className="inline-flex items-center gap-0.5 text-success ml-1 animate-pulse"><Check size={8} /></span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={codeAssistant.pricePerSeat}
|
||||
onChange={(e) => { setCodeAssistantPrice(Number(e.target.value)); feedback.trigger(); }}
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
min={5}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-surface-500">
|
||||
Research "Code Assistant Product" to unlock. Requires the code-generation research.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-3 ${!agentsPlatform.isUnlocked ? 'opacity-60' : ''}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot size={16} className="text-orange-400" />
|
||||
<h4 className="font-semibold text-sm">AI Agents Platform</h4>
|
||||
</div>
|
||||
{agentsPlatform.isUnlocked ? (
|
||||
<button
|
||||
onClick={() => { toggleAgentsPlatform(); feedback.trigger(); }}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full ${agentsPlatform.isActive ? 'bg-success/20 text-success' : 'bg-surface-700 text-surface-400'}`}
|
||||
>
|
||||
{agentsPlatform.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-[10px] text-surface-500">
|
||||
<Lock size={10} /> Research Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{agentsPlatform.isUnlocked ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-[10px] text-surface-400 uppercase">Seats</div>
|
||||
<div className="text-lg font-bold font-mono">{formatNumber(agentsPlatform.seats)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-surface-400 uppercase">Quality</div>
|
||||
<div className="text-lg font-bold font-mono">{agentsPlatform.qualityScore.toFixed(0)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-surface-400 uppercase">Revenue/s</div>
|
||||
<div className="text-lg font-bold font-mono text-success">
|
||||
{formatMoney(agentsPlatform.seats * agentsPlatform.pricePerSeat / 86400)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-surface-400 mb-1">
|
||||
Price ($/seat/mo)
|
||||
{feedback.show && <span className="inline-flex items-center gap-0.5 text-success ml-1 animate-pulse"><Check size={8} /></span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={agentsPlatform.pricePerSeat}
|
||||
onChange={(e) => { setAgentsPlatformPrice(Number(e.target.value)); feedback.trigger(); }}
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
min={10}
|
||||
step={10}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-surface-500">
|
||||
Research "AI Agents Platform" to unlock. Requires the agentic-architecture research.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+131
-1
@@ -16,6 +16,7 @@ import type {
|
||||
ModelArchitecture,
|
||||
SFTSpecialization, QuantizationLevel, VariantCreationJob,
|
||||
EvalJob,
|
||||
ConsumerTierId, ApiTierId,
|
||||
} from '@ai-tycoon/shared';
|
||||
import {
|
||||
INITIAL_SETTINGS, SAVE_VERSION,
|
||||
@@ -125,6 +126,15 @@ interface Actions {
|
||||
openSourceModel: (modelId: string) => void;
|
||||
setOverloadPolicy: (policy: Partial<OverloadPolicy>) => void;
|
||||
acquireCompetitor: (competitorId: string) => void;
|
||||
setConsumerTierPrice: (tierId: ConsumerTierId, price: number) => void;
|
||||
toggleConsumerTier: (tierId: ConsumerTierId) => void;
|
||||
setApiTierPrice: (tierId: ApiTierId, field: 'monthlyFee' | 'inputTokenPrice' | 'outputTokenPrice', value: number) => void;
|
||||
toggleApiTier: (tierId: ApiTierId) => void;
|
||||
setDevRelSpending: (amount: number) => void;
|
||||
setCodeAssistantPrice: (price: number) => void;
|
||||
toggleCodeAssistant: () => void;
|
||||
setAgentsPlatformPrice: (price: number) => void;
|
||||
toggleAgentsPlatform: () => void;
|
||||
updateState: (partial: Partial<GameState>) => void;
|
||||
}
|
||||
|
||||
@@ -1205,6 +1215,126 @@ export const useGameStore = create<Store>()(
|
||||
};
|
||||
}),
|
||||
|
||||
setConsumerTierPrice: (tierId, price) => set((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
consumerTiers: {
|
||||
...s.market.consumerTiers,
|
||||
tiers: {
|
||||
...s.market.consumerTiers.tiers,
|
||||
[tierId]: {
|
||||
...s.market.consumerTiers.tiers[tierId],
|
||||
config: { ...s.market.consumerTiers.tiers[tierId].config, price },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
toggleConsumerTier: (tierId) => set((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
consumerTiers: {
|
||||
...s.market.consumerTiers,
|
||||
tiers: {
|
||||
...s.market.consumerTiers.tiers,
|
||||
[tierId]: {
|
||||
...s.market.consumerTiers.tiers[tierId],
|
||||
config: {
|
||||
...s.market.consumerTiers.tiers[tierId].config,
|
||||
isActive: !s.market.consumerTiers.tiers[tierId].config.isActive,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setApiTierPrice: (tierId, field, value) => set((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
apiTiers: {
|
||||
...s.market.apiTiers,
|
||||
tiers: {
|
||||
...s.market.apiTiers.tiers,
|
||||
[tierId]: {
|
||||
...s.market.apiTiers.tiers[tierId],
|
||||
config: { ...s.market.apiTiers.tiers[tierId].config, [field]: value },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
toggleApiTier: (tierId) => set((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
apiTiers: {
|
||||
...s.market.apiTiers,
|
||||
tiers: {
|
||||
...s.market.apiTiers.tiers,
|
||||
[tierId]: {
|
||||
...s.market.apiTiers.tiers[tierId],
|
||||
config: {
|
||||
...s.market.apiTiers.tiers[tierId].config,
|
||||
isActive: !s.market.apiTiers.tiers[tierId].config.isActive,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setDevRelSpending: (amount) => set((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
developerEcosystem: {
|
||||
...s.market.developerEcosystem,
|
||||
devRelSpending: amount,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setCodeAssistantPrice: (price) => set((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
codeAssistant: {
|
||||
...s.market.codeAssistant,
|
||||
pricePerSeat: price,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
toggleCodeAssistant: () => set((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
codeAssistant: {
|
||||
...s.market.codeAssistant,
|
||||
isActive: !s.market.codeAssistant.isActive,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setAgentsPlatformPrice: (price) => set((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
agentsPlatform: {
|
||||
...s.market.agentsPlatform,
|
||||
pricePerSeat: price,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
toggleAgentsPlatform: () => set((s) => ({
|
||||
market: {
|
||||
...s.market,
|
||||
agentsPlatform: {
|
||||
...s.market.agentsPlatform,
|
||||
isActive: !s.market.agentsPlatform.isActive,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
updateState: (partial) => set((s) => {
|
||||
const newState: Partial<Store> = {};
|
||||
for (const key of Object.keys(partial) as (keyof GameState)[]) {
|
||||
@@ -1234,7 +1364,7 @@ export const useGameStore = create<Store>()(
|
||||
notifications: [{
|
||||
id: uuid(),
|
||||
title: 'Save Reset',
|
||||
message: 'Your save was reset due to a major model system overhaul — multi-stage training pipelines, model families with variants, benchmarks, and architecture choices!',
|
||||
message: 'Your save was reset due to a major market system overhaul — shared TAM competition, multi-tier pricing, enterprise pipeline, developer ecosystem, and technology obsolescence!',
|
||||
type: 'info' as const,
|
||||
tick: 0,
|
||||
read: false,
|
||||
|
||||
Reference in New Issue
Block a user