Add Week 2 depth systems: research, events, competitors, talent, data
Tech tree with 21 research nodes across 5 categories (infrastructure, efficiency, generation, specialization, safety). Research page with category-grouped cards, progress tracking, prerequisite gating. Event engine with 34 events across industry/regulatory/PR/internal/market categories, weighted random firing, cooldowns, expiry, and choice modal with consequence preview. Events auto-expire with default choice. Competitor system with 3 rival AI labs (Prometheus AI, Nexus Labs, Titan Computing), personality-driven milestone progression, and comparison UI. Talent page with department hiring, headcount management, and key hire recruitment from a pool of 10 named characters with special abilities. Data marketplace with 8 purchasable datasets, user data flywheel from subscribers, and data system processing in tick loop. Era transition system checks revenue/capability/reputation thresholds. All new systems integrated into tick processor with notifications. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import type { GameState, CompetitorState } from '@ai-tycoon/shared';
|
||||
|
||||
export function processCompetitors(state: GameState): CompetitorState {
|
||||
const tick = state.meta.tickCount;
|
||||
const rivals = state.competitors.rivals.map(rival => {
|
||||
if (rival.status !== 'active') return rival;
|
||||
|
||||
if (tick < rival.nextMilestoneAtTick) return rival;
|
||||
|
||||
const { personality } = rival;
|
||||
const capGrowth = (2 + personality.researchFocus * 5 + personality.riskTolerance * 3) *
|
||||
(1 + tick * 0.00005);
|
||||
const revenueGrowth = rival.estimatedRevenue * (0.02 + personality.marketingFocus * 0.03);
|
||||
const userGrowth = rival.estimatedUsers * (0.01 + personality.marketingFocus * 0.02);
|
||||
|
||||
const newCapability = Math.min(95, rival.estimatedCapability + capGrowth);
|
||||
const newRevenue = rival.estimatedRevenue + revenueGrowth + 50;
|
||||
const newUsers = rival.estimatedUsers + userGrowth + 100;
|
||||
|
||||
const repChange = personality.safetyFocus > 0.6
|
||||
? 1
|
||||
: personality.riskTolerance > 0.7 ? -1 : 0;
|
||||
|
||||
const modelNames = [
|
||||
'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon',
|
||||
'Nova', 'Quantum', 'Nexus', 'Apex', 'Zenith',
|
||||
];
|
||||
const modelIdx = Math.floor(newCapability / 10);
|
||||
const latestModelName = `${rival.name.split(' ')[0]}-${modelNames[Math.min(modelIdx, modelNames.length - 1)]}`;
|
||||
|
||||
const milestoneInterval = 200 + Math.floor(Math.random() * 200);
|
||||
|
||||
return {
|
||||
...rival,
|
||||
estimatedCapability: newCapability,
|
||||
estimatedRevenue: newRevenue,
|
||||
estimatedUsers: Math.floor(newUsers),
|
||||
reputation: Math.min(100, Math.max(0, rival.reputation + repChange)),
|
||||
latestModelName,
|
||||
nextMilestoneAtTick: tick + milestoneInterval,
|
||||
};
|
||||
});
|
||||
|
||||
const allCaps = [
|
||||
...rivals.filter(r => r.status === 'active').map(r => r.estimatedCapability),
|
||||
state.models.trainedModels.reduce((best, m) => Math.max(best, m.benchmarkScore), 0),
|
||||
];
|
||||
const industryBenchmark = allCaps.length > 0 ? Math.max(...allCaps) : 0;
|
||||
|
||||
return { rivals, industryBenchmark };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { GameState, DataState } from '@ai-tycoon/shared';
|
||||
|
||||
export function processData(state: GameState): DataState {
|
||||
const subscribers = state.market.consumers.totalSubscribers;
|
||||
const userDataRate = subscribers * 0.5;
|
||||
|
||||
const partnershipTokens = state.data.partnerships.reduce((sum, p) => sum + p.tokensPerTick, 0);
|
||||
|
||||
const newTokens = userDataRate + partnershipTokens;
|
||||
const totalTrainingTokens = state.data.totalTrainingTokens + newTokens;
|
||||
|
||||
return {
|
||||
...state.data,
|
||||
userDataGenerationRate: userDataRate,
|
||||
totalTrainingTokens,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GameState, Era } from '@ai-tycoon/shared';
|
||||
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
|
||||
|
||||
export function checkEraTransition(state: GameState): Era | null {
|
||||
const current = state.meta.currentEra;
|
||||
const eraOrder: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentIdx = eraOrder.indexOf(current);
|
||||
const nextEra = eraOrder[currentIdx + 1];
|
||||
if (!nextEra) return null;
|
||||
|
||||
const thresholds = ERA_THRESHOLDS[nextEra as keyof typeof ERA_THRESHOLDS];
|
||||
if (!thresholds) return null;
|
||||
|
||||
const bestModel = state.models.trainedModels.reduce(
|
||||
(best, m) => Math.max(best, m.benchmarkScore), 0,
|
||||
);
|
||||
|
||||
if (
|
||||
state.economy.totalRevenue >= thresholds.revenue &&
|
||||
bestModel >= thresholds.capability &&
|
||||
state.reputation.score >= thresholds.reputation
|
||||
) {
|
||||
return nextEra;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { GameState, EventState, ActiveEvent, EventDefinition, EventCondition } from '@ai-tycoon/shared';
|
||||
|
||||
export interface EventTickResult {
|
||||
events: EventState;
|
||||
newEvents: ActiveEvent[];
|
||||
}
|
||||
|
||||
export function processEvents(
|
||||
state: GameState,
|
||||
definitions: EventDefinition[],
|
||||
): EventTickResult {
|
||||
const tick = state.meta.tickCount;
|
||||
const events = { ...state.events };
|
||||
const newEvents: ActiveEvent[] = [];
|
||||
|
||||
// Remove expired events (auto-choose default)
|
||||
const stillActive: ActiveEvent[] = [];
|
||||
for (const event of events.activeEvents) {
|
||||
if (tick >= event.expiresAtTick) {
|
||||
events.eventHistory = [
|
||||
...events.eventHistory,
|
||||
{
|
||||
eventId: event.eventId,
|
||||
instanceId: event.instanceId,
|
||||
title: event.title,
|
||||
category: event.category,
|
||||
tick,
|
||||
chosenOptionIndex: event.defaultChoiceIndex,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
stillActive.push(event);
|
||||
}
|
||||
}
|
||||
events.activeEvents = stillActive;
|
||||
|
||||
if (events.eventHistory.length > 50) {
|
||||
events.eventHistory = events.eventHistory.slice(-50);
|
||||
}
|
||||
|
||||
// Only try to fire a new event every 30 ticks, and max 1 active at a time
|
||||
if (tick % 30 !== 0 || events.activeEvents.length > 0) {
|
||||
return { events, newEvents };
|
||||
}
|
||||
|
||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentEraIdx = eraOrder.indexOf(state.meta.currentEra);
|
||||
|
||||
const eligible = definitions.filter(def => {
|
||||
if (!def.eras.some(e => eraOrder.indexOf(e) <= currentEraIdx)) return false;
|
||||
const occ = events.eventOccurrences[def.id] ?? 0;
|
||||
if (occ >= def.maxOccurrences) return false;
|
||||
const cooldownEnd = events.eventCooldowns[def.id] ?? 0;
|
||||
if (tick < cooldownEnd) return false;
|
||||
if (def.prerequisites.some(p => !state.research.completedResearch.includes(p))) return false;
|
||||
if (!def.conditions.every(c => evaluateCondition(state, c))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (eligible.length === 0) return { events, newEvents };
|
||||
|
||||
const totalWeight = eligible.reduce((s, d) => s + d.weight, 0);
|
||||
let roll = Math.random() * totalWeight;
|
||||
let chosen: EventDefinition | null = null;
|
||||
for (const def of eligible) {
|
||||
roll -= def.weight;
|
||||
if (roll <= 0) { chosen = def; break; }
|
||||
}
|
||||
if (!chosen) return { events, newEvents };
|
||||
|
||||
// Only fire with 30% probability per check to space events out
|
||||
if (Math.random() > 0.3) return { events, newEvents };
|
||||
|
||||
const activeEvent: ActiveEvent = {
|
||||
eventId: chosen.id,
|
||||
instanceId: crypto.randomUUID(),
|
||||
triggeredAtTick: tick,
|
||||
expiresAtTick: tick + chosen.expiryTicks,
|
||||
title: chosen.title,
|
||||
description: chosen.descriptionTemplate,
|
||||
category: chosen.category,
|
||||
choices: chosen.choices,
|
||||
defaultChoiceIndex: chosen.defaultChoiceIndex,
|
||||
};
|
||||
|
||||
events.activeEvents = [...events.activeEvents, activeEvent];
|
||||
events.eventCooldowns = { ...events.eventCooldowns, [chosen.id]: tick + chosen.cooldownTicks };
|
||||
events.eventOccurrences = {
|
||||
...events.eventOccurrences,
|
||||
[chosen.id]: (events.eventOccurrences[chosen.id] ?? 0) + 1,
|
||||
};
|
||||
newEvents.push(activeEvent);
|
||||
|
||||
return { events, newEvents };
|
||||
}
|
||||
|
||||
function evaluateCondition(state: GameState, condition: EventCondition): boolean {
|
||||
const value = getNestedValue(state, condition.field);
|
||||
if (value === undefined) return false;
|
||||
switch (condition.operator) {
|
||||
case 'gt': return value > condition.value;
|
||||
case 'lt': return value < condition.value;
|
||||
case 'gte': return value >= condition.value;
|
||||
case 'lte': return value <= condition.value;
|
||||
case 'eq': return value === condition.value;
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedValue(obj: object, path: string): number | undefined {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof current === 'number' ? current : undefined;
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { GameState, ResearchState, ComputeState } from '@ai-tycoon/shared';
|
||||
import { TECH_TREE } from '../data/techTree';
|
||||
|
||||
export function processResearch(state: GameState, compute: ComputeState): ResearchState {
|
||||
export interface ResearchTickResult {
|
||||
research: ResearchState;
|
||||
researchCompleted: string | null;
|
||||
}
|
||||
|
||||
export function processResearch(state: GameState, compute: ComputeState): ResearchTickResult {
|
||||
const active = state.research.activeResearch;
|
||||
if (!active) return state.research;
|
||||
if (!active) return { research: state.research, researchCompleted: null };
|
||||
|
||||
const researcherBoost = state.talent.departments.research.headcount *
|
||||
state.talent.departments.research.effectiveness;
|
||||
@@ -12,18 +18,38 @@ export function processResearch(state: GameState, compute: ComputeState): Resear
|
||||
|
||||
if (newProgress >= active.totalTicks) {
|
||||
return {
|
||||
...state.research,
|
||||
completedResearch: [...state.research.completedResearch, active.researchId],
|
||||
activeResearch: null,
|
||||
researchPoints: state.research.researchPoints + 1,
|
||||
research: {
|
||||
...state.research,
|
||||
completedResearch: [...state.research.completedResearch, active.researchId],
|
||||
activeResearch: null,
|
||||
researchPoints: state.research.researchPoints + 1,
|
||||
},
|
||||
researchCompleted: active.researchId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state.research,
|
||||
activeResearch: {
|
||||
...active,
|
||||
progressTicks: newProgress,
|
||||
research: {
|
||||
...state.research,
|
||||
activeResearch: { ...active, progressTicks: newProgress },
|
||||
},
|
||||
researchCompleted: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAvailableResearch(state: GameState): typeof TECH_TREE {
|
||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentEraIdx = eraOrder.indexOf(state.meta.currentEra);
|
||||
|
||||
return TECH_TREE.filter(node => {
|
||||
if (state.research.completedResearch.includes(node.id)) return false;
|
||||
if (state.research.activeResearch?.researchId === node.id) return false;
|
||||
if (eraOrder.indexOf(node.era) > currentEraIdx) return false;
|
||||
if (node.prerequisites.some(p => !state.research.completedResearch.includes(p))) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function getResearchNode(id: string) {
|
||||
return TECH_TREE.find(n => n.id === id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user