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,190 @@
|
||||
import type {
|
||||
TotalAddressableMarket,
|
||||
TAMSegmentId,
|
||||
MarketShareEntry,
|
||||
Competitor,
|
||||
Era,
|
||||
ObsolescenceState,
|
||||
DeveloperEcosystem,
|
||||
} from '@ai-tycoon/shared';
|
||||
import {
|
||||
TAM_BASE_SIZES,
|
||||
TAM_GROWTH_PER_TICK,
|
||||
SHARE_TEMPERATURE,
|
||||
SHARE_MIGRATION_SPEED,
|
||||
ATTRACTIVENESS_WEIGHTS,
|
||||
OBSOLESCENCE_PENALTY_WEIGHT,
|
||||
NEW_MODEL_BOOST_VALUE,
|
||||
} from '@ai-tycoon/shared';
|
||||
|
||||
export interface ParticipantProfile {
|
||||
id: string;
|
||||
qualityScore: number;
|
||||
priceScore: number;
|
||||
reputation: number;
|
||||
ecosystemScore: number;
|
||||
freshness: number;
|
||||
hasFreeTier: boolean;
|
||||
}
|
||||
|
||||
export function buildPlayerProfile(
|
||||
qualityScore: number,
|
||||
chatPrice: number,
|
||||
apiOutputPrice: number,
|
||||
reputation: number,
|
||||
ecosystem: DeveloperEcosystem,
|
||||
obsolescence: ObsolescenceState,
|
||||
hasFreeTier: boolean,
|
||||
): ParticipantProfile {
|
||||
const avgPrice = (chatPrice + apiOutputPrice) / 2;
|
||||
const priceScore = Math.max(0, Math.min(1, 1 - avgPrice / 100));
|
||||
|
||||
let freshness = obsolescence.playerModelFreshness;
|
||||
if (obsolescence.newModelBoostRemaining > 0) {
|
||||
freshness = Math.min(1, freshness + NEW_MODEL_BOOST_VALUE);
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'player',
|
||||
qualityScore: Math.min(1, qualityScore),
|
||||
priceScore,
|
||||
reputation,
|
||||
ecosystemScore: ecosystem.ecosystemScore,
|
||||
freshness,
|
||||
hasFreeTier,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCompetitorProfile(c: Competitor): ParticipantProfile {
|
||||
const avgPrice = (c.products.chatPrice + c.products.apiOutputPrice) / 2;
|
||||
const priceScore = Math.max(0, Math.min(1, 1 - avgPrice / 100));
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
qualityScore: Math.min(1, c.estimatedCapability / 100),
|
||||
priceScore,
|
||||
reputation: c.reputation,
|
||||
ecosystemScore: c.developerEcosystemScore,
|
||||
freshness: c.modelFreshness,
|
||||
hasFreeTier: c.products.hasFreeTier,
|
||||
};
|
||||
}
|
||||
|
||||
function computeAttractiveness(
|
||||
p: ParticipantProfile,
|
||||
segment: TAMSegmentId,
|
||||
qualityBaseline: number,
|
||||
): number {
|
||||
const w = ATTRACTIVENESS_WEIGHTS[segment];
|
||||
let score =
|
||||
w.quality * p.qualityScore +
|
||||
w.price * p.priceScore +
|
||||
w.reputation * (p.reputation / 100) +
|
||||
w.ecosystem * (p.ecosystemScore / 100) +
|
||||
w.freshness * p.freshness +
|
||||
w.freeTier * (p.hasFreeTier ? 1 : 0);
|
||||
|
||||
const qualityGap = qualityBaseline / 100 - p.qualityScore;
|
||||
if (qualityGap > 0) {
|
||||
score -= qualityGap * OBSOLESCENCE_PENALTY_WEIGHT;
|
||||
}
|
||||
|
||||
return Math.max(0.01, score);
|
||||
}
|
||||
|
||||
function softmaxShares(scores: number[]): number[] {
|
||||
const maxScore = Math.max(...scores);
|
||||
const exps = scores.map(s => Math.exp((s - maxScore) * SHARE_TEMPERATURE));
|
||||
const sumExp = exps.reduce((a, b) => a + b, 0);
|
||||
return exps.map(e => e / sumExp);
|
||||
}
|
||||
|
||||
export function computeMarketShares(
|
||||
tam: TotalAddressableMarket,
|
||||
participants: ParticipantProfile[],
|
||||
qualityBaseline: number,
|
||||
): TotalAddressableMarket {
|
||||
const segments = { ...tam.segments };
|
||||
const segmentIds: TAMSegmentId[] = ['consumer', 'developer', 'enterprise', 'government'];
|
||||
|
||||
for (const segId of segmentIds) {
|
||||
const seg = segments[segId];
|
||||
const scores = participants.map(p => computeAttractiveness(p, segId, qualityBaseline));
|
||||
const targetShares = softmaxShares(scores);
|
||||
|
||||
const oldShareMap = new Map<string, MarketShareEntry>();
|
||||
for (const entry of seg.shares) {
|
||||
oldShareMap.set(entry.playerId, entry);
|
||||
}
|
||||
|
||||
const newShares: MarketShareEntry[] = participants.map((p, i) => {
|
||||
const old = oldShareMap.get(p.id);
|
||||
const oldShare = old?.sharePercent ?? 0;
|
||||
const migratedShare = oldShare + (targetShares[i] - oldShare) * SHARE_MIGRATION_SPEED;
|
||||
return {
|
||||
playerId: p.id,
|
||||
sharePercent: migratedShare,
|
||||
customers: Math.floor(migratedShare * seg.totalSize),
|
||||
attractivenessScore: scores[i],
|
||||
};
|
||||
});
|
||||
|
||||
const totalShare = newShares.reduce((s, e) => s + e.sharePercent, 0);
|
||||
if (totalShare > 0) {
|
||||
for (const entry of newShares) {
|
||||
entry.sharePercent /= totalShare;
|
||||
entry.customers = Math.floor(entry.sharePercent * seg.totalSize);
|
||||
}
|
||||
}
|
||||
|
||||
segments[segId] = { ...seg, shares: newShares };
|
||||
}
|
||||
|
||||
return { segments };
|
||||
}
|
||||
|
||||
export function updateTAMGrowth(tam: TotalAddressableMarket, era: Era): TotalAddressableMarket {
|
||||
const baseSizes = TAM_BASE_SIZES[era];
|
||||
const segments = { ...tam.segments };
|
||||
const segmentIds: TAMSegmentId[] = ['consumer', 'developer', 'enterprise', 'government'];
|
||||
|
||||
for (const segId of segmentIds) {
|
||||
const seg = segments[segId];
|
||||
const base = baseSizes[segId];
|
||||
const grown = seg.totalSize + seg.totalSize * TAM_GROWTH_PER_TICK;
|
||||
segments[segId] = {
|
||||
...seg,
|
||||
totalSize: Math.max(base, grown),
|
||||
};
|
||||
}
|
||||
|
||||
return { segments };
|
||||
}
|
||||
|
||||
export function initializeTAM(era: Era, competitors: Competitor[]): TotalAddressableMarket {
|
||||
const baseSizes = TAM_BASE_SIZES[era];
|
||||
const segmentIds: TAMSegmentId[] = ['consumer', 'developer', 'enterprise', 'government'];
|
||||
const segments = {} as Record<TAMSegmentId, { totalSize: number; shares: MarketShareEntry[] }>;
|
||||
|
||||
for (const segId of segmentIds) {
|
||||
const shares: MarketShareEntry[] = [
|
||||
{ playerId: 'player', sharePercent: 0.05, customers: 0, attractivenessScore: 0 },
|
||||
...competitors.map(c => ({
|
||||
playerId: c.id,
|
||||
sharePercent: c.marketShares[segId] ?? 0.1,
|
||||
customers: 0,
|
||||
attractivenessScore: 0,
|
||||
})),
|
||||
];
|
||||
|
||||
const totalShare = shares.reduce((s, e) => s + e.sharePercent, 0);
|
||||
for (const entry of shares) {
|
||||
entry.sharePercent /= totalShare;
|
||||
entry.customers = Math.floor(entry.sharePercent * baseSizes[segId]);
|
||||
}
|
||||
|
||||
segments[segId] = { totalSize: baseSizes[segId], shares };
|
||||
}
|
||||
|
||||
return { segments };
|
||||
}
|
||||
Reference in New Issue
Block a user