Add Week 4 social features, regulation, and safety tradeoffs
Leaderboard page with category tabs and score submission, shareable company stats card with clipboard copy, dynamic regulation system (compliance costs scale with capability and era, regulatory standing tracks safety research), 6 geopolitical events (export controls, energy crisis, natural disaster, AI safety summit, immigration policy, data sovereignty), safety-capability tradeoff (safety score affects benchmark, low safety triggers incidents with reputation damage), and enhanced event consequence handling for regulation and talent types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared';
|
||||
import { Share2, Copy, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
||||
|
||||
export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const companyName = useGameStore((s) => s.meta.companyName);
|
||||
const era = useGameStore((s) => s.meta.currentEra);
|
||||
const totalPlayTime = useGameStore((s) => s.meta.totalPlayTime);
|
||||
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 models = useGameStore((s) => s.models.trainedModels.length);
|
||||
const bestModel = useGameStore((s) =>
|
||||
s.models.trainedModels.reduce((best, m) => Math.max(best, m.benchmarkScore), 0),
|
||||
);
|
||||
const reputation = useGameStore((s) => s.reputation.score);
|
||||
const achievements = useGameStore((s) => s.achievements.unlocked.length);
|
||||
const dataCenters = useGameStore((s) => s.infrastructure.dataCenters.length);
|
||||
|
||||
const eraLabel = era === 'startup' ? 'Startup' : era === 'scaleup' ? 'Scale-up' : era === 'bigtech' ? 'Big Tech' : 'AGI';
|
||||
const hours = Math.floor(totalPlayTime / 3600);
|
||||
const minutes = Math.floor((totalPlayTime % 3600) / 60);
|
||||
|
||||
const statsText = [
|
||||
`${companyName} — AI Tycoon`,
|
||||
`Era: ${eraLabel} | Playtime: ${hours}h ${minutes}m`,
|
||||
`Cash: ${formatMoney(money)} | Revenue: ${formatMoney(totalRevenue)}`,
|
||||
`Valuation: ${formatMoney(valuation)}`,
|
||||
`Subscribers: ${formatNumber(subscribers)} | Models: ${models}`,
|
||||
`Best Model: ${bestModel.toFixed(1)}/100 | Reputation: ${reputation}/100`,
|
||||
`Data Centers: ${dataCenters} | Achievements: ${achievements}/${ACHIEVEMENT_DEFINITIONS.length}`,
|
||||
].join('\n');
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(statsText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold flex items-center gap-2"><Share2 size={18} /> Company Stats</h3>
|
||||
<button onClick={onClose} className="text-surface-400 hover:text-surface-200 text-sm">Close</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-800 rounded-lg p-4 font-mono text-xs leading-relaxed whitespace-pre-line text-surface-200">
|
||||
{statsText}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="mt-4 w-full flex items-center justify-center gap-2 bg-accent hover:bg-accent-dark text-white rounded-lg py-2 text-sm font-medium"
|
||||
>
|
||||
{copied ? <><Check size={14} /> Copied!</> : <><Copy size={14} /> Copy to Clipboard</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { TalentPage } from '@/pages/TalentPage';
|
||||
import { DataPage } from '@/pages/DataPage';
|
||||
import { CompetitorsPage } from '@/pages/CompetitorsPage';
|
||||
import { AchievementsPage } from '@/pages/AchievementsPage';
|
||||
import { LeaderboardPage } from '@/pages/LeaderboardPage';
|
||||
|
||||
export function MainLayout() {
|
||||
const activePage = useGameStore((s) => s.activePage);
|
||||
@@ -45,6 +46,7 @@ function PageRouter({ page }: { page: string }) {
|
||||
case 'data': return <DataPage />;
|
||||
case 'competitors': return <CompetitorsPage />;
|
||||
case 'achievements': return <AchievementsPage />;
|
||||
case 'leaderboard': return <LeaderboardPage />;
|
||||
case 'settings': return <SettingsPage />;
|
||||
default: return <PlaceholderPage name={page} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
LayoutDashboard, Server, FlaskConical, Brain,
|
||||
TrendingUp, Users, Database, Swords, DollarSign, Settings, Trophy,
|
||||
TrendingUp, Users, Database, Swords, DollarSign, Settings, Trophy, Medal,
|
||||
} from 'lucide-react';
|
||||
import { useGameStore, type ActivePage } from '@/store';
|
||||
|
||||
@@ -16,6 +16,7 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard
|
||||
{ page: 'data', label: 'Data', icon: Database, era: 'scaleup' },
|
||||
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
|
||||
{ page: 'achievements', label: 'Achievements', icon: Trophy },
|
||||
{ page: 'leaderboard', label: 'Leaderboard', icon: Medal },
|
||||
{ page: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Pause, Play, Bell } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { Pause, Play, Bell, Share2 } from 'lucide-react';
|
||||
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { GameSpeed } from '@ai-tycoon/shared';
|
||||
@@ -21,6 +22,7 @@ export function TopBar() {
|
||||
const setGameSpeed = useGameStore((s) => s.setGameSpeed);
|
||||
const notifications = useGameStore((s) => s.notifications);
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
const [showStats, setShowStats] = useState(false);
|
||||
|
||||
const netIncome = revenuePerTick - expensesPerTick;
|
||||
|
||||
@@ -86,6 +88,14 @@ export function TopBar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowStats(true)}
|
||||
className="p-2 rounded hover:bg-surface-800 transition-colors"
|
||||
title="Share Stats"
|
||||
>
|
||||
<Share2 size={18} />
|
||||
</button>
|
||||
|
||||
<button className="relative p-2 rounded hover:bg-surface-800 transition-colors">
|
||||
<Bell size={18} />
|
||||
{unreadCount > 0 && (
|
||||
@@ -95,6 +105,8 @@ export function TopBar() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showStats && <CompanyStatsCard onClose={() => setShowStats(false)} />}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Trophy, Medal, Clock, TrendingUp } from 'lucide-react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
|
||||
import { api, getAuthToken } from '@/lib/api';
|
||||
|
||||
interface LeaderboardEntry {
|
||||
companyName: string;
|
||||
score: number;
|
||||
era: string;
|
||||
tickCount: number;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'revenue', label: 'Highest Revenue', icon: TrendingUp },
|
||||
{ id: 'fastest-agi', label: 'Fastest to AGI', icon: Clock },
|
||||
{ id: 'capability', label: 'Best Model', icon: Trophy },
|
||||
] as const;
|
||||
|
||||
export function LeaderboardPage() {
|
||||
const [category, setCategory] = useState('revenue');
|
||||
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const companyName = useGameStore((s) => s.meta.companyName);
|
||||
const totalRevenue = useGameStore((s) => s.economy.totalRevenue);
|
||||
const era = useGameStore((s) => s.meta.currentEra);
|
||||
const tickCount = useGameStore((s) => s.meta.tickCount);
|
||||
const bestModel = useGameStore((s) =>
|
||||
s.models.trainedModels.reduce((best, m) => Math.max(best, m.benchmarkScore), 0),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api.leaderboard.get(category)
|
||||
.then(data => setEntries(data.entries))
|
||||
.catch(() => setEntries([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [category]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!getAuthToken()) return;
|
||||
const scoreMap: Record<string, number> = {
|
||||
revenue: Math.floor(totalRevenue),
|
||||
'fastest-agi': era === 'agi' ? tickCount : 0,
|
||||
capability: Math.floor(bestModel * 10),
|
||||
};
|
||||
const score = scoreMap[category];
|
||||
if (score <= 0) return;
|
||||
try {
|
||||
await api.leaderboard.submit({ companyName, category, score, era, tickCount });
|
||||
setSubmitted(true);
|
||||
const data = await api.leaderboard.get(category);
|
||||
setEntries(data.entries);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Leaderboard</h2>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => { setCategory(cat.id); setSubmitted(false); }}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm border transition-colors ${
|
||||
category === cat.id
|
||||
? 'bg-accent/20 border-accent text-accent-light'
|
||||
: 'bg-surface-900 border-surface-700 text-surface-300 hover:border-surface-500'
|
||||
}`}
|
||||
>
|
||||
<cat.icon size={14} />
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{getAuthToken() && !submitted && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="bg-accent hover:bg-accent-dark text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Submit My Score
|
||||
</button>
|
||||
)}
|
||||
{submitted && <p className="text-sm text-success">Score submitted!</p>}
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-surface-500">Loading...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-8 text-center text-surface-500">
|
||||
<Trophy size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>No entries yet. Be the first!</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 text-surface-400 text-xs uppercase">
|
||||
<th className="p-3 text-left w-12">#</th>
|
||||
<th className="p-3 text-left">Company</th>
|
||||
<th className="p-3 text-right">Score</th>
|
||||
<th className="p-3 text-right">Era</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry, i) => (
|
||||
<tr key={i} className="border-b border-surface-800 hover:bg-surface-800/50">
|
||||
<td className="p-3">
|
||||
{i < 3 ? <Medal size={16} className={i === 0 ? 'text-yellow-400' : i === 1 ? 'text-gray-400' : 'text-orange-400'} /> : i + 1}
|
||||
</td>
|
||||
<td className="p-3 font-medium">{entry.companyName}</td>
|
||||
<td className="p-3 text-right font-mono">{formatNumber(entry.score)}</td>
|
||||
<td className="p-3 text-right text-surface-400 capitalize">{entry.era}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
|
||||
|
||||
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
|
||||
| 'market' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'settings';
|
||||
| 'market' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'settings';
|
||||
|
||||
interface UIState {
|
||||
activePage: ActivePage;
|
||||
@@ -252,18 +252,28 @@ export const useGameStore = create<Store>()(
|
||||
|
||||
let money = s.economy.money;
|
||||
let reputation = { ...s.reputation };
|
||||
let talent = { ...s.talent };
|
||||
const consequences = choice.consequences;
|
||||
|
||||
for (const c of consequences) {
|
||||
switch (c.type) {
|
||||
case 'money': money += c.value; break;
|
||||
case 'reputation': reputation = { ...reputation, score: Math.min(100, Math.max(0, reputation.score + c.value)), publicPerception: Math.min(100, Math.max(0, reputation.publicPerception + c.value)) }; break;
|
||||
case 'regulation': reputation = { ...reputation, regulatoryStanding: Math.min(100, Math.max(0, reputation.regulatoryStanding + c.value)) }; break;
|
||||
case 'talent': {
|
||||
const dept = c.target as keyof typeof talent.departments | undefined;
|
||||
if (dept && talent.departments[dept]) {
|
||||
talent = { ...talent, departments: { ...talent.departments, [dept]: { ...talent.departments[dept], headcount: Math.max(0, talent.departments[dept].headcount + c.value) } } };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
economy: { ...s.economy, money: Math.max(0, money) },
|
||||
reputation,
|
||||
talent,
|
||||
events: {
|
||||
...s.events,
|
||||
activeEvents: s.events.activeEvents.filter(e => e.instanceId !== instanceId),
|
||||
|
||||
Reference in New Issue
Block a user