Redesign model lifecycle: upfront SFT/alignment, multi-size families, point releases, quantization-only variants
CI / build-and-push (push) Successful in 45s
CI / build-and-push (push) Successful in 45s
Training pipeline now requires SFT specializations and alignment method configured at start — no more mid-training configuration step. Model families support multiple size tiers (Nano/Small/Medium/Large/Flagship) trained independently, mimicking real AI company model families. Point releases iterate on deployed models with 40% training time and 8% capability gain. Distillation and fine-tuning variants removed — players train smaller size tiers or configure SFT during initial training instead. Only quantization remains as a variant type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,6 @@ import type {
|
||||
import { BENCHMARKS } from '../data/benchmarks';
|
||||
import {
|
||||
uuid, VRAM_REQUIREMENTS_BY_GENERATION,
|
||||
SFT_TIME_FRACTION, SFT_COMPUTE_FRACTION,
|
||||
ALIGNMENT_TIME_FRACTION, ALIGNMENT_COMPUTE_FRACTION,
|
||||
MOE_CAPABILITY_MULTIPLIER, MOE_SPEED_MULTIPLIER,
|
||||
EVENT_BASE_PROBABILITY,
|
||||
LOSS_SPIKE_DELAY_MIN, LOSS_SPIKE_DELAY_MAX,
|
||||
@@ -18,8 +16,8 @@ import {
|
||||
ALIGNMENT_METHODS,
|
||||
SFT_SPECIALIZATION_BONUSES,
|
||||
QUANTIZATION_CONFIGS,
|
||||
DISTILLATION_BASE_RETENTION,
|
||||
QUANTIZATION_TICKS,
|
||||
POINT_RELEASE_CAPABILITY_GAIN,
|
||||
SIZE_TIER_LABELS,
|
||||
} from '@ai-tycoon/shared';
|
||||
import type { ResearchBonuses } from './researchBonuses';
|
||||
|
||||
@@ -101,60 +99,25 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
|
||||
stage.computeAllocated = effectiveFlops;
|
||||
stage.lossValue = Math.max(0.01, 10 * Math.exp(-stage.progressTicks / stage.totalTicks * 3));
|
||||
|
||||
const progressRatio = stage.progressTicks / stage.totalTicks;
|
||||
if (progressRatio >= 0.75 && progressRatio < 0.78 && !pipeline.stages.sft && !pipeline.stages.alignment) {
|
||||
notifications.push({
|
||||
title: 'Post-Training Reminder',
|
||||
message: `${pipeline.modelName} is 75% pre-trained. Configure SFT/Alignment now or they'll be skipped!`,
|
||||
type: 'warning',
|
||||
action: { label: 'Configure Now', page: 'models', modelsTab: 'overview' },
|
||||
});
|
||||
}
|
||||
|
||||
if (stage.progressTicks >= stage.totalTicks) {
|
||||
stage.isComplete = true;
|
||||
stage.progressTicks = stage.totalTicks;
|
||||
|
||||
if (updated.stages.sft) {
|
||||
updated.currentStage = 'sft';
|
||||
notifications.push({ title: 'Pre-training Complete', message: `${pipeline.modelName}: Moving to supervised fine-tuning.`, type: 'info' });
|
||||
} else if (updated.stages.alignment) {
|
||||
updated.currentStage = 'alignment';
|
||||
notifications.push({ title: 'Pre-training Complete', message: `${pipeline.modelName}: Moving to alignment.`, type: 'info' });
|
||||
} else {
|
||||
const model = createBaseModel(updated, state, researchBonuses);
|
||||
baseModels = [...baseModels, model];
|
||||
families = families.map(f =>
|
||||
f.id === pipeline.familyId ? { ...f, baseModelId: model.id } : f,
|
||||
);
|
||||
completedModels.push(model);
|
||||
updated.status = 'completed';
|
||||
}
|
||||
updated.currentStage = 'sft';
|
||||
notifications.push({ title: 'Pre-training Complete', message: `${pipeline.modelName}: Moving to supervised fine-tuning.`, type: 'info' });
|
||||
}
|
||||
updated = { ...updated, stages: { ...updated.stages, pretraining: stage } };
|
||||
} else if (pipeline.currentStage === 'sft' && pipeline.stages.sft) {
|
||||
} else if (pipeline.currentStage === 'sft') {
|
||||
const stage = { ...pipeline.stages.sft };
|
||||
stage.progressTicks += speedMultiplier;
|
||||
|
||||
if (stage.progressTicks >= stage.totalTicks) {
|
||||
stage.isComplete = true;
|
||||
stage.progressTicks = stage.totalTicks;
|
||||
|
||||
if (updated.stages.alignment) {
|
||||
updated.currentStage = 'alignment';
|
||||
notifications.push({ title: 'SFT Complete', message: `${pipeline.modelName}: Moving to alignment.`, type: 'info' });
|
||||
} else {
|
||||
const model = createBaseModel(updated, state, researchBonuses);
|
||||
baseModels = [...baseModels, model];
|
||||
families = families.map(f =>
|
||||
f.id === pipeline.familyId ? { ...f, baseModelId: model.id } : f,
|
||||
);
|
||||
completedModels.push(model);
|
||||
updated.status = 'completed';
|
||||
}
|
||||
updated.currentStage = 'alignment';
|
||||
notifications.push({ title: 'SFT Complete', message: `${pipeline.modelName}: Moving to alignment.`, type: 'info' });
|
||||
}
|
||||
updated = { ...updated, stages: { ...updated.stages, sft: stage } };
|
||||
} else if (pipeline.currentStage === 'alignment' && pipeline.stages.alignment) {
|
||||
} else if (pipeline.currentStage === 'alignment') {
|
||||
const stage = { ...pipeline.stages.alignment };
|
||||
stage.progressTicks += speedMultiplier;
|
||||
|
||||
@@ -165,7 +128,7 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse
|
||||
const model = createBaseModel(updated, state, researchBonuses);
|
||||
baseModels = [...baseModels, model];
|
||||
families = families.map(f =>
|
||||
f.id === pipeline.familyId ? { ...f, baseModelId: model.id } : f,
|
||||
f.id === pipeline.familyId ? { ...f, baseModelIds: [...f.baseModelIds, model.id] } : f,
|
||||
);
|
||||
completedModels.push(model);
|
||||
updated.status = 'completed';
|
||||
@@ -320,79 +283,93 @@ function createBaseModel(
|
||||
const dataTokens = pipeline.stages.pretraining.targetTokens;
|
||||
const params = architecture.totalParameters;
|
||||
|
||||
// Pillar 1: Parameters (0-30) — larger models have higher ceiling
|
||||
const paramFactor = Math.min(30, Math.log2(1 + params) * 4.5);
|
||||
const sourceModel = pipeline.isPointRelease && pipeline.sourceModelId
|
||||
? state.models.baseModels.find(m => m.id === pipeline.sourceModelId)
|
||||
: null;
|
||||
|
||||
// Pillar 2: Compute (0-25) — compute relative to parameter count (Chinchilla scaling)
|
||||
const computePerParam = compute / Math.max(1, params);
|
||||
const computeFactor = Math.min(25, Math.sqrt(computePerParam) * 8);
|
||||
let rawCapability: number;
|
||||
let capabilities: ModelCapabilities;
|
||||
|
||||
// Pillar 3: Data (0-20) — token count with quality multiplier
|
||||
const dataQualityMultiplier = 1 + (researchBonuses?.dataQualityBonus ?? 0);
|
||||
const dataFactor = Math.min(20, Math.log10(1 + dataTokens / 1e8) * 8 * dataQualityMultiplier);
|
||||
if (sourceModel) {
|
||||
rawCapability = Math.min(98, sourceModel.rawCapability * (1 + POINT_RELEASE_CAPABILITY_GAIN));
|
||||
|
||||
// Pillar 4: Research (0-20) — accumulated research knowledge
|
||||
const capabilityResearchBonus = researchBonuses?.globalCapabilityBonus ?? 0;
|
||||
const researchFactor = Math.min(20, capabilityResearchBonus + state.research.completedResearch.length * 0.5);
|
||||
|
||||
let rawCapability = Math.min(95, paramFactor + computeFactor + dataFactor + researchFactor);
|
||||
|
||||
if (architecture.type === 'moe') {
|
||||
rawCapability = Math.min(98, rawCapability * MOE_CAPABILITY_MULTIPLIER);
|
||||
}
|
||||
|
||||
// MoE tradeoff: total params need full VRAM even though only active params run
|
||||
// This is enforced in the UI/store when checking VRAM requirements
|
||||
|
||||
const researcherQuality = state.talent.departments.research.effectiveness;
|
||||
const contextBonus = Math.log2(Math.max(1, architecture.contextWindow / 4)) * 3;
|
||||
const contextPenalty = Math.max(0, Math.log2(architecture.contextWindow / 8)) * 2;
|
||||
|
||||
const capabilities: ModelCapabilities = {
|
||||
reasoning: clamp(rawCapability * (0.6 + dataMix.scientific * 0.5 + dataMix.code * 0.3) * (1 + researcherQuality * 0.2)),
|
||||
coding: clamp(rawCapability * (0.5 + dataMix.code * 1.0)),
|
||||
creative: clamp(rawCapability * (0.4 + dataMix.books * 0.6 + dataMix.conversation * 0.3)),
|
||||
math: clamp(rawCapability * (0.3 + dataMix.scientific * 0.7 + dataMix.code * 0.2)),
|
||||
knowledge: clamp(rawCapability * (0.5 + dataMix.web * 0.3 + dataMix.books * 0.3) + contextBonus * 0.3),
|
||||
multimodal: clamp(rawCapability * (dataMix.images * 0.5 + dataMix.video * 0.4 + dataMix.audio * 0.2)),
|
||||
agents: clamp(rawCapability * (0.2 + dataMix.code * 0.3 + dataMix.conversation * 0.2) + contextBonus * 0.5),
|
||||
speed: Math.max(1, 100 - params * 0.3 - contextPenalty + (researchBonuses?.inferenceEfficiencyBonus ?? 0) * 20 + (architecture.type === 'moe' ? MOE_SPEED_MULTIPLIER * 10 : 0)),
|
||||
contextUtilization: Math.min(100, architecture.contextWindow * 0.4),
|
||||
};
|
||||
|
||||
if (researchBonuses) {
|
||||
capabilities.reasoning = clamp(capabilities.reasoning + researchBonuses.reasoningBonus);
|
||||
capabilities.coding = clamp(capabilities.coding + researchBonuses.codingBonus);
|
||||
capabilities.creative = clamp(capabilities.creative + researchBonuses.creativeBonus);
|
||||
capabilities.multimodal = clamp(capabilities.multimodal + researchBonuses.multimodalBonus);
|
||||
capabilities.agents = clamp(capabilities.agents + researchBonuses.agentsBonus);
|
||||
}
|
||||
|
||||
const breakthroughBonuses: Partial<Record<keyof ModelCapabilities, number>> = {};
|
||||
for (const event of pipeline.events) {
|
||||
if ((event.type === 'breakthrough' || event.type === 'emergent_capability') && event.impact.capabilityDomain && event.impact.capabilityBonus) {
|
||||
const domain = event.impact.capabilityDomain;
|
||||
breakthroughBonuses[domain] = (breakthroughBonuses[domain] ?? 0) + event.impact.capabilityBonus;
|
||||
capabilities = { ...sourceModel.capabilities };
|
||||
const boost = POINT_RELEASE_CAPABILITY_GAIN;
|
||||
for (const key of Object.keys(capabilities) as (keyof ModelCapabilities)[]) {
|
||||
if (key !== 'speed' && key !== 'contextUtilization') {
|
||||
capabilities[key] = clamp(capabilities[key] * (1 + boost));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const paramFactor = Math.min(30, Math.log2(1 + params) * 4.5);
|
||||
|
||||
const computePerParam = compute / Math.max(1, params);
|
||||
const computeFactor = Math.min(25, Math.sqrt(computePerParam) * 8);
|
||||
|
||||
const dataQualityMultiplier = 1 + (researchBonuses?.dataQualityBonus ?? 0);
|
||||
const dataFactor = Math.min(20, Math.log10(1 + dataTokens / 1e8) * 8 * dataQualityMultiplier);
|
||||
|
||||
const capabilityResearchBonus = researchBonuses?.globalCapabilityBonus ?? 0;
|
||||
const researchFactor = Math.min(20, capabilityResearchBonus + state.research.completedResearch.length * 0.5);
|
||||
|
||||
rawCapability = Math.min(95, paramFactor + computeFactor + dataFactor + researchFactor);
|
||||
|
||||
if (architecture.type === 'moe') {
|
||||
rawCapability = Math.min(98, rawCapability * MOE_CAPABILITY_MULTIPLIER);
|
||||
}
|
||||
|
||||
const researcherQuality = state.talent.departments.research.effectiveness;
|
||||
const contextBonus = Math.log2(Math.max(1, architecture.contextWindow / 4)) * 3;
|
||||
const contextPenalty = Math.max(0, Math.log2(architecture.contextWindow / 8)) * 2;
|
||||
|
||||
capabilities = {
|
||||
reasoning: clamp(rawCapability * (0.6 + dataMix.scientific * 0.5 + dataMix.code * 0.3) * (1 + researcherQuality * 0.2)),
|
||||
coding: clamp(rawCapability * (0.5 + dataMix.code * 1.0)),
|
||||
creative: clamp(rawCapability * (0.4 + dataMix.books * 0.6 + dataMix.conversation * 0.3)),
|
||||
math: clamp(rawCapability * (0.3 + dataMix.scientific * 0.7 + dataMix.code * 0.2)),
|
||||
knowledge: clamp(rawCapability * (0.5 + dataMix.web * 0.3 + dataMix.books * 0.3) + contextBonus * 0.3),
|
||||
multimodal: clamp(rawCapability * (dataMix.images * 0.5 + dataMix.video * 0.4 + dataMix.audio * 0.2)),
|
||||
agents: clamp(rawCapability * (0.2 + dataMix.code * 0.3 + dataMix.conversation * 0.2) + contextBonus * 0.5),
|
||||
speed: Math.max(1, 100 - params * 0.3 - contextPenalty + (researchBonuses?.inferenceEfficiencyBonus ?? 0) * 20 + (architecture.type === 'moe' ? MOE_SPEED_MULTIPLIER * 10 : 0)),
|
||||
contextUtilization: Math.min(100, architecture.contextWindow * 0.4),
|
||||
};
|
||||
|
||||
if (researchBonuses) {
|
||||
capabilities.reasoning = clamp(capabilities.reasoning + researchBonuses.reasoningBonus);
|
||||
capabilities.coding = clamp(capabilities.coding + researchBonuses.codingBonus);
|
||||
capabilities.creative = clamp(capabilities.creative + researchBonuses.creativeBonus);
|
||||
capabilities.multimodal = clamp(capabilities.multimodal + researchBonuses.multimodalBonus);
|
||||
capabilities.agents = clamp(capabilities.agents + researchBonuses.agentsBonus);
|
||||
}
|
||||
|
||||
const breakthroughBonuses: Partial<Record<keyof ModelCapabilities, number>> = {};
|
||||
for (const event of pipeline.events) {
|
||||
if ((event.type === 'breakthrough' || event.type === 'emergent_capability') && event.impact.capabilityDomain && event.impact.capabilityBonus) {
|
||||
const domain = event.impact.capabilityDomain;
|
||||
breakthroughBonuses[domain] = (breakthroughBonuses[domain] ?? 0) + event.impact.capabilityBonus;
|
||||
}
|
||||
}
|
||||
for (const [domain, bonus] of Object.entries(breakthroughBonuses)) {
|
||||
const key = domain as keyof ModelCapabilities;
|
||||
capabilities[key] = clamp(capabilities[key] + bonus);
|
||||
}
|
||||
}
|
||||
for (const [domain, bonus] of Object.entries(breakthroughBonuses)) {
|
||||
const key = domain as keyof ModelCapabilities;
|
||||
capabilities[key] = clamp(capabilities[key] + bonus);
|
||||
}
|
||||
|
||||
const completedStages: ('pretraining' | 'sft' | 'alignment')[] = ['pretraining'];
|
||||
|
||||
if (pipeline.stages.sft?.isComplete) {
|
||||
if (pipeline.stages.sft.isComplete) {
|
||||
completedStages.push('sft');
|
||||
const sft = pipeline.stages.sft;
|
||||
for (let i = 0; i < sft.specializations.length; i++) {
|
||||
const spec = sft.specializations[i];
|
||||
const bonuses = SFT_SPECIALIZATION_BONUSES[spec];
|
||||
if (!bonuses) continue;
|
||||
const diminishing = i === 0 ? 1.0 : i === 1 ? 0.7 : 0.4;
|
||||
for (const [cap, value] of Object.entries(bonuses)) {
|
||||
const key = cap as keyof ModelCapabilities;
|
||||
capabilities[key] = clamp(capabilities[key] + value * diminishing);
|
||||
if (!sourceModel) {
|
||||
for (let i = 0; i < sft.specializations.length; i++) {
|
||||
const spec = sft.specializations[i];
|
||||
const bonuses = SFT_SPECIALIZATION_BONUSES[spec];
|
||||
if (!bonuses) continue;
|
||||
const diminishing = i === 0 ? 1.0 : i === 1 ? 0.7 : 0.4;
|
||||
for (const [cap, value] of Object.entries(bonuses)) {
|
||||
const key = cap as keyof ModelCapabilities;
|
||||
capabilities[key] = clamp(capabilities[key] + value * diminishing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,7 +378,7 @@ function createBaseModel(
|
||||
let overallSafety = Math.min(100, 30 + safetyResearchBonus + Math.random() * 10);
|
||||
let refusalRate = overallSafety > 60 ? 0.1 : 0.03;
|
||||
|
||||
if (pipeline.stages.alignment?.isComplete) {
|
||||
if (pipeline.stages.alignment.isComplete) {
|
||||
completedStages.push('alignment');
|
||||
const alignment = pipeline.stages.alignment;
|
||||
const methodConfig = ALIGNMENT_METHODS[alignment.method];
|
||||
@@ -409,10 +386,12 @@ function createBaseModel(
|
||||
const safetyGain = methodConfig.safetyGain * alignment.safetyWeight;
|
||||
overallSafety = Math.min(100, overallSafety + safetyGain);
|
||||
refusalRate = methodConfig.baseRefusal * Math.pow(alignment.safetyWeight, 1.5);
|
||||
const capLoss = methodConfig.capabilityLoss * alignment.safetyWeight * 0.5;
|
||||
for (const key of Object.keys(capabilities) as (keyof ModelCapabilities)[]) {
|
||||
if (key !== 'speed' && key !== 'contextUtilization') {
|
||||
capabilities[key] = clamp(capabilities[key] - capLoss);
|
||||
if (!sourceModel) {
|
||||
const capLoss = methodConfig.capabilityLoss * alignment.safetyWeight * 0.5;
|
||||
for (const key of Object.keys(capabilities) as (keyof ModelCapabilities)[]) {
|
||||
if (key !== 'speed' && key !== 'contextUtilization') {
|
||||
capabilities[key] = clamp(capabilities[key] - capLoss);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,10 +405,15 @@ function createBaseModel(
|
||||
honesty: overallSafety * 0.9,
|
||||
};
|
||||
|
||||
const family = state.models.families.find(f => f.id === pipeline.familyId);
|
||||
const version = sourceModel ? Math.round((sourceModel.version + 0.1) * 10) / 10 : 1.0;
|
||||
const familyName = family?.name ?? pipeline.modelName;
|
||||
const autoName = `${familyName} ${SIZE_TIER_LABELS[pipeline.sizeTier]} v${version.toFixed(1)}`;
|
||||
|
||||
return {
|
||||
id: uuid(),
|
||||
familyId: pipeline.familyId,
|
||||
name: pipeline.modelName,
|
||||
name: autoName,
|
||||
architecture,
|
||||
dataMix,
|
||||
capabilities,
|
||||
@@ -439,6 +423,10 @@ function createBaseModel(
|
||||
trainedAtTick: state.meta.tickCount,
|
||||
trainingCostTotal: compute,
|
||||
trainingStagesCompleted: completedStages,
|
||||
sizeTier: pipeline.sizeTier,
|
||||
version,
|
||||
sftSpecializations: pipeline.stages.sft.specializations,
|
||||
alignmentMethod: pipeline.stages.alignment.method,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -467,59 +455,30 @@ function createVariant(job: VariantCreationJob, base: BaseModel): ModelVariant {
|
||||
const caps = { ...base.capabilities };
|
||||
let costMultiplier = 1.0;
|
||||
let speedMultiplier = 1.0;
|
||||
let variantName = base.name;
|
||||
let arch = { ...base.architecture };
|
||||
|
||||
if (job.jobType === 'distillation' && 'targetParameters' in job.config) {
|
||||
const config = job.config;
|
||||
const sizeRatio = config.targetParameters / base.architecture.totalParameters;
|
||||
const retention = DISTILLATION_BASE_RETENTION + sizeRatio * 0.25;
|
||||
const config = job.config;
|
||||
const qConfig = QUANTIZATION_CONFIGS[config.level];
|
||||
if (qConfig) {
|
||||
for (const key of Object.keys(caps) as (keyof ModelCapabilities)[]) {
|
||||
caps[key] = clamp(caps[key] * retention);
|
||||
if (key !== 'speed') caps[key] = clamp(caps[key] * qConfig.qualityRetention);
|
||||
}
|
||||
costMultiplier = sizeRatio * 0.8;
|
||||
speedMultiplier = (1 / sizeRatio) * 0.7;
|
||||
arch = { ...arch, totalParameters: config.targetParameters, activeParameters: config.targetParameters };
|
||||
variantName = config.variantName;
|
||||
} else if (job.jobType === 'fine-tuning' && 'specialization' in job.config) {
|
||||
const config = job.config;
|
||||
const bonuses = SFT_SPECIALIZATION_BONUSES[config.specialization];
|
||||
if (bonuses) {
|
||||
for (const [cap, value] of Object.entries(bonuses)) {
|
||||
caps[cap as keyof ModelCapabilities] = clamp(caps[cap as keyof ModelCapabilities] + value);
|
||||
}
|
||||
}
|
||||
variantName = config.variantName;
|
||||
} else if (job.jobType === 'quantization' && 'level' in job.config) {
|
||||
const config = job.config;
|
||||
const qConfig = QUANTIZATION_CONFIGS[config.level];
|
||||
if (qConfig) {
|
||||
for (const key of Object.keys(caps) as (keyof ModelCapabilities)[]) {
|
||||
if (key !== 'speed') caps[key] = clamp(caps[key] * qConfig.qualityRetention);
|
||||
}
|
||||
caps.speed = clamp(caps.speed * qConfig.speedMultiplier);
|
||||
costMultiplier = qConfig.costMultiplier;
|
||||
speedMultiplier = qConfig.speedMultiplier;
|
||||
}
|
||||
variantName = config.variantName;
|
||||
caps.speed = clamp(caps.speed * qConfig.speedMultiplier);
|
||||
costMultiplier = qConfig.costMultiplier;
|
||||
speedMultiplier = qConfig.speedMultiplier;
|
||||
}
|
||||
|
||||
return {
|
||||
id: uuid(),
|
||||
familyId: base.familyId,
|
||||
baseModelId: base.id,
|
||||
name: variantName,
|
||||
variantType: job.jobType === 'distillation' ? 'distilled' : job.jobType === 'fine-tuning' ? 'fine-tuned' : 'quantized',
|
||||
architecture: arch,
|
||||
name: config.variantName,
|
||||
variantType: 'quantized',
|
||||
architecture: { ...base.architecture },
|
||||
capabilities: caps,
|
||||
safetyProfile: { ...base.safetyProfile },
|
||||
isDeployed: false,
|
||||
createdAtTick: 0,
|
||||
quantization: job.jobType === 'quantization' && 'level' in job.config ? job.config.level : undefined,
|
||||
distillationRetention: job.jobType === 'distillation' && 'targetParameters' in job.config
|
||||
? DISTILLATION_BASE_RETENTION + (job.config.targetParameters / base.architecture.totalParameters) * 0.25
|
||||
: undefined,
|
||||
finetuneSpecialization: job.jobType === 'fine-tuning' && 'specialization' in job.config ? job.config.specialization : undefined,
|
||||
quantization: config.level,
|
||||
costMultiplier,
|
||||
speedMultiplier,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user