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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user