recommendation-pool / index.html
ZouBo's picture
Upload 2 files
e0cc29f verified
Raw History Blame Contribute Delete
58.5 kB
<meta charset="utf-8">
<title>The Recommendation Pool</title>
<meta name="claude:viewport-fit-sides" content="cover">
<style>
/* 版式:一张横放的方格草稿纸,整张画面就是那个循环;控件在纸下方。单一纸面风格,不随深浅色切换。 */
:root{
--paper:#fbfbf7; --grid:#dfe7ee; --ink:#262a33; --ink-soft:#6b7280;
--cake:#e0484f; --pop:#f2c14e; --poop:#8a5a3b; --coin:#d9a520; --accent:#2f5fb3; --bad:#c2410c;
--font:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Source Han Sans SC",system-ui,sans-serif;
color-scheme:light;
}
html,body{background:var(--paper);color:var(--ink)}
body{font-family:var(--font);padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:16px 28px}
.wrap{max-width:1280px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
.scene{position:relative;width:100%;aspect-ratio:16/9;max-width:100%;border:1.5px solid var(--ink);border-radius:6px;overflow:hidden;background:var(--paper)}
canvas{display:block;width:100%;height:100%}
.controls{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center}
.group{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.lab{font-size:13px;color:var(--ink-soft);letter-spacing:.04em}
button{font:inherit;font-size:14px;color:var(--ink);background:var(--paper);border:1.5px solid var(--ink);border-radius:999px;padding:6px 14px;cursor:pointer}
button:hover{background:#eef2f7}
button:focus-visible,input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
button.primary{background:var(--ink);color:var(--paper)}
button.primary:hover{background:#3b4150}
button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
input[type=range]{width:220px;max-width:100%;accent-color:var(--accent)}
output{font-variant-numeric:tabular-nums;font-weight:700;min-width:4.2em;display:inline-block}
.note{font-size:12.5px;color:var(--ink-soft);line-height:1.6;max-width:72ch;margin:0}
@media (max-width:640px){input[type=range]{width:160px}}
</style>
<div class="wrap">
<div class="scene"><canvas id="c" width="1600" height="900" aria-label="推荐池模拟画面"></canvas></div>
<div class="controls">
<div class="group">
<button id="play" class="primary" type="button">开始</button>
<button id="reset" type="button">重来</button>
</div>
<div class="group">
<span class="lab" id="kLab">AI 产能倍数</span>
<input id="k" type="range" min="0" max="2" step="0.01" value="2" aria-labelledby="kLab">
<output id="kOut" for="k">×100</output>
<span class="group" id="presets"></span>
</div>
<div class="group">
<span class="lab" id="spLab">速度</span>
<button type="button" data-speed="0.5">慢</button>
<button type="button" data-speed="1" aria-pressed="true">正常</button>
<button type="button" data-speed="2.5">快</button>
</div>
<div class="group"><button id="lang" type="button">English</button></div>
</div>
<p class="note" id="note">示意模型。筛选部分(三层试看、完播率、晋级线)沿用论文图 1 的参数;用户去留、分成、成本、创作者去留等循环部分的规则与参数未经实证校准,只用来展示方向。</p>
</div>
<script>
// 推荐池演示:模拟核心(浏览器和命令行共用)
// 一步 = 一天。筛选部分(三层试看)沿用 模拟.py 的参数和公式;其余为示意规则,未经实证校准。
(function (root) {
"use strict";
// ---------- 写死的设定 ----------
const P = {
// 筛选部分:与 模拟.py 相同
COMPLETION: { cake: 0.40, pop: 0.30, poop: 0.30 }, // 试看完播率:好内容 / 平庸内容 / 批量垃圾(试看表现和平庸内容一样,区别在用户看完会反感)。数值都取自论文图 1
CUT: 0.35, // 晋级线
TIERS: [100, 500, 2500], // 基线下三层每条试看人数
TRIAL_SHARE: 0.10, // 用户时间里用于试看的份额
// 用户
U0: 10000, // 一倍时的用户数
VIEWS_PER_USER: 60, // 每人每天看多少条(固定)
JOY: { cake: 1.0, pop: 0.0, poop: -1.5 }, // 看一条带来的满意度
USER_REACT: 0.04, // 用户数每天向"目标人数"靠拢的速度
USER_MEMORY: 0.15, // 满意度的平滑:越小,用户记得越久
USER_SENS: 0.7, // 满意度每差 1,平台能留住的人数变化多少(相对一倍时)
// 钱
AD_PRICE: 0.01, // 每次观看卖给广告商的价钱(元)
CREATOR_SHARE: 0.30, // 分给创作者的比例;平台拿大头
COST_PER_ITEM: 0.1, // 每条内容的存储、分发、审核成本(元)
COST_FIXED: 600, // 平台每天的固定开支(元)
// 创作者:一倍时
RATE0: { cake: 0.2, pop: 0.5, poop: 0.2 }, // 每人每天做几条(做蛋糕的五天一条)
ENTRY0: { cake: 0.27, pop: 13 }, // 每天进来试试的新人
WAGE: 22, // 每天至少要挣到多少才肯留下(元);做蛋糕的、做爆米花的、机器人都一样
PATIENCE: { cake: 30, pop: 30, poop: 20 }, // 连续挣不够多少天就走
RETIRE: 0.005, // 每天自然退出的比例
// AI 产能倍数 k 的作用
HUMAN_RATE_EXP: 0.65, // 真人每人产能 × k^0.65(要打磨,涨得少;一百倍时约二十倍)
TALENT_EXP: 0.35, // 工具释放有能力的人:做蛋糕的新人 × k^0.35。两者相乘,蛋糕总量 × k(乐观设定,同论文"同比例增加")
BOT_RATE_EXP: 1.0, // 做便便的:按账号算,每个号产能 × k(用 Boo 的数:不管质量,一百倍时一天二十条)
BOT_COST_EXP: 0.5, // 每个号每天要挣回的成本 = 工钱 ÷ k^0.5:AI 越强,一个人能管的号越多,摊到每个号上越便宜
POOP_PER_K: 320, // 倍数每涨 1,做便便的号的"目标数"多 160 个(平台收入不变时)
POOP_ARRIVE: 0.08, // 号数低于目标时,每天补上差距的 8%
POOP_LEAVE: 0.03, // 号数高于目标时,每天走掉超出部分的 3%
BOT_ENTRY_BASE: 1.0, // 有利可图时每天冒出来的机器人基数
BOT_COPY: 0.15, // 跟风:已有机器人越多,跟进得越快
CONVERT: 0.5, // 新机器人里,由做爆米花的人转行过来的比例
TOP_LIFE: 3, // 进了最上层的内容在推荐流里待几天
};
// 与 模拟.py 相同的晋级概率(正态近似;试看人数少于 1 按 1 计)
function erf(x) { // Abramowitz–Stegun 7.1.26,误差 < 1.5e-7
const s = Math.sign(x); x = Math.abs(x);
const t = 1 / (1 + 0.3275911 * x);
const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
return s * y;
}
function cdf(x) { return 0.5 * (1 + erf(x / Math.SQRT2)); }
function passRate(p, n) { n = Math.max(n, 1); return 1 - cdf((P.CUT - p) / Math.sqrt(p * (1 - p) / n)); }
function rng(seed) { // 可复现的随机数
let s = seed >>> 0 || 1;
return function () { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
}
const TYPES = ["cake", "pop", "poop"];
// 一倍时各层的流入量(每条新内容 = 1),用来确定每层的固定试看预算
const BASE_MIX = { cake: 0.05, pop: 0.95, poop: 0 };
const BASE_IN = (function () {
let m = Object.assign({}, BASE_MIX); const ins = [];
for (const n of P.TIERS) {
ins.push(m.cake + m.pop + m.poop);
m = { cake: m.cake * passRate(P.COMPLETION.cake, n), pop: m.pop * passRate(P.COMPLETION.pop, n), poop: 0 };
}
return ins;
})();
const TRIAL_VIEWS_PER_ITEM0 = P.TIERS.reduce((a, n, i) => a + n * BASE_IN[i], 0); // 一倍时每条新内容平均消耗的试看次数
const V0 = P.U0 * P.VIEWS_PER_USER * P.TRIAL_SHARE / TRIAL_VIEWS_PER_ITEM0; // 一倍时每天的新内容条数
// 一层层筛:给定今天各类新内容条数和用户数,返回各层流入、最上层产出、试看分布
function sieve(counts, users) {
const budgetScale = users / P.U0; // 注意力 ∝ 用户数
let m = Object.assign({}, counts);
const tiers = [];
const trialViews = { cake: 0, pop: 0, poop: 0 };
for (let i = 0; i < P.TIERS.length; i++) {
const inflow = m.cake + m.pop + m.poop;
const budget = P.TIERS[i] * BASE_IN[i] * V0 * budgetScale; // 这一层每天的固定试看总量
const n = inflow > 0 ? budget / inflow : P.TIERS[i];
const next = {};
for (const t of TYPES) { trialViews[t] += m[t] * n; next[t] = m[t] * passRate(P.COMPLETION[t], n); } // 实际花掉的试看次数按预算分;晋级概率按 模拟.py 的口径(少于 1 人按 1 计)
tiers.push({ inflow, n, mix: Object.assign({}, m) });
m = next;
}
return { tiers, top: m, trialViews };
}
function makeWorld(opts) {
opts = opts || {};
const R = rng(opts.seed || 7);
const w = {
day: 0, k: 1, kTarget: opts.k || 1, aiDay: opts.aiDay == null ? 20 : opts.aiDay,
users: P.U0, sat: 0, creators: [], nextId: 1,
topPool: [], // 每天进最上层的 {cake,pop,poop}
log: [], events: { left: [], joined: [], usersLeft: 0, usersJoined: 0 },
today: null, R,
};
// 一倍时的稳态人数
const nCake = Math.round(P.ENTRY0.cake / P.RETIRE * 1.0);
const nPop = Math.round(P.ENTRY0.pop * P.PATIENCE.pop * 0.9);
for (let i = 0; i < nCake; i++) w.creators.push(newCreator(w, "cake", Math.floor(R() * 200)));
for (let i = 0; i < nPop; i++) { const c = newCreator(w, "pop", 0); c.broke = Math.floor(R() * P.PATIENCE.pop); w.creators.push(c); }
// 预热:把最上层推荐流填成一倍时的样子
const warm = sieve({ cake: V0 * 0.05, pop: V0 * 0.95, poop: 0 }, P.U0).top;
for (let i = 0; i < P.TOP_LIFE; i++) w.topPool.push(Object.assign({}, warm));
w.sat = satisfaction(w, { cake: V0 * 0.05, pop: V0 * 0.95, poop: 0 });
w.sat0 = w.sat;
w.rev0 = P.U0 * P.VIEWS_PER_USER * P.AD_PRICE;
return w;
}
function newCreator(w, type, age) {
// luck:真人是运气(收入 ×0.6~1.4);做便便的号是成本高低(成本 ×0.2~1.8,有的号几乎不花钱)。patience:耐心 ×0.5~1.5,大家不会同一天一起走
return { id: w.nextId++, type, age: age || 0, broke: 0, earned: 0, items: 0, luck: 0.6 + 0.8 * w.R(), costMul: 0.2 + 1.6 * w.R(), pat: 0.5 + w.R() };
}
function feedMix(w) {
const s = { cake: 0, pop: 0, poop: 0 };
for (const d of w.topPool) for (const t of TYPES) s[t] += d[t];
const tot = s.cake + s.pop + s.poop;
return tot > 0 ? { cake: s.cake / tot, pop: s.pop / tot, poop: s.poop / tot, total: tot } : { cake: 0, pop: 0, poop: 0, total: 0 };
}
function satisfaction(w, newCounts) {
const f = feedMix(w);
const nt = newCounts.cake + newCounts.pop + newCounts.poop;
let s = 0;
for (const t of TYPES) s += P.JOY[t] * ((1 - P.TRIAL_SHARE) * f[t] + P.TRIAL_SHARE * (nt > 0 ? newCounts[t] / nt : 0));
return s;
}
function step(w) {
const R = w.R;
w.day++;
// AI 到来:第 aiDay 天起,倍数在两周内爬到目标值
if (w.day >= w.aiDay) w.k = Math.min(w.kTarget, w.k * Math.pow(w.kTarget, 1 / 14));
if (w.kTarget < w.k) w.k = w.kTarget;
const k = w.k;
const humanRate = Math.pow(k, P.HUMAN_RATE_EXP), talent = Math.pow(k, P.TALENT_EXP);
// 1. 出东西
const made = { cake: 0, pop: 0, poop: 0 };
for (const c of w.creators) {
const r = c.type === "poop" ? P.RATE0.poop * Math.pow(k, P.BOT_RATE_EXP) : P.RATE0[c.type] * humanRate;
c.todayItems = r; made[c.type] += r;
}
// 2. 筛
const sv = sieve(made, w.users);
w.topPool.push(sv.top); if (w.topPool.length > P.TOP_LIFE) w.topPool.shift();
// 3. 看:推荐流里各类内容的观看次数
const views = w.users * P.VIEWS_PER_USER;
const feedViews = views * (1 - P.TRIAL_SHARE);
const trialViews = views * P.TRIAL_SHARE;
const fm = feedMix(w);
// 每条新内容平均拿到的观看:试看 + 进了最上层后在推荐流里分到的
const topItems = fm.total; // 推荐流里同时在架的条数
const viewsPerTopItemPerDay = topItems > 0 ? feedViews / topItems : 0;
const trialPerItem = {}, topProb = {};
for (const t of TYPES) {
trialPerItem[t] = made[t] > 0 ? sv.trialViews[t] / made[t] : 0;
topProb[t] = made[t] > 0 ? sv.top[t] / made[t] : 0;
}
// 4. 钱
const revenue = views * P.AD_PRICE;
const pool = revenue * P.CREATOR_SHARE;
const yuanPerView = pool / views;
const cost = P.COST_FIXED + P.COST_PER_ITEM * (made.cake + made.pop + made.poop);
const perItemIncome = {};
for (const t of TYPES) perItemIncome[t] = yuanPerView * (trialPerItem[t] + topProb[t] * viewsPerTopItemPerDay * P.TOP_LIFE);
// 预期:如果有人做一条便便,它能挣多少(还没有机器人时也算得出来)
const n1 = sv.tiers[0].n, n2 = sv.tiers[1].n, n3 = sv.tiers[2].n;
const poopTopProb = passRate(P.COMPLETION.poop, n1) * passRate(P.COMPLETION.poop, n2) * passRate(P.COMPLETION.poop, n3);
const poopIncomeProspect = made.poop > 0 ? perItemIncome.poop : yuanPerView * (Math.max(n1, 1) + poopTopProb * viewsPerTopItemPerDay * P.TOP_LIFE);
// 5. 创作者去留
const left = [], joined = [];
const keep = [];
for (const c of w.creators) {
c.age++;
const inc = perItemIncome[c.type] * c.todayItems * (c.type === "poop" ? 1 : c.luck);
c.earned = 0.8 * c.earned + 0.2 * inc;
const need = c.type === "poop" ? c.costMul * P.WAGE / Math.pow(k, P.BOT_COST_EXP) : P.WAGE;
if (inc < need) c.broke++; else c.broke = Math.max(0, c.broke - 2);
let go = c.type !== "poop" && c.broke >= P.PATIENCE[c.type] * c.pat;
if (c.type !== "poop" && R() < P.RETIRE) go = true;
if (go) left.push(c); else keep.push(c);
}
w.creators = keep;
// 新人
const add = (type, expected) => { let n = Math.floor(expected); if (R() < expected - n) n++; for (let i = 0; i < n; i++) { const c = newCreator(w, type, 0); w.creators.push(c); joined.push(c); } };
add("cake", P.ENTRY0.cake * talent);
add("pop", P.ENTRY0.pop);
// 做便便的:成本几乎为零、买彩票心态,人数主要取决于 AI 让批量生产变得多容易(同 模拟.py:新增的量是批量内容),
// 只温和地跟着钱走:平台收入越少,来的人越少,但不会走光。一部分是做爆米花的人"装上天线"转过来的
const nBots = w.creators.reduce((a, c) => a + (c.type === "poop"), 0);
const botTarget = P.POOP_PER_K * (k - 1) * Math.sqrt(Math.max(0.02, revenue / w.rev0));
w.converted = [];
const gap = botTarget - nBots;
if (gap > 0) {
let n = Math.floor(gap * P.POOP_ARRIVE); if (R() < gap * P.POOP_ARRIVE - n) n++;
const pops = w.creators.filter(c => c.type === "pop"); let convLeft = Math.floor(pops.length * 0.02);
for (let i = 0; i < n; i++) {
if (convLeft > 0 && R() < P.CONVERT) { const c = pops[Math.floor(R() * pops.length)]; if (c.type === "pop") { c.type = "poop"; w.converted.push(c); convLeft--; continue; } }
const c = newCreator(w, "poop", 0); w.creators.push(c); joined.push(c);
}
} else if (gap < 0) {
let n = Math.floor(-gap * P.POOP_LEAVE); if (R() < -gap * P.POOP_LEAVE - n) n++;
if (n > 0) { const out = new Set(); const bots = w.creators.filter(c => c.type === "poop");
while (out.size < Math.min(n, bots.length)) out.add(bots[Math.floor(R() * bots.length)]);
w.creators = w.creators.filter(c => !out.has(c)); for (const c of out) left.push(c); }
}
// 6. 用户去留
const sToday = satisfaction(w, made);
w.sat = (1 - P.USER_MEMORY) * w.sat + P.USER_MEMORY * sToday;
// 用户忍耐度不一:满意度每差 1,留下的人按比例少一截,越往后剩下的越是不挑的人,走得越慢
const target = P.U0 * Math.exp(P.USER_SENS * (w.sat - w.sat0));
const before = w.users;
w.users = w.users + P.USER_REACT * (target - w.users);
const count = { cake: 0, pop: 0, poop: 0 };
for (const c of w.creators) count[c.type]++;
w.today = {
day: w.day, k, made, sv, fm, revenue, cost, pool, users: w.users, sat: w.sat,
count, perItemIncome, botTarget, poopIncomeProspect, converted: w.converted,
cakeShareTop: fm.cake, left, joined, usersDelta: w.users - before,
};
w.log.push({ day: w.day, k, users: w.users, revenue, cost, cakeTop: fm.cake, poopTop: fm.poop, nCake: count.cake, nPop: count.pop, nBot: count.poop, items: made.cake + made.pop + made.poop, n1 });
return w.today;
}
const API = { P, makeWorld, step, sieve, passRate, V0, TYPES };
if (typeof module !== "undefined" && module.exports) module.exports = API; else root.PoolSim = API;
})(typeof window !== "undefined" ? window : globalThis);
</script>
<script>
(function(){
"use strict";
const S = window.PoolSim, P = S.P;
const cv = document.getElementById("c"), ctx = cv.getContext("2d");
const W = 1600, H = 900;
const DPR = Math.min(2, window.devicePixelRatio || 1); cv.width = W * DPR; cv.height = H * DPR;
const FONT = getComputedStyle(document.documentElement).getPropertyValue("--font");
const C = { paper:"#fbfbf7", grid:"#dfe7ee", ink:"#262a33", soft:"#6b7280", cake:"#e0484f", cream:"#fff6f2", pink:"#f4a6b0", pop:"#f2c14e", popHi:"#fff1c4", poop:"#8a5a3b", poopHi:"#a87452", coin:"#d9a520", coinHi:"#f7d774", accent:"#2f5fb3", bad:"#c2410c", good:"#2f8f5b", leaf:"#3f9a4a", strip:"#ffffffee" };
const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---------- 文字 ----------
const T = {
zh:{ title:"推荐池", sub:"免费看 · 广告养 · 分成活", ai:"AI 产能", day:"第 {d} 天",
m:["用户","广告收入 / 天","平台成本 / 天","推荐池里的蛋糕"], unitYuan:"元",
stage:"创作者", cake:"做蛋糕的", pop:"做爆米花的", bot:"做便便的", stageNote:"台上小人按比例代表全部创作者(做便便的按账号算)",
tier:["第一层","第二层","第三层"], perItem:"每条 {n} 人试看", rejected:"没被推荐", feed:"推荐流", plaza:"用户",
plazaNote:"一个点 = 50 位用户", adv:"广告商", vault:"平台", keep:"净赚 {v} 元/天", lose:"亏 {v} 元/天",
server:"存储·分发·审核", toCreators:"分给创作者 {v} 元/天", adIn:"广告费 {v} 元/天", attention:"用户的时间,卖给广告商",
play:"开始", pause:"暂停", resume:"继续", reset:"重来", kLab:"AI 产能倍数", spLab:"速度", slow:"慢", normal:"正常", fast:"快", lang:"English",
note:"示意模型。筛选部分(三层试看、完播率、晋级线)沿用论文图 1 的参数;用户去留、分成、成本、创作者去留等循环部分的规则与参数未经实证校准,只用来展示方向。",
cap:{ intro:"一年前,AI 还没来。好内容被一层层试看挑出来,用户吃得开心。", ready:"拧好 AI 产能倍数,按「开始」。",
ai:"AI 来了:每个人的产能 ×{k}", bot:"装天线的来了:批量做,划算了。", flood:"推荐池里混进了大量便便。",
users:"用户开始走了,广告费跟着少了。", chefs:"做蛋糕的人挣不到钱,背上包走了。", botsGone:"钱少了,做便便的退了一批,剩下的赖着不走。",
popFlood:"爆米花挤满了推荐流,好内容被淹没。", calm:"产能涨了,推荐池还挑得出来。",
half:"上传的东西一半是便便,筛子还挡得住。", lottery:"每条只剩一两个人试看,筛子成了抽签。" },
endFail:"内容多了 {k} 倍,推荐池挑不出好内容了", endOk:"{k} 倍时,推荐池还挑得出好内容", endHead:"第 365 天", from:"一年前", to:"现在", again:"按「重来」再看一遍,或者换个倍数。" },
en:{ title:"The Recommendation Pool", sub:"Free to watch · ad-funded · revenue-shared", ai:"AI output", day:"Day {d}",
m:["Users","Ad revenue / day","Platform cost / day","Cake in the pool"], unitYuan:"¥",
stage:"Creators", cake:"Cake", pop:"Popcorn", bot:"Poop", stageNote:"Figures show the creator mix in proportion (poop makers counted by account)",
tier:["Tier 1","Tier 2","Tier 3"], perItem:"{n} trial viewers each", rejected:"Not recommended", feed:"Feed", plaza:"Users",
plazaNote:"one dot = 50 users", adv:"Advertisers", vault:"Platform", keep:"Nets ¥{v}/day", lose:"Loses ¥{v}/day",
server:"Storage · delivery · review", toCreators:"To creators ¥{v}/day", adIn:"Ad spend ¥{v}/day", attention:"Users' time, sold to advertisers",
play:"Start", pause:"Pause", resume:"Resume", reset:"Restart", kLab:"AI output multiple", spLab:"Speed", slow:"Slow", normal:"Normal", fast:"Fast", lang:"中文",
note:"Illustrative model. The selection part (three trial tiers, completion rates, cut-off) uses the parameters of Figure 1 in the paper; the loop part (users, revenue share, cost, creators coming and going) uses rules and parameters that are not empirically calibrated and only show the direction.",
cap:{ intro:"A year ago, before AI. Good content is picked out tier by tier, and users are happy.", ready:"Set the AI output multiple, then press Start.",
ai:"AI arrives: everyone's output ×{k}", bot:"Antennas show up: batch production now pays.", flood:"The pool is filling up with poop.",
users:"Users start leaving, and ad money leaves with them.", chefs:"Cake makers can't earn a living and pack up.", botsGone:"The money dries up. Some poop makers quit; the rest stay put.",
popFlood:"Popcorn crowds the feed and good content gets buried.", calm:"Output is up, and the pool still picks well.",
half:"Half of all uploads are poop. The sieve still holds.", lottery:"One or two trial viewers per item: the sieve has become a lottery." },
endFail:"With {k}× the content, the pool can no longer pick out good content", endOk:"At {k}×, the pool still picks out good content", endHead:"Day 365", from:"A year ago", to:"Now", again:"Press Restart to watch again, or try another multiple." },
};
let lang = "zh";
const tr = (key, vars) => { let s = key.split(".").reduce((o, k) => o[k], T[lang]); if (vars) for (const k in vars) s = s.replace("{" + k + "}", vars[k]); return s; };
const fmtInt = v => Math.round(v).toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
const fmtK = k => k >= 10 ? Math.round(k).toString() : (Math.round(k * 10) / 10).toString();
// ---------- 手绘工具 ----------
function hash(n){ n = (n ^ 61) ^ (n >>> 16); n = n + (n << 3); n = n ^ (n >>> 4); n = Math.imul(n, 0x27d4eb2d); n = n ^ (n >>> 15); return (n >>> 0) / 4294967296; }
function jit(seed, a){ return (hash(seed) - 0.5) * 2 * a; }
function line(x1,y1,x2,y2,seed,w,col,a){ a = a == null ? 1.2 : a; ctx.strokeStyle = col || C.ink; ctx.lineWidth = w || 2; ctx.beginPath(); ctx.moveTo(x1 + jit(seed,a), y1 + jit(seed+1,a)); const mx = (x1+x2)/2 + jit(seed+2,a*1.5), my = (y1+y2)/2 + jit(seed+3,a*1.5); ctx.quadraticCurveTo(mx, my, x2 + jit(seed+4,a), y2 + jit(seed+5,a)); ctx.stroke(); }
function rrect(x,y,w,h,seed,fill,stroke,lw){ ctx.beginPath(); const a = 1.4; ctx.moveTo(x + jit(seed,a), y + jit(seed+1,a)); ctx.lineTo(x + w + jit(seed+2,a), y + jit(seed+3,a)); ctx.lineTo(x + w + jit(seed+4,a), y + h + jit(seed+5,a)); ctx.lineTo(x + jit(seed+6,a), y + h + jit(seed+7,a)); ctx.closePath(); if (fill){ ctx.fillStyle = fill; ctx.fill(); } if (stroke !== false){ ctx.strokeStyle = stroke || C.ink; ctx.lineWidth = lw || 2; ctx.stroke(); } }
function circ(x,y,r,fill,stroke,lw){ ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); if (fill){ ctx.fillStyle = fill; ctx.fill(); } if (stroke !== false){ ctx.strokeStyle = stroke || C.ink; ctx.lineWidth = lw || 2; ctx.stroke(); } }
function text(s,x,y,size,col,align,weight,base){ ctx.font = (weight||400) + " " + size + "px " + FONT; ctx.fillStyle = col || C.ink; ctx.textAlign = align || "left"; ctx.textBaseline = base || "alphabetic"; ctx.fillText(s,x,y); }
function textW(s,size,weight){ ctx.font = (weight||400) + " " + size + "px " + FONT; return ctx.measureText(s).width; }
// ---------- 三种东西 ----------
function drawItem(type,x,y,s,alpha){
ctx.save(); ctx.globalAlpha = alpha == null ? 1 : alpha; ctx.translate(x,y);
const lw = Math.max(1, s * 0.09);
if (type === "cake"){
ctx.beginPath(); ctx.moveTo(-s*0.5, s*0.32); ctx.lineTo(s*0.5, s*0.32); ctx.lineTo(s*0.42,-s*0.12); ctx.lineTo(-s*0.42,-s*0.12); ctx.closePath();
ctx.fillStyle = C.cream; ctx.fill(); ctx.strokeStyle = C.ink; ctx.lineWidth = lw; ctx.stroke();
ctx.fillStyle = C.pink; ctx.fillRect(-s*0.46, s*0.06, s*0.92, s*0.09);
circ(0, -s*0.26, s*0.2, C.cake, C.ink, lw);
ctx.fillStyle = C.leaf; ctx.beginPath(); ctx.ellipse(0, -s*0.47, s*0.12, s*0.05, 0, 0, Math.PI*2); ctx.fill();
} else if (type === "pop"){
const pts = [[-0.22,0.12],[0.2,0.14],[0,-0.1],[-0.25,-0.18],[0.24,-0.16],[0.02,0.3]];
for (const [px,py] of pts){ circ(px*s, py*s, s*0.2, C.pop, C.ink, lw); }
circ(-s*0.05, -s*0.14, s*0.07, C.popHi, false);
} else {
ctx.fillStyle = C.poop; ctx.strokeStyle = C.ink; ctx.lineWidth = lw;
ctx.beginPath(); ctx.ellipse(0, s*0.22, s*0.46, s*0.16, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.ellipse(0, s*0.02, s*0.34, s*0.14, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.ellipse(0, -s*0.16, s*0.22, s*0.12, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-s*0.04,-s*0.27); ctx.quadraticCurveTo(s*0.08,-s*0.42, s*0.14,-s*0.46); ctx.lineTo(s*0.02,-s*0.27); ctx.closePath(); ctx.fill(); ctx.stroke();
if (s >= 18){ circ(-s*0.13, s*0.0, s*0.08, "#fff", C.ink, lw*0.7); circ(s*0.13, s*0.0, s*0.08, "#fff", C.ink, lw*0.7); circ(-s*0.12, s*0.01, s*0.035, C.ink, false); circ(s*0.14, s*0.01, s*0.035, C.ink, false);
ctx.beginPath(); ctx.arc(0, s*0.14, s*0.08, 0.15*Math.PI, 0.85*Math.PI); ctx.lineWidth = lw*0.7; ctx.stroke(); }
}
ctx.restore();
}
// ---------- 小人 ----------
function drawPerson(x,y,type,opt){
opt = opt || {}; const seed = opt.seed || 1, mood = opt.mood == null ? 1 : opt.mood;
ctx.save(); ctx.translate(x,y); if (opt.flip) ctx.scale(-1,1); ctx.globalAlpha = opt.alpha == null ? 1 : opt.alpha;
const lw = 2.2;
const walk = opt.walk || 0;
// 腿
line(0,-6, -7 + Math.sin(walk)*6, 14, seed, lw); line(0,-6, 7 - Math.sin(walk)*6, 14, seed+9, lw);
// 身子
line(0,-30, 0,-6, seed+20, lw);
// 背包
if (opt.pack){ rrect(-16,-30,11,18,seed+40,"#c9b48a",C.ink,1.8); }
// 左手:摊开
line(0,-24, -16,-16, seed+30, lw); ctx.beginPath(); ctx.arc(-19,-17,4,Math.PI*0.6,Math.PI*1.7); ctx.strokeStyle = C.ink; ctx.lineWidth = 1.8; ctx.stroke();
// 右手:往前推
const push = opt.push == null ? 0 : opt.push, blur = opt.blur || 0;
if (blur > 0){ for (let i = 0; i < 4; i++){ const p = i/3; ctx.globalAlpha = (opt.alpha==null?1:opt.alpha) * 0.25; line(0,-24, 6 + 16*p, -24 + 6*(1-p), seed+50+i, lw); } ctx.globalAlpha = opt.alpha == null ? 1 : opt.alpha;
ctx.strokeStyle = C.soft; ctx.lineWidth = 1.2; for (let i = 0; i < 3; i++){ ctx.beginPath(); ctx.moveTo(26, -26 + i*5); ctx.lineTo(34, -26 + i*5); ctx.stroke(); } }
else { line(0,-24, 8 + 14*push, -22 + 4*push, seed+60, lw); }
// 头
const headFill = type === "poop" ? "#eef1f6" : "#ffffff";
circ(0,-44, 14, headFill, C.ink, lw);
if (type === "poop"){ ctx.fillStyle = C.ink; ctx.fillRect(-7,-48,4,4); ctx.fillRect(3,-48,4,4); line(-5,-38,5,-38,seed+70,1.6,C.ink,0.3); }
else { circ(-5,-46,1.7,C.ink,false); circ(5,-46,1.7,C.ink,false); ctx.beginPath(); ctx.strokeStyle = C.ink; ctx.lineWidth = 1.6;
if (mood > 0.5) ctx.arc(0,-41,5,0.15*Math.PI,0.85*Math.PI); else if (mood > -0.2){ ctx.moveTo(-4,-38); ctx.lineTo(4,-38); } else ctx.arc(0,-34,5,1.15*Math.PI,1.85*Math.PI); ctx.stroke(); }
// 头顶
if (type === "cake"){ ctx.beginPath(); ctx.fillStyle = "#fff"; ctx.strokeStyle = C.ink; ctx.lineWidth = 1.8; ctx.rect(-10,-64,20,7); ctx.fill(); ctx.stroke();
circ(-7,-69,6,"#fff",C.ink,1.8); circ(0,-72,7,"#fff",C.ink,1.8); circ(7,-69,6,"#fff",C.ink,1.8); ctx.fillStyle = "#fff"; ctx.fillRect(-9,-67,18,5); }
if (type === "poop"){ line(0,-58,2,-72,seed+80,1.8); const blink = opt.blink ? C.cake : "#f08a8f"; circ(2,-75,3.5,blink,C.ink,1.4); }
if (opt.spark){ ctx.strokeStyle = C.coin; ctx.lineWidth = 2; for (let i = 0; i < 6; i++){ const a = i/6*Math.PI*2; ctx.beginPath(); ctx.moveTo(2+Math.cos(a)*8,-75+Math.sin(a)*8); ctx.lineTo(2+Math.cos(a)*14,-75+Math.sin(a)*14); ctx.stroke(); } }
ctx.restore();
}
// ---------- 版面坐标 ----------
const L = {
stage:{ x:28, y:150, w:450, h:500 },
slots:[], // 填在下面
sieve:[ {x:730,y:225}, {x:730,y:385}, {x:730,y:545} ], bowlW:240,
bin:{ x:730, y:640 },
feed:{ x1:1060, x2:1572, y:598 },
plaza:{ x:1040, y:150, w:540, h:420 },
adv:{ x:1500, y:810 }, vault:{ x:830, y:800 }, server:{ x:1050, y:820 },
};
const COLS = 6, ROWS = 5;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) L.slots.push({ x: 72 + c*66 + (r%2)*14, y: 262 + r*76 });
const NSLOT = L.slots.length;
// ---------- 状态 ----------
let world, today, kSel = 100, running = false, started = false, ended = false, speed = 1, dayAcc = 0;
const DAY_SEC = 0.16;
let slots = [], walkers = [], items = [], pops = [], coins = [], dots = [], reacts = [], hist = [], belt = [];
let caps = [], capShown = null, capT = 0, flags = {}, peakBots = 0, startStats = null, endStats = null;
function reset(){
world = S.makeWorld({ k: kSel, seed: 7 }); world.kTarget = kSel;
today = probeToday();
running = false; started = false; ended = false; dayAcc = 0;
slots = []; walkers = []; items = []; pops = []; coins = []; dots = []; reacts = []; hist = []; belt = [];
caps = []; capShown = { key:"intro" }; capT = 0; flags = {}; peakBots = 0; endStats = null;
layoutSlots(true); layoutDots(true);
startStats = { users: P.U0, revenue: today.revenue, cost: today.cost, cake: today.cakeShareTop };
pushHist(today);
setPlayLabel();
}
// 一倍时的"今天":复制世界跑一步拿数,不推进真正的世界
function probeToday(){
const w2 = S.makeWorld({ k: 1, seed: 7 }); w2.kTarget = 1; return S.step(w2);
}
function pushHist(t){ hist.push({ users:t.users, revenue:t.revenue, cost:t.cost, cake:t.cakeShareTop }); }
// 台上的小人:按人数比例分 30 个位子,有人就至少 1 个位子
function targetSlots(count){
const tot = count.cake + count.pop + count.poop || 1; const types = ["cake","pop","poop"]; const out = {}; let used = 0; const rem = [];
for (const t of types){ const ex = count[t] / tot * NSLOT; out[t] = Math.floor(ex); if (count[t] > 0 && out[t] === 0) out[t] = 1; used += out[t]; rem.push([ex - Math.floor(ex), t]); }
rem.sort((a,b) => b[0]-a[0]); let i = 0;
while (used < NSLOT){ out[rem[i % 3][1]]++; used++; i++; }
while (used > NSLOT){ const t = types.slice().sort((a,b) => out[b]-out[a])[0]; out[t]--; used--; }
return out;
}
function layoutSlots(instant, converted){
const count = today ? today.count : world.creators.reduce((o,c) => (o[c.type]++, o), {cake:0,pop:0,poop:0});
const tgt = targetSlots(count);
if (instant || slots.length === 0){
const order = []; for (const t of ["cake","pop","poop"]) for (let i = 0; i < tgt[t]; i++) order.push(t);
// 打散,让三种人混站
for (let i = order.length - 1; i > 0; i--){ const j = Math.floor(hash(i*7+3) * (i+1)); [order[i],order[j]] = [order[j],order[i]]; }
slots = order.map((t,i) => ({ type:t, seed:i*31+5, phase:hash(i*13), pop:1, born:-9 }));
return;
}
const have = { cake:0, pop:0, poop:0 }; for (const s of slots) have[s.type]++;
let conv = converted || 0;
// 先让做爆米花的就地装天线
while (conv > 0 && have.poop < tgt.poop && have.pop > tgt.pop){
const cand = slots.filter(s => s.type === "pop"); if (!cand.length) break; const s = cand[Math.floor(Math.random()*cand.length)];
s.type = "poop"; s.spark = 1.2; have.pop--; have.poop++; conv--;
}
for (const t of ["cake","pop","poop"]){
while (have[t] > tgt[t]){
const cand = slots.map((s,i) => [s,i]).filter(([s]) => s.type === t && !s.leaving); if (!cand.length) break;
const [s,i] = cand[Math.floor(Math.random()*cand.length)];
walkers.push({ type:t, x:L.slots[i].x, y:L.slots[i].y, seed:s.seed, t:0 });
s.type = null; have[t]--;
}
}
const empty = slots.filter(s => s.type === null);
const need = []; for (const t of ["cake","pop","poop"]) for (let i = have[t]; i < tgt[t]; i++) need.push(t);
for (let i = 0; i < empty.length; i++){ const t = need[i] || "pop"; empty[i].type = t; empty[i].pop = 0; empty[i].born = 0; }
}
// 用户的点
function layoutDots(instant){
const n = Math.max(0, Math.round((today ? today.users : P.U0) / 50));
const cols = 20, cw = 26, rh = 38;
while (dots.filter(d => !d.leaving).length > n){
const live = dots.filter(d => !d.leaving); const d = live[Math.floor(Math.random()*live.length)]; d.leaving = true;
}
let live = dots.filter(d => !d.leaving).length;
while (live < n){
// 找空位
const used = new Set(dots.filter(d => !d.leaving).map(d => d.slot)); let slot = 0; while (used.has(slot)) slot++;
const cx = L.plaza.x + 22 + (slot % cols) * cw + (Math.floor(slot/cols)%2) * 9, cy = L.plaza.y + 56 + Math.floor(slot/cols) * rh;
dots.push({ slot, hx:cx, hy:cy, x: instant ? cx : W + 30 + Math.random()*120, y: instant ? cy : cy, off: Math.random()*1.2 - 0.6, seed: slot*17+3 });
live++;
}
}
// ---------- 一天 ----------
function advanceDay(){
if (world.day >= 365){ finish(); return; }
today = S.step(world);
pushHist(today);
layoutSlots(false, (today.converted || []).length);
layoutDots(false);
events();
if (world.day >= 365) finish();
}
function queue(key, vars){ if (flags[key]) return; flags[key] = true; caps = [{ key, vars }]; } // 只留最新的一条
function events(){
const t = today, d = world.day;
peakBots = Math.max(peakBots, t.count.poop);
if (d === world.aiDay) queue("ai", { k: fmtK(kSel) });
if (t.count.poop > 0) queue("bot");
{ const m = t.made, up = m.poop / (m.cake + m.pop + m.poop || 1); if (up > 0.4 && t.fm.poop < 0.1) queue("half"); }
if (t.sv.tiers[0].n < 2 && d > world.aiDay) queue("lottery");
if (t.fm.poop > 0.25) queue("flood");
if (t.users < 0.85 * P.U0) queue("users");
if (t.count.cake < 30 && d > world.aiDay) queue("chefs");
if (peakBots >= 10 && t.count.poop < peakBots * 0.3) queue("botsGone");
if (t.fm.pop > 0.5) queue("popFlood");
if (d === world.aiDay + 80 && t.users > 0.9 * P.U0 && t.cakeShareTop > 0.8) queue("calm");
}
function finish(){
running = false; ended = true; setPlayLabel();
endStats = { users: today.users, revenue: today.revenue, cost: today.cost, cake: today.cakeShareTop };
}
// ---------- 动画 ----------
function stageTarget(){ return { x: L.sieve[0].x - 40 + Math.random()*80, y: L.sieve[0].y - 22 }; }
function tierLoad(i){ const n = today ? today.sv.tiers[i].n : P.TIERS[i]; return P.TIERS[i] / Math.max(n, 0.5); }
function emitFrom(i){
const s = slots[i]; if (!s.type) return;
const p = L.slots[i]; const tg = stageTarget();
if (items.length > 520) return;
items.push({ type:s.type, st:"fly", x:p.x+24, y:p.y-22, sx:p.x+24, sy:p.y-22, tx:tg.x, ty:tg.y, t:0, dur:0.9 + Math.random()*0.35, tier:0, rot:Math.random()*6 });
}
function personRate(type){ const k = today ? today.k : 1; return type === "poop" ? P.RATE0.poop * Math.pow(k, P.BOT_RATE_EXP) : P.RATE0[type] * Math.pow(k, P.HUMAN_RATE_EXP); }
const VIS = 0.43; // 每天 1 条 ≈ 每秒 0.43 次推
function update(dt){
const simDt = dt * speed;
if (running && !ended){ dayAcc += simDt; while (dayAcc >= DAY_SEC){ dayAcc -= DAY_SEC; advanceDay(); if (ended) break; } }
// 推
for (let i = 0; i < slots.length; i++){
const s = slots[i]; if (!s.type) continue;
s.pop = Math.min(1, s.pop + dt*2.5); if (s.spark) s.spark = Math.max(0, s.spark - dt);
const f = personRate(s.type) * VIS; s.f = f;
s.phase += dt * f;
while (s.phase >= 1){ s.phase -= 1; if (Math.random() < 1) emitFrom(i); }
}
// 东西
pileCount = [0,0,0]; for (const it of items) if (it.st === "pile") pileCount[it.tier]++;
const fall = [];
for (const it of items){
it.t += dt;
if (it.st === "fly"){ const u = Math.min(1, it.t/it.dur); it.x = it.sx + (it.tx-it.sx)*u; it.y = it.sy + (it.ty-it.sy)*u - Math.sin(u*Math.PI)*45; if (u >= 1){ it.st = "pile"; it.t = 0; it.dwell = Math.min(4, 0.45 + 0.4*Math.log2(Math.max(1, tierLoad(0)))) * (0.7 + Math.random()*0.6); placeOnPile(it, 0); } }
else if (it.st === "pile"){ if (it.t >= it.dwell){ judge(it); } }
else if (it.st === "drop"){ it.vy += 900*dt; it.y += it.vy*dt; it.x += it.vx*dt;
if (it.next != null && it.y >= L.sieve[it.next].y - 18){ const tier = it.next; it.st = "pile"; it.t = 0; it.tier = tier; it.dwell = Math.min(4, 0.45 + 0.4*Math.log2(Math.max(1, tierLoad(tier)))) * (0.7 + Math.random()*0.6); placeOnPile(it, tier); }
else if (it.next == null && it.y >= L.bin.y - 6){ it.st = "dead"; binCount[it.type] = (binCount[it.type]||0) + 1; } }
else if (it.st === "chute"){ const u = Math.min(1, it.t/0.9); it.x = it.sx + (L.feed.x1 - it.sx)*u; it.y = it.sy + (L.feed.y - it.sy)*u; if (u >= 1){ it.st = "dead"; } }
else if (it.st === "feed"){ it.x += dt * 70; if (it.x > L.feed.x2){ it.st = "dead"; } }
}
items = items.filter(it => it.st !== "dead");
// 堆:每层最多显示 140 个,多的从底下消掉(仍按规则判)
// 推荐流里太挤就去掉旧的
// 推荐流传送带:上面每样东西的比例 = 当天推荐池里的真实比例
for (const b of belt) b.x += dt * 150 * Math.max(1, speed);
belt = belt.filter(b => b.x < L.feed.x2 - 6);
if (!belt.length || belt[belt.length-1].x > L.feed.x1 + 30){
if (!belt.length){ for (let x = L.feed.x2 - 20; x > L.feed.x1; x -= 30) belt.unshift({ x, type: sampleFeed() }); }
belt.push({ x: L.feed.x1 + 4, type: sampleFeed() });
}
// 走的人
for (const w of walkers){ w.t += dt; w.x -= dt*80; }
walkers = walkers.filter(w => w.x > -60);
// 用户
for (const d of dots){
if (d.leaving){ d.x += dt * 110; continue; }
d.x += (d.hx - d.x) * Math.min(1, dt*2.2); d.y += (d.hy - d.y) * Math.min(1, dt*2.2);
}
dots = dots.filter(d => d.x < W + 40);
// 反应
if (today){ const live = dots.filter(d => !d.leaving); const rate = live.length * 0.06 * dt * (REDUCED ? 0.3 : 1);
let n = Math.floor(rate); if (Math.random() < rate - n) n++;
for (let i = 0; i < n && live.length; i++){ const d = live[Math.floor(Math.random()*live.length)]; const r = Math.random(); const m = mixSeen();
const kind = r < m.cake ? "heart" : r < m.cake + m.poop ? "cloud" : null; if (kind) reacts.push({ kind, x:d.x, y:d.y-14, t:0 }); } }
for (const r of reacts){ r.t += dt; r.y -= dt*22; }
reacts = reacts.filter(r => r.t < 1.4);
// 金币
if (today){
const base = startStats.revenue;
spawnCoins("ad", today.revenue / base * 5 * dt);
spawnCoins("share", today.pool / base * 5 * dt * 2.2);
spawnCoins("cost", today.cost / base * 5 * dt * 2.2);
}
for (const c of coins){ c.t += dt; }
coins = coins.filter(c => c.t < c.dur);
// 字幕
capT += dt;
if (caps.length && (!capShown || capT > 1.4 || capShown.key === "intro" || capShown.key === "ready")){ capShown = caps.shift(); capT = 0; }
}
const binCount = {}; let pileCount = [0,0,0];
function mixSeen(){ const f = today.fm, m = today.made, nt = m.cake + m.pop + m.poop || 1; const ts = P.TRIAL_SHARE;
return { cake:(1-ts)*f.cake + ts*m.cake/nt, poop:(1-ts)*f.poop + ts*m.poop/nt }; }
function placeOnPile(it, tier){
const s = L.sieve[tier]; let idx = pileCount[tier];
let row = 0, perRow = 15; while (idx >= perRow && row < 9){ idx -= perRow; row++; perRow = Math.max(3, 15 - row*1.5) | 0; }
if (row >= 9){ it.tier = tier; it.t = 0; judge(it); return; } // 堆满了:不再往上堆,直接筛
const span = L.bowlW * 0.84 * perRow / 15;
it.tier = tier; it.x = s.x - span/2 + (idx + 0.5) * (span/perRow) + Math.random()*3 - 1.5; it.y = s.y - 4 - row*7 + Math.random()*2; pileCount[tier]++;
}
function judge(it, silent){
const n = today ? today.sv.tiers[it.tier].n : P.TIERS[it.tier];
const pass = Math.random() < S.passRate(P.COMPLETION[it.type], n);
if (silent){ it.st = "dead"; return; }
if (pass){
if (it.tier < 2){ it.st = "drop"; it.vy = 40; it.vx = 0; it.next = it.tier + 1; it.x = L.sieve[it.tier].x - 50 + Math.random()*100; it.y = L.sieve[it.tier].y + 26; }
else { it.st = "chute"; it.t = 0; it.sx = L.sieve[2].x + L.bowlW/2; it.sy = L.sieve[2].y + 2; }
} else { it.st = "drop"; it.vy = 10; it.vx = (Math.random() < 0.5 ? -1 : 1) * (10 + Math.random()*30); it.next = null; it.rej = true; it.x = L.sieve[it.tier].x + (it.vx < 0 ? -1 : 1) * (L.bowlW*0.5 - 8); it.y = L.sieve[it.tier].y + 4; }
}
function spawnCoins(kind, expected){
let n = Math.floor(expected); if (Math.random() < expected - n) n++;
for (let i = 0; i < n; i++) coins.push({ kind, t:0, dur: kind === "ad" ? 2.2 : kind === "share" ? 2.6 : 1.3, j:Math.random()*8-4 });
}
// 金币路径
const PATH = {
ad:[[L.adv.x-40, L.adv.y-10],[L.vault.x+70, L.vault.y-10]],
share:[[L.vault.x-70, L.vault.y-10],[452, L.vault.y-10],[452, 650]],
cost:[[L.vault.x+40, L.vault.y+45],[L.server.x-50, L.server.y+30]],
};
function along(path, u){
let len = 0; const seg = []; for (let i = 1; i < path.length; i++){ const l = Math.hypot(path[i][0]-path[i-1][0], path[i][1]-path[i-1][1]); seg.push(l); len += l; }
let d = u * len; for (let i = 0; i < seg.length; i++){ if (d <= seg[i]){ const v = d/seg[i]; return [path[i][0] + (path[i+1][0]-path[i][0])*v, path[i][1] + (path[i+1][1]-path[i][1])*v]; } d -= seg[i]; }
return path[path.length-1];
}
// ---------- 画 ----------
function drawPaper(){
ctx.fillStyle = C.paper; ctx.fillRect(0,0,W,H);
ctx.strokeStyle = C.grid; ctx.lineWidth = 1;
ctx.beginPath(); for (let x = 0; x <= W; x += 25){ ctx.moveTo(x,0); ctx.lineTo(x,H); } for (let y = 0; y <= H; y += 25){ ctx.moveTo(0,y); ctx.lineTo(W,y); } ctx.stroke();
}
function sparkline(x,y,w,h,key,base){
if (hist.length < 2) return;
const vals = hist.map(o => o[key]); const max = Math.max(base*1.15, ...vals);
ctx.beginPath(); for (let i = 0; i < 366; i++){ if (i >= vals.length) break; const px = x + i/365*w, py = y + h - vals[i]/max*h; if (i === 0) ctx.moveTo(px,py); else ctx.lineTo(px,py); }
ctx.strokeStyle = C.accent; ctx.lineWidth = 2; ctx.stroke();
ctx.setLineDash([3,4]); ctx.strokeStyle = C.soft; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y + h - base/max*h); ctx.lineTo(x+w, y + h - base/max*h); ctx.stroke(); ctx.setLineDash([]);
}
function drawTop(){
ctx.fillStyle = "#ffffffcc"; ctx.fillRect(0,0,W,112); line(0,112,W,112,3,2.5);
text(tr("title"), 30, 52, lang === "zh" ? 40 : 30, C.ink, "left", 800);
text(tr("sub"), 32, 86, 18, C.soft);
// 倍数和天数
const k = today ? today.k : 1;
text(tr("ai"), 500, 44, 18, C.soft);
text("×" + fmtK(started ? k : kSel), 500, 92, 46, k > 1.05 ? C.bad : C.ink, "left", 800);
text(tr("day", { d: world.day }), 690, 92, 22, C.ink, "left", 600);
if (!started){ text(lang === "zh" ? "(即将到来)" : "(coming)", 690, 60, 15, C.soft); }
// 四个表
const t = today;
const ms = [
{ v: fmtInt(t.users), key:"users", base:P.U0, bad: t.users < 0.85*P.U0 },
{ v: (lang==="zh" ? "" : "¥") + fmtInt(t.revenue) + (lang==="zh" ? " 元" : ""), key:"revenue", base:startStats.revenue, bad: t.revenue < 0.85*startStats.revenue },
{ v: (lang==="zh" ? "" : "¥") + fmtInt(t.cost) + (lang==="zh" ? " 元" : ""), key:"cost", base:startStats.cost, bad: t.cost > 1.5*startStats.cost },
{ v: Math.round(t.cakeShareTop*100) + "%", key:"cake", base:1, bad: t.cakeShareTop < 0.5 },
];
for (let i = 0; i < 4; i++){
const x = 840 + i*188, y = 12, w = 176;
rrect(x, y, w, 90, 100+i*9, "#fff", C.ink, 1.6);
text(tr("m")[i], x+12, y+24, 15, C.soft);
text(ms[i].v, x+12, y+58, 27, ms[i].bad ? C.bad : C.ink, "left", 800);
sparkline(x+12, y+64, w-24, 20, ms[i].key, ms[i].base);
}
}
function drawCaption(){
if (!capShown) return;
const s = tr("cap." + capShown.key, capShown.vars);
const size = 24, w = textW(s, size, 700) + 44, x = W/2 - w/2 - 60, y = 124;
ctx.save(); ctx.globalAlpha = Math.min(1, capT*4 + (capShown.key==="intro"?1:0));
rrect(x, y, w, 42, 999, "#fff8d6", C.ink, 1.8);
text(s, x + w/2, y + 30, size, C.ink, "center", 700); ctx.restore();
}
function drawStage(){
const st = L.stage;
// 台子
rrect(st.x+6, 620, st.w-20, 22, 11, "#e8dcc2", C.ink, 2.2);
line(st.x+20, 642, st.x+20, 660, 12, 2.2); line(st.x+st.w-30, 642, st.x+st.w-30, 660, 13, 2.2);
text(tr("stage"), st.x+14, 168, 20, C.ink, "left", 700);
text(tr("stageNote"), st.x+14 + textW(tr("stage"), 20, 700) + 10, 168, 13, C.soft);
for (let i = 0; i < slots.length; i++){
const s = slots[i]; if (!s.type) continue; const p = L.slots[i];
const f = s.f || 0.1; const blur = f > 3.2 ? 1 : 0; const push = blur ? 0 : Math.max(0, 1 - Math.abs(s.phase - 0.12) * 6);
ctx.save(); const sc = 0.4 + 0.6 * easeOut(s.pop); ctx.translate(p.x, p.y); ctx.scale(sc, sc);
drawPerson(0, 0, s.type, { seed: s.seed, push, blur, mood: 1, blink: (performance.now()/400 + i) % 2 < 1, spark: s.spark > 0 });
ctx.restore();
}
for (const w of walkers){ drawPerson(w.x, w.y, w.type, { seed: w.seed, pack: true, flip: true, walk: w.t*9, mood: -1, alpha: Math.max(0, Math.min(1, (w.x+40)/120)) }); }
// 人数
const c = today.count;
const legend = [["cake", tr("cake"), c.cake], ["pop", tr("pop"), c.pop], ["poop", tr("bot"), c.poop]];
// 竖排,一行一种,图标放大到看得清
legend.forEach(([type, name, n], i) => {
const y = 706 + i * 68;
drawItem(type, st.x + 46, y, 50);
const col = type === "poop" && n > 0 ? C.bad : C.ink;
text(name, st.x + 92, y + 10, 26, col, "left", 700);
text(fmtInt(n), st.x + 92 + textW(name, 26, 700) + 14, y + 10, 30, col, "left", 800);
});
}
function easeOut(u){ return 1 - Math.pow(1-u, 3); }
function drawSieves(){
for (let i = 0; i < 3; i++){
const s = L.sieve[i], bw = L.bowlW;
// 碗
ctx.beginPath(); ctx.moveTo(s.x - bw/2, s.y); ctx.quadraticCurveTo(s.x, s.y + 64, s.x + bw/2, s.y); ctx.strokeStyle = C.ink; ctx.lineWidth = 2.4; ctx.stroke();
ctx.save(); ctx.beginPath(); ctx.moveTo(s.x - bw/2, s.y); ctx.quadraticCurveTo(s.x, s.y + 64, s.x + bw/2, s.y); ctx.closePath(); ctx.clip();
ctx.strokeStyle = "#9aa7b8"; ctx.lineWidth = 1; ctx.beginPath(); for (let x = -bw/2; x < bw/2; x += 10){ ctx.moveTo(s.x + x, s.y); ctx.lineTo(s.x + x + 30, s.y + 40); ctx.moveTo(s.x + x + 30, s.y); ctx.lineTo(s.x + x, s.y + 40); } ctx.stroke(); ctx.restore();
line(s.x - bw/2 - 10, s.y, s.x + bw/2 + 10, s.y, 300+i, 2.6);
// 试吃的人
const n = today.sv.tiers[i].n; const tasters = Math.max(1, Math.round(5 * today.users / P.U0));
for (let j = 0; j < tasters; j++){ const tx = s.x + bw/2 + 34 + (j%3)*24, ty = s.y - 12 + Math.floor(j/3)*30; const bob = Math.sin(performance.now()/180 + j*1.7) * 2;
circ(tx, ty + bob, 8, "#fff", C.ink, 1.8); circ(tx-3, ty-2+bob, 1.2, C.ink, false); circ(tx+3, ty-2+bob, 1.2, C.ink, false); line(tx-3, ty+3+bob, tx+3, ty+3+bob, j, 1.2, C.ink, 0.2); }
const lab = tr("tier")[i];
}
// 没被推荐
ctx.beginPath(); ctx.ellipse(L.bin.x, L.bin.y + 6, 120, 14, 0, 0, Math.PI*2); ctx.fillStyle = "#eceae3"; ctx.fill(); ctx.strokeStyle = C.ink; ctx.lineWidth = 1.6; ctx.stroke();
text(tr("rejected"), L.bin.x, L.bin.y + 42, 14, C.soft, "center");
// 滑道
line(L.sieve[2].x + L.bowlW/2, L.sieve[2].y + 12, L.feed.x1 - 6, L.feed.y + 14, 410, 2.2, C.ink, 0.8);
}
function drawTierLabels(){
for (let i = 0; i < 3; i++){ const s = L.sieve[i], n = today.sv.tiers[i].n, lab = tr("tier")[i];
const nTxt = n >= 10 ? fmtInt(n) : n >= 0.1 ? (Math.round(n*10)/10).toString() : (lang === "zh" ? "不到 0.1" : "<0.1");
const lt = lab + " " + tr("perItem", { n: nTxt }); const lw2 = textW(lt, 14, 700) + 20;
rrect(s.x - lw2/2, s.y + 46, lw2, 26, 320+i, "#fffffff2", C.ink, 1.2);
text(lab, s.x - lw2/2 + 10, s.y + 64, 14, C.ink, "left", 800);
text(tr("perItem", { n: nTxt }), s.x - lw2/2 + 10 + textW(lab + " ", 14, 800), s.y + 64, 14, n < P.TIERS[i] * 0.3 ? C.bad : C.soft, "left", 700); }
}
function sampleFeed(){ const f = today ? today.fm : { cake:1, pop:0, poop:0 }; const r = Math.random(); return r < f.cake ? "cake" : r < f.cake + f.pop ? "pop" : "poop"; }
function drawItems(){
for (const it of items){ const sz = it.st === "fly" ? 13 : 15; const a = it.rej ? Math.max(0.15, 1 - (it.y - L.sieve[it.tier].y) / 160) : 1; drawItem(it.type, it.x, it.y, sz, a); }
for (const b of belt) drawItem(b.type, b.x, L.feed.y - 2, 22);
}
function drawPlaza(){
// 推荐流
text(tr("feed"), L.feed.x1, L.feed.y + 46, 16, C.ink, "left", 700);
line(L.feed.x1 - 8, L.feed.y + 16, L.feed.x2 + 6, L.feed.y + 16, 500, 2.4);
for (let x = L.feed.x1; x < L.feed.x2; x += 28){ circ(x + ((performance.now()/1000*70) % 28), L.feed.y + 21, 3.5, "#fff", C.ink, 1.4); }
text(tr("plaza"), L.plaza.x + 10, L.plaza.y + 18, 18, C.ink, "left", 700);
text(tr("plazaNote"), L.plaza.x + 10 + textW(tr("plaza"), 18, 700) + 10, L.plaza.y + 18, 13, C.soft);
const sat = today ? today.sat : 0.9;
for (const d of dots){
const m = sat + d.off; const mood = m > 0.55 ? 1 : m > -0.1 ? 0 : -1;
ctx.save(); ctx.translate(d.x, d.y); const fill = mood > 0 ? "#ffeef0" : mood < 0 ? "#e9e3dc" : "#ffffff";
circ(0,0,9,fill,C.ink,1.6); circ(-3,-2,1.3,C.ink,false); circ(3,-2,1.3,C.ink,false);
ctx.beginPath(); ctx.strokeStyle = C.ink; ctx.lineWidth = 1.3; if (mood > 0) ctx.arc(0,1,3.5,0.15*Math.PI,0.85*Math.PI); else if (mood === 0){ ctx.moveTo(-3,4); ctx.lineTo(3,4); } else ctx.arc(0,7,3.5,1.15*Math.PI,1.85*Math.PI); ctx.stroke();
ctx.restore();
}
for (const r of reacts){ ctx.save(); ctx.globalAlpha = Math.max(0, 1 - r.t/1.4);
if (r.kind === "heart"){ ctx.fillStyle = C.cake; ctx.beginPath(); const x = r.x, y = r.y; ctx.moveTo(x, y+4); ctx.bezierCurveTo(x-8, y-2, x-4, y-9, x, y-4); ctx.bezierCurveTo(x+4, y-9, x+8, y-2, x, y+4); ctx.fill(); }
else { ctx.fillStyle = "#7c8796"; circ(r.x-4, r.y, 4, "#7c8796", false); circ(r.x+3, r.y-2, 5, "#7c8796", false); circ(r.x+8, r.y+1, 3.5, "#7c8796", false); }
ctx.restore(); }
}
function drawMoney(){
const t = today;
// 注意力:用户 → 广告商
ctx.setLineDash([6,6]); line(1420, 628, L.adv.x - 6, L.adv.y - 70, 600, 1.8, C.accent, 0.5); ctx.setLineDash([]);
text(tr("attention"), 1455, 672, 14, C.accent, "right", 600);
// 管道
const pipe = (path, w, seed) => { ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = C.ink; ctx.lineWidth = w + 4; ctx.beginPath(); path.forEach(([x,y],i) => i ? ctx.lineTo(x,y) : ctx.moveTo(x,y)); ctx.stroke(); ctx.strokeStyle = "#fff"; ctx.lineWidth = w; ctx.stroke(); ctx.lineCap = "butt"; };
const base = startStats.revenue;
pipe(PATH.ad, 6 + 14 * Math.min(1.5, t.revenue/base), 1);
pipe(PATH.share, 3 + 6 * Math.min(1.5, t.pool/(base*P.CREATOR_SHARE)), 2);
pipe(PATH.cost, 3 + 5 * Math.min(4, t.cost/startStats.cost), 3);
for (const c of coins){ const u = c.t / c.dur; const [x,y] = along(PATH[c.kind], u); circ(x, y + (c.kind === "share" ? 0 : c.j*0.3), 6, C.coin, C.ink, 1.3); circ(x-1.5, y-1.5, 2, C.coinHi, false); }
// 广告商
drawPerson(L.adv.x, L.adv.y + 30, "pop", { seed: 901, mood: t.revenue > 0.85*base ? 1 : 0, push: 0 });
ctx.fillStyle = C.accent; ctx.beginPath(); ctx.moveTo(L.adv.x, L.adv.y + 0); ctx.lineTo(L.adv.x - 3, L.adv.y + 14); ctx.lineTo(L.adv.x + 3, L.adv.y + 14); ctx.fill();
rrect(L.adv.x + 14, L.adv.y + 2, 24, 18, 902, "#8f6b3e", C.ink, 1.6);
text(tr("adv"), L.adv.x, L.adv.y + 72, 16, C.ink, "center", 700);
text(tr("adIn", { v: fmtInt(t.revenue) }), (PATH.ad[0][0] + PATH.ad[1][0]) / 2, L.adv.y - 34, 15, C.ink, "center", 600);
// 金库
rrect(L.vault.x - 60, L.vault.y - 50, 120, 96, 910, "#e7ecf3", C.ink, 2.4);
circ(L.vault.x, L.vault.y - 2, 18, "#fff", C.ink, 2); line(L.vault.x, L.vault.y - 2, L.vault.x + 12, L.vault.y - 10, 911, 2);
text(tr("vault"), L.vault.x, L.vault.y - 60, 16, C.ink, "center", 700);
const net = t.revenue * (1 - P.CREATOR_SHARE) - t.cost;
text(net >= 0 ? tr("keep", { v: fmtInt(net) }) : tr("lose", { v: fmtInt(-net) }), L.vault.x, L.vault.y + 72, 15, net >= 0 ? C.good : C.bad, "center", 700);
// 服务器
const racks = Math.min(6, Math.max(1, Math.round(t.cost / startStats.cost)));
for (let i = 0; i < racks; i++){ rrect(L.server.x - 40 + (i%3)*30, L.server.y - (Math.floor(i/3))*30 - 4, 26, 28, 920+i, "#dfe4ea", C.ink, 1.6); circ(L.server.x - 33 + (i%3)*30, L.server.y + 4 - Math.floor(i/3)*30, 2, C.good, false); }
text(tr("server"), L.server.x + 60, L.server.y + 14, 14, C.soft, "left");
text((lang==="zh" ? "" : "¥") + fmtInt(t.cost) + (lang==="zh" ? " 元/天" : "/day"), L.server.x + 60, L.server.y + 34, 15, t.cost > 1.5*startStats.cost ? C.bad : C.ink, "left", 700);
// 分给创作者
text(tr("toCreators", { v: fmtInt(t.pool) }), 480, L.vault.y - 26, 15, C.ink, "left", 600);
}
function drawEnd(){
if (!ended || !endStats) return;
const fail = endStats.users < 0.75*P.U0 || endStats.cake < 0.5;
const head = fail ? tr("endFail", { k: fmtK(kSel) }) : tr("endOk", { k: fmtK(kSel) });
ctx.save(); ctx.fillStyle = "#fbfbf7e6"; ctx.fillRect(0, 112, W, H-112);
const x = 330, y = 210, w = 940, h = 470;
rrect(x, y, w, h, 1001, "#fff", C.ink, 2.6);
text(tr("endHead"), x + 40, y + 58, 20, C.soft, "left", 700);
const hs = textW(head, 34, 800) > w - 80 ? 26 : 34;
text(head, x + 40, y + 108, hs, C.ink, "left", 800);
const rows = [
[tr("m")[0], fmtInt(startStats.users), fmtInt(endStats.users)],
[tr("m")[1], fmtInt(startStats.revenue), fmtInt(endStats.revenue)],
[tr("m")[2], fmtInt(startStats.cost), fmtInt(endStats.cost)],
[tr("m")[3], Math.round(startStats.cake*100) + "%", Math.round(endStats.cake*100) + "%"],
];
text(tr("from"), x + 520, y + 168, 16, C.soft, "right", 700); text(tr("to"), x + 760, y + 168, 16, C.soft, "right", 700);
rows.forEach((r,i) => { const yy = y + 214 + i*52; line(x + 40, yy + 16, x + w - 40, yy + 16, 1100+i, 1, C.grid, 0.4);
text(r[0], x + 40, yy, 20, C.ink, "left", 600); text(r[1], x + 520, yy, 22, C.ink, "right", 700); text("→", x + 600, yy, 22, C.soft, "center"); text(r[2], x + 760, yy, 22, C.ink, "right", 800); });
text(tr("again"), x + 40, y + h - 30, 15, C.soft, "left");
ctx.restore();
}
function render(){
ctx.setTransform(DPR,0,0,DPR,0,0);
drawPaper(); drawStage(); drawSieves(); drawPlaza(); drawMoney(); drawItems(); drawTierLabels(); drawTop(); drawCaption(); drawEnd();
}
// ---------- 循环 ----------
let last = performance.now();
function frame(now){
const dt = Math.min(0.05, (now - last) / 1000); last = now;
update(dt); render(); requestAnimationFrame(frame);
}
// ---------- 控件 ----------
const btnPlay = document.getElementById("play"), btnReset = document.getElementById("reset"), kIn = document.getElementById("k"), kOut = document.getElementById("kOut"), btnLang = document.getElementById("lang");
const presets = [1, 3, 10, 30, 100]; const presetBox = document.getElementById("presets");
presets.forEach(v => { const b = document.createElement("button"); b.type = "button"; b.textContent = "×" + v; b.addEventListener("click", () => { setK(v); }); presetBox.appendChild(b); });
function niceK(v){ if (v < 10) return Math.round(v*10)/10; return Math.round(v); }
function setK(v){ kSel = niceK(v); kIn.value = Math.log10(kSel); kOut.textContent = "×" + fmtK(kSel); world.kTarget = kSel; if (!started) reset(); else if (world.day >= world.aiDay) {/* 运行中改倍数:立刻生效 */} }
kIn.addEventListener("input", () => setK(Math.pow(10, +kIn.value)));
btnPlay.addEventListener("click", () => {
if (ended){ reset(); }
if (!started){ started = true; running = true; capShown = { key:"intro" }; capT = 0; }
else running = !running;
setPlayLabel();
});
btnReset.addEventListener("click", () => { reset(); });
document.querySelectorAll("[data-speed]").forEach(b => b.addEventListener("click", () => { speed = +b.dataset.speed; document.querySelectorAll("[data-speed]").forEach(o => o.setAttribute("aria-pressed", o === b ? "true" : "false")); }));
btnLang.addEventListener("click", () => setLang(lang === "zh" ? "en" : "zh"));
function setPlayLabel(){ btnPlay.textContent = ended ? tr("play") : !started ? tr("play") : running ? tr("pause") : tr("resume"); }
function setLang(l){ lang = l; document.documentElement.lang = l === "zh" ? "zh-CN" : "en";
btnLang.textContent = tr("lang"); btnReset.textContent = tr("reset"); document.getElementById("kLab").textContent = tr("kLab"); document.getElementById("spLab").textContent = tr("spLab");
const sp = document.querySelectorAll("[data-speed]"); sp[0].textContent = tr("slow"); sp[1].textContent = tr("normal"); sp[2].textContent = tr("fast");
document.getElementById("note").textContent = tr("note"); cv.setAttribute("aria-label", l === "zh" ? "推荐池模拟画面" : "Recommendation pool simulation"); setPlayLabel(); }
// 截图用:快进到某一天,并让画面上的东西先流动一会儿
window.demo = {
jump(k, day, warmSec){ kSel = k; kIn.value = Math.log10(k); kOut.textContent = "×" + fmtK(k); reset(); started = true; running = false;
while (world.day < day){ today = S.step(world); pushHist(today); } peakBots = 0; layoutSlots(true); layoutDots(true); caps = []; capShown = null; ended = false;
const steps = Math.round((warmSec || 4) / 0.02); for (let i = 0; i < steps; i++) update(0.02); },
setLang, get today(){ return today; }, get world(){ return world; },
finishAt(k){ this.jump(k, 365, 3); finish(); },
};
reset(); setK(kSel);
setLang("en");
capShown = { key:"ready" };
requestAnimationFrame(frame);
})();
</script>