Compare commits

..

2 Commits

Author SHA1 Message Date
josh 5885e33531 Add research queue: queue multiple projects, auto-promote on completion, RP refund on dequeue
Balance Check / multi-run-balance (push) Has been cancelled
Balance Check / balance-simulation (push) Has been cancelled
CI / build-and-push (push) Successful in 28s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 08:16:16 -04:00
josh 626ca51041 Fix community size ballooning to infinity with logistic growth damping
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 08:16:02 -04:00
6 changed files with 169 additions and 23 deletions
+68 -10
View File
@@ -1,4 +1,4 @@
import { FlaskConical, Lock, Check, Play } from 'lucide-react';
import { FlaskConical, Lock, Check, Play, ListOrdered, X } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store';
import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared';
@@ -24,13 +24,17 @@ const CATEGORY_LABELS: Record<string, string> = {
export function ResearchPage() {
const completedResearch = useGameStore((s) => s.research.completedResearch);
const activeResearch = useGameStore((s) => s.research.activeResearch);
const researchQueue = useGameStore((s) => s.research.researchQueue);
const researchPoints = useGameStore((s) => s.research.researchPoints);
const startResearch = useGameStore((s) => s.startResearch);
const queueResearch = useGameStore((s) => s.queueResearch);
const removeFromResearchQueue = useGameStore((s) => s.removeFromResearchQueue);
const era = useGameStore((s) => s.meta.currentEra);
const state = useGameStore.getState();
const available = getAvailableResearch(state);
const availableIds = new Set(available.map(n => n.id));
const queuedIds = new Set(researchQueue);
const handleStart = (node: ResearchNode) => {
if (activeResearch) return;
@@ -43,6 +47,10 @@ export function ResearchPage() {
});
};
const handleQueue = (node: ResearchNode) => {
queueResearch(node.id);
};
const categories = [...new Set(TECH_TREE.map(n => n.category))];
return (
@@ -60,7 +68,7 @@ export function ResearchPage() {
</div>
<TutorialHint id="research-intro">
Only one research project can run at a time. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure.
Queue up multiple research projects to run in sequence. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure.
</TutorialHint>
{activeResearch && (
@@ -88,6 +96,36 @@ export function ResearchPage() {
</div>
)}
{researchQueue.length > 0 && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<ListOrdered size={16} className="text-surface-400" />
<span className="text-sm font-medium text-surface-300">Queue ({researchQueue.length})</span>
</div>
<div className="space-y-2">
{researchQueue.map((id, idx) => {
const node = TECH_TREE.find(n => n.id === id);
if (!node) return null;
return (
<div key={id} className="flex items-center justify-between bg-surface-800 rounded-lg px-3 py-2">
<div className="flex items-center gap-3">
<span className="text-xs text-surface-500 font-mono w-5">{idx + 1}.</span>
<span className="text-sm">{node.name}</span>
<span className="text-xs text-surface-500">{formatDuration(node.cost.ticks)}</span>
</div>
<button
onClick={() => removeFromResearchQueue(id)}
className="text-surface-500 hover:text-red-400 transition-colors p-1"
>
<X size={14} />
</button>
</div>
);
})}
</div>
</div>
)}
{categories.map(category => {
const nodes = TECH_TREE.filter(n => n.category === category);
return (
@@ -99,24 +137,29 @@ export function ResearchPage() {
{nodes.map(node => {
const isCompleted = completedResearch.includes(node.id);
const isActive = activeResearch?.researchId === node.id;
const isQueued = queuedIds.has(node.id);
const isAvailable = availableIds.has(node.id);
const isLocked = !isCompleted && !isActive && !isAvailable;
const isLocked = !isCompleted && !isActive && !isAvailable && !isQueued;
const canStart = isAvailable && !activeResearch;
const canQueue = isAvailable && !!activeResearch;
const queuePosition = isQueued ? researchQueue.indexOf(node.id) + 1 : -1;
return (
<div
key={node.id}
onClick={() => isAvailable && !activeResearch && handleStart(node)}
onClick={() => canStart ? handleStart(node) : canQueue ? handleQueue(node) : undefined}
className={`rounded-xl border p-4 transition-all ${
isCompleted ? 'border-success/50 bg-success/5 opacity-70' :
isActive ? 'border-accent/50 bg-accent/5' :
isAvailable && !activeResearch ? `${CATEGORY_COLORS[category]} hover:brightness-110 cursor-pointer ring-1 ring-transparent hover:ring-accent/30` :
isAvailable ? `${CATEGORY_COLORS[category]}` :
isQueued ? 'border-amber-500/50 bg-amber-500/5' :
(canStart || canQueue) ? `${CATEGORY_COLORS[category]} hover:brightness-110 cursor-pointer ring-1 ring-transparent hover:ring-accent/30` :
'border-surface-700 bg-surface-900 opacity-50'
}`}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-medium text-sm">{node.name}</h4>
{isCompleted && <Check size={16} className="text-success flex-shrink-0" />}
{isQueued && <span className="text-xs text-amber-400 font-mono flex-shrink-0">#{queuePosition}</span>}
{isLocked && <Lock size={14} className="text-surface-500 flex-shrink-0" />}
</div>
<p className="text-xs text-surface-400 mb-3">{node.description}</p>
@@ -125,17 +168,32 @@ export function ResearchPage() {
{formatDuration(node.cost.ticks)} · {formatNumber(node.cost.compute)} compute
{node.cost.researchPoints > 0 && ` · ${node.cost.researchPoints} RP`}
</div>
{isAvailable && !activeResearch && (
{canStart && (
<button
onClick={() => handleStart(node)}
onClick={(e) => { e.stopPropagation(); handleStart(node); }}
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-2 py-1 text-xs"
>
<Play size={12} />
Start
</button>
)}
{isAvailable && activeResearch && (
<span className="text-xs text-surface-500 italic">Queue after current</span>
{canQueue && (
<button
onClick={(e) => { e.stopPropagation(); handleQueue(node); }}
className="flex items-center gap-1 bg-amber-600 hover:bg-amber-700 text-white rounded px-2 py-1 text-xs"
>
<ListOrdered size={12} />
Queue
</button>
)}
{isQueued && (
<button
onClick={(e) => { e.stopPropagation(); removeFromResearchQueue(node.id); }}
className="flex items-center gap-1 bg-surface-700 hover:bg-red-900 text-surface-300 hover:text-red-300 rounded px-2 py-1 text-xs transition-colors"
>
<X size={12} />
Remove
</button>
)}
</div>
{node.prerequisites.length > 0 && isLocked && (
+37 -1
View File
@@ -138,6 +138,8 @@ interface Actions {
setProductPricing: (productLineId: string, field: string, value: number) => void;
toggleProductLine: (productLineId: string) => void;
startResearch: (research: ActiveResearch) => void;
queueResearch: (researchId: string) => void;
removeFromResearchQueue: (researchId: string) => void;
hireDepartment: (departmentId: string, count: number) => void;
purchaseDataset: (dataset: OwnedDataset, cost: number) => void;
raiseFunding: (roundType: FundingRoundType) => void;
@@ -1170,6 +1172,35 @@ export const useGameStore = create<Store>()(
};
}),
queueResearch: (researchId) => set((s) => {
if (s.research.researchQueue.includes(researchId)) return s;
const node = TECH_TREE.find(n => n.id === researchId);
if (!node) return s;
const rpCost = node.cost.researchPoints ?? 0;
if (rpCost > s.research.researchPoints) return s;
return {
research: {
...s.research,
researchQueue: [...s.research.researchQueue, researchId],
researchPoints: s.research.researchPoints - rpCost,
},
};
}),
removeFromResearchQueue: (researchId) => set((s) => {
const idx = s.research.researchQueue.indexOf(researchId);
if (idx === -1) return s;
const node = TECH_TREE.find(n => n.id === researchId);
const rpRefund = node?.cost.researchPoints ?? 0;
return {
research: {
...s.research,
researchQueue: s.research.researchQueue.filter(id => id !== researchId),
researchPoints: s.research.researchPoints + rpRefund,
},
};
}),
hireDepartment: (departmentId, count) => set((s) => {
const costPerHire = 2000;
const totalCost = costPerHire * count;
@@ -1450,7 +1481,12 @@ export const useGameStore = create<Store>()(
infraNav: { level: 'clusters' },
} as unknown as Store;
}
return _persisted as Store;
const s = _persisted as Record<string, unknown>;
const research = s.research as Record<string, unknown>;
if (!research.researchQueue) {
s.research = { ...research, researchQueue: [] };
}
return s as unknown as Store;
},
},
),
@@ -22,6 +22,8 @@ export function processDeveloperEcosystem(
): DeveloperEcosystem {
const updated = { ...eco };
const eraCap = TAM_BASE_SIZES[era].developer;
const growthRate =
BASE_DEV_GROWTH +
apiFreeTierDevs * FREE_TIER_DEV_MULTIPLIER +
@@ -29,8 +31,9 @@ export function processDeveloperEcosystem(
updated.devRelSpending * DEV_REL_EFFECTIVENESS +
updated.sdkCoverage * SDK_GROWTH_BONUS;
updated.communityGrowthRate = growthRate;
updated.communitySize = Math.max(0, updated.communitySize + updated.communitySize * growthRate);
const logisticDamping = Math.max(0, 1 - updated.communitySize / Math.max(1, eraCap));
updated.communityGrowthRate = growthRate * logisticDamping;
updated.communitySize = Math.max(0, updated.communitySize + updated.communitySize * updated.communityGrowthRate);
if (updated.communitySize < 10 && apiTotalDevs > 0) {
updated.communitySize += 1 + apiTotalDevs * 0.1;
@@ -47,7 +50,6 @@ export function processDeveloperEcosystem(
updated.documentationQuality += (docTarget - updated.documentationQuality) * 0.003;
updated.documentationQuality = Math.min(1, Math.max(0, updated.documentationQuality));
const eraCap = TAM_BASE_SIZES[era].developer;
const communityNorm = Math.min(1, updated.communitySize / Math.max(1, eraCap * 0.1));
const activeRatio = updated.communitySize > 0
? Math.min(1, updated.activeDevelopers / updated.communitySize)
@@ -1,4 +1,4 @@
import type { GameState, ResearchState, ComputeState } from '@ai-tycoon/shared';
import type { GameState, ResearchState, ActiveResearch, ComputeState } from '@ai-tycoon/shared';
import { TECH_TREE } from '../data/techTree';
export interface ResearchTickResult {
@@ -6,6 +6,42 @@ export interface ResearchTickResult {
researchCompleted: string | null;
}
function promoteFromQueue(
research: ResearchState,
state: GameState,
): ResearchState {
const queue = research.researchQueue;
if (queue.length === 0) return research;
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
const currentEraIdx = eraOrder.indexOf(state.meta.currentEra);
const completed = research.completedResearch;
for (let i = 0; i < queue.length; i++) {
const id = queue[i];
const node = TECH_TREE.find(n => n.id === id);
if (!node) continue;
if (eraOrder.indexOf(node.era) > currentEraIdx) continue;
if (node.prerequisites.some(p => !completed.includes(p))) continue;
const active: ActiveResearch = {
researchId: id,
progressTicks: 0,
totalTicks: node.cost.ticks,
allocatedResearchers: state.talent.departments.research.headcount,
allocatedCompute: node.cost.compute,
};
return {
...research,
activeResearch: active,
researchQueue: [...queue.slice(0, i), ...queue.slice(i + 1)],
};
}
return research;
}
export function processResearch(state: GameState, compute: ComputeState): ResearchTickResult {
const active = state.research.activeResearch;
if (!active) return { research: state.research, researchCompleted: null };
@@ -17,13 +53,18 @@ export function processResearch(state: GameState, compute: ComputeState): Resear
const newProgress = active.progressTicks + speedMultiplier;
if (newProgress >= active.totalTicks) {
const completedResearch = {
...state.research,
completedResearch: [...state.research.completedResearch, active.researchId],
activeResearch: null,
researchPoints: state.research.researchPoints + 1,
};
return {
research: {
...state.research,
completedResearch: [...state.research.completedResearch, active.researchId],
activeResearch: null,
researchPoints: state.research.researchPoints + 1,
},
research: promoteFromQueue(completedResearch, {
...state,
research: completedResearch,
}),
researchCompleted: active.researchId,
};
}
@@ -40,10 +81,12 @@ export function processResearch(state: GameState, compute: ComputeState): Resear
export function getAvailableResearch(state: GameState): typeof TECH_TREE {
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
const currentEraIdx = eraOrder.indexOf(state.meta.currentEra);
const queuedIds = new Set(state.research.researchQueue);
return TECH_TREE.filter(node => {
if (state.research.completedResearch.includes(node.id)) return false;
if (state.research.activeResearch?.researchId === node.id) return false;
if (queuedIds.has(node.id)) return false;
if (eraOrder.indexOf(node.era) > currentEraIdx) return false;
if (node.prerequisites.some(p => !state.research.completedResearch.includes(p))) return false;
if (node.cost.researchPoints > state.research.researchPoints) return false;
@@ -2,14 +2,19 @@ import type { GameState, ActiveResearch } from '@ai-tycoon/shared';
import { TECH_TREE } from '@ai-tycoon/game-engine';
export function startResearch(state: GameState, research: ActiveResearch): boolean {
if (state.research.activeResearch) return false;
const node = TECH_TREE.find(n => n.id === research.researchId);
if (!node) return false;
const rpCost = node.cost.researchPoints ?? 0;
if (rpCost > state.research.researchPoints) return false;
if (state.research.activeResearch) {
if (state.research.researchQueue.includes(research.researchId)) return false;
state.research.researchQueue.push(research.researchId);
state.research.researchPoints -= rpCost;
return true;
}
state.research.activeResearch = research;
state.research.researchPoints -= rpCost;
return true;
+2
View File
@@ -3,6 +3,7 @@ import type { Era } from './gameState';
export interface ResearchState {
completedResearch: string[];
activeResearch: ActiveResearch | null;
researchQueue: string[];
researchPoints: number;
}
@@ -41,5 +42,6 @@ export interface ResearchEffect {
export const INITIAL_RESEARCH: ResearchState = {
completedResearch: [],
activeResearch: null,
researchQueue: [],
researchPoints: 0,
};