Files
AIHostingTycoon/packages/game-engine/src/systems/market/tamSystem.ts
T
josh 63e56dc229
Balance Check / balance-simulation (push) Successful in 51s
Balance Check / multi-run-balance (push) Successful in 13m19s
CI / build-and-push (push) Successful in 45s
Fix consumer subscription pricing exploit with perceived-value-based elasticity
Players could set astronomical prices and still retain subscribers because
price elasticity floored at 10% for any price above $100, satisfaction
ignored pricing entirely, and churn had no price component.

Introduces perceived value per tier (model quality × reputation), replaces
the broken linear formula with sigmoid decay, adds price-aware satisfaction
blending, and applies per-tier price-based churn multipliers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 21:51:03 -04:00

210 lines
6.2 KiB
TypeScript

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,
CONSUMER_TIER_BASE_PERCEIVED_VALUE,
PERCEIVED_VALUE_REPUTATION_RANGE,
} from '@ai-tycoon/shared';
export interface ParticipantProfile {
id: string;
qualityScore: number;
priceScore: number;
reputation: number;
ecosystemScore: number;
freshness: number;
hasFreeTier: boolean;
}
function computeTamPriceScore(price: number, qualityScore: number, reputation: number): number {
if (price <= 0) return 1;
const repRange = PERCEIVED_VALUE_REPUTATION_RANGE;
const repMult = repRange.min + (Math.max(0, Math.min(100, reputation)) / 100) * (repRange.max - repRange.min);
const refPV = CONSUMER_TIER_BASE_PERCEIVED_VALUE.plus * qualityScore * repMult;
if (refPV <= 0) return 0;
const ratio = price / refPV;
return 1 / (1 + ratio * ratio);
}
export function buildPlayerProfile(
qualityScore: number,
chatPrice: number,
apiOutputPrice: number,
reputation: number,
ecosystem: DeveloperEcosystem,
obsolescence: ObsolescenceState,
hasFreeTier: boolean,
): ParticipantProfile {
const priceScore = computeTamPriceScore(chatPrice, qualityScore, reputation);
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 compQuality = Math.min(1, c.estimatedCapability / 100);
const priceScore = computeTamPriceScore(c.products.chatPrice, compQuality, c.reputation);
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);
}
export function computeMarketShares(
tam: TotalAddressableMarket,
participants: ParticipantProfile[],
qualityBaseline: number,
): TotalAddressableMarket {
const segments = { ...tam.segments };
const segmentIds: TAMSegmentId[] = ['consumer', 'developer', 'enterprise', 'government'];
const n = participants.length;
const scores = new Array<number>(n);
const targetShares = new Array<number>(n);
for (const segId of segmentIds) {
const seg = segments[segId];
for (let i = 0; i < n; i++) {
scores[i] = computeAttractiveness(participants[i], segId, qualityBaseline);
}
// Inline softmax
let maxScore = scores[0];
for (let i = 1; i < n; i++) { if (scores[i] > maxScore) maxScore = scores[i]; }
let sumExp = 0;
for (let i = 0; i < n; i++) {
targetShares[i] = Math.exp((scores[i] - maxScore) * SHARE_TEMPERATURE);
sumExp += targetShares[i];
}
for (let i = 0; i < n; i++) { targetShares[i] /= sumExp; }
const oldShareMap = new Map<string, MarketShareEntry>();
for (const entry of seg.shares) {
oldShareMap.set(entry.playerId, entry);
}
const newShares: MarketShareEntry[] = new Array(n);
let totalShare = 0;
for (let i = 0; i < n; i++) {
const p = participants[i];
const old = oldShareMap.get(p.id);
const oldShare = old?.sharePercent ?? 0;
const migratedShare = oldShare + (targetShares[i] - oldShare) * SHARE_MIGRATION_SPEED;
totalShare += migratedShare;
newShares[i] = {
playerId: p.id,
sharePercent: migratedShare,
customers: 0,
attractivenessScore: scores[i],
};
}
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 };
}