ZouBo commited on
Commit
e0cc29f
·
verified ·
1 Parent(s): 16a4bf9

Upload 2 files

Browse files
Files changed (2) hide show
  1. README.md +17 -5
  2. index.html +884 -19
README.md CHANGED
@@ -1,10 +1,22 @@
1
  ---
2
- title: Recommendation Pool
3
- emoji: ⚡
4
- colorFrom: indigo
5
- colorTo: purple
6
  sdk: static
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: The Recommendation Pool
3
+ emoji: 🐟
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ short_description: A toy model of a free video feed under AI output growth
10
  ---
11
 
12
+ # The Recommendation Pool
13
+
14
+ This is an interactive demo for the paper "After One-Click Video" (https://arxiv.org/abs/2610.37695).
15
+
16
+ It shows a free, ad-funded video platform. New content must pass three layers of trial viewing before it reaches the main feed.
17
+
18
+ Set how much more content AI lets people make (from 1x to 100x), press Start, and watch one year go by.
19
+
20
+ It is a simple illustration, not a forecast. The page runs fully in your browser and needs no network.
21
+
22
+ You can switch to Chinese with the button under the picture.
index.html CHANGED
@@ -1,19 +1,884 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <meta charset="utf-8">
2
+ <title>The Recommendation Pool</title>
3
+ <meta name="claude:viewport-fit-sides" content="cover">
4
+ <style>
5
+ /* 版式:一张横放的方格草稿纸,整张画面就是那个循环;控件在纸下方。单一纸面风格,不随深浅色切换。 */
6
+ :root{
7
+ --paper:#fbfbf7; --grid:#dfe7ee; --ink:#262a33; --ink-soft:#6b7280;
8
+ --cake:#e0484f; --pop:#f2c14e; --poop:#8a5a3b; --coin:#d9a520; --accent:#2f5fb3; --bad:#c2410c;
9
+ --font:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Source Han Sans SC",system-ui,sans-serif;
10
+ color-scheme:light;
11
+ }
12
+ html,body{background:var(--paper);color:var(--ink)}
13
+ 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}
14
+ .wrap{max-width:1280px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
15
+ .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)}
16
+ canvas{display:block;width:100%;height:100%}
17
+ .controls{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center}
18
+ .group{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
19
+ .lab{font-size:13px;color:var(--ink-soft);letter-spacing:.04em}
20
+ 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}
21
+ button:hover{background:#eef2f7}
22
+ button:focus-visible,input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
23
+ button.primary{background:var(--ink);color:var(--paper)}
24
+ button.primary:hover{background:#3b4150}
25
+ button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
26
+ input[type=range]{width:220px;max-width:100%;accent-color:var(--accent)}
27
+ output{font-variant-numeric:tabular-nums;font-weight:700;min-width:4.2em;display:inline-block}
28
+ .note{font-size:12.5px;color:var(--ink-soft);line-height:1.6;max-width:72ch;margin:0}
29
+ @media (max-width:640px){input[type=range]{width:160px}}
30
+ </style>
31
+
32
+ <div class="wrap">
33
+ <div class="scene"><canvas id="c" width="1600" height="900" aria-label="推荐池模拟画面"></canvas></div>
34
+ <div class="controls">
35
+ <div class="group">
36
+ <button id="play" class="primary" type="button">开始</button>
37
+ <button id="reset" type="button">重来</button>
38
+ </div>
39
+ <div class="group">
40
+ <span class="lab" id="kLab">AI 产能倍数</span>
41
+ <input id="k" type="range" min="0" max="2" step="0.01" value="2" aria-labelledby="kLab">
42
+ <output id="kOut" for="k">×100</output>
43
+ <span class="group" id="presets"></span>
44
+ </div>
45
+ <div class="group">
46
+ <span class="lab" id="spLab">速度</span>
47
+ <button type="button" data-speed="0.5">慢</button>
48
+ <button type="button" data-speed="1" aria-pressed="true">正常</button>
49
+ <button type="button" data-speed="2.5">快</button>
50
+ </div>
51
+ <div class="group"><button id="lang" type="button">English</button></div>
52
+ </div>
53
+ <p class="note" id="note">示意模型。筛选部分(三层试看、完播率、晋级线)沿用论文图 1 的参数;用户去留、分成、成本、创作者去留等循环部分的规则与参数未经实证校准,只用来展示方向。</p>
54
+ </div>
55
+
56
+ <script>
57
+ // 推荐池演示:模拟核心(浏览器和命令行共用)
58
+ // 一步 = 一天。筛选部分(三层试看)沿用 模拟.py 的参数和公式;其余为示意规则,未经实证校准。
59
+ (function (root) {
60
+ "use strict";
61
+
62
+ // ---------- 写死的设定 ----------
63
+ const P = {
64
+ // 筛选部分:与 模拟.py 相同
65
+ COMPLETION: { cake: 0.40, pop: 0.30, poop: 0.30 }, // 试看完播率:好内容 / 平庸内容 / 批量垃圾(试看表现和平庸内容一样,区别在用户看完会反感)。数值都取自论文图 1
66
+ CUT: 0.35, // 晋级线
67
+ TIERS: [100, 500, 2500], // 基线下三层每条试看人数
68
+ TRIAL_SHARE: 0.10, // 用户时间里用于试看的份额
69
+
70
+ // 用户
71
+ U0: 10000, // 一倍时的用户数
72
+ VIEWS_PER_USER: 60, // 每人每天看多少条(固定)
73
+ JOY: { cake: 1.0, pop: 0.0, poop: -1.5 }, // 看一条带来的满意度
74
+ USER_REACT: 0.04, // 用户数每天向"目标人数"靠拢的速度
75
+ USER_MEMORY: 0.15, // 满意��的平滑:越小,用户记得越久
76
+ USER_SENS: 0.7, // 满意度每差 1,平台能留住的人数变化多少(相对一倍时)
77
+
78
+ // 钱
79
+ AD_PRICE: 0.01, // 每次观看卖给广告商的价钱(元)
80
+ CREATOR_SHARE: 0.30, // 分给创作者的比例;平台拿大头
81
+ COST_PER_ITEM: 0.1, // 每条内容的存储、分发、审核成本(元)
82
+ COST_FIXED: 600, // 平台每天的固定开支(元)
83
+
84
+ // 创作者:一倍时
85
+ RATE0: { cake: 0.2, pop: 0.5, poop: 0.2 }, // 每人每天做几条(做蛋糕的五天一条)
86
+ ENTRY0: { cake: 0.27, pop: 13 }, // 每天进来试试的新人
87
+ WAGE: 22, // 每天至少要挣到多少才肯留下(元);做蛋糕的、做爆米花的、机器人都一样
88
+ PATIENCE: { cake: 30, pop: 30, poop: 20 }, // 连续挣不够多少天就走
89
+ RETIRE: 0.005, // 每天自然退出的比例
90
+
91
+ // AI 产能倍数 k 的作用
92
+ HUMAN_RATE_EXP: 0.65, // 真人每人产能 × k^0.65(要打磨,涨得少;一百倍时约二十倍)
93
+ TALENT_EXP: 0.35, // 工具释放有能力的人:做蛋糕的新人 × k^0.35。两者相乘,蛋糕总量 × k(乐观设定,同论文"同比例增加")
94
+ BOT_RATE_EXP: 1.0, // 做便便的:按账号算,每个号产能 × k(用 Boo 的数:不管质量,一百倍时一天二十条)
95
+ BOT_COST_EXP: 0.5, // 每个号每天要挣回的成本 = 工钱 ÷ k^0.5:AI 越强,一个人能管的号越多,摊到每个号上越便宜
96
+
97
+ POOP_PER_K: 320, // 倍数每涨 1,做便便的号的"目标数"多 160 个(平台收入不变时)
98
+ POOP_ARRIVE: 0.08, // 号数低于目标时,每天补上差距的 8%
99
+ POOP_LEAVE: 0.03, // 号数高于目标时,每天走掉超出部分的 3%
100
+ BOT_ENTRY_BASE: 1.0, // 有利可图时每天冒出来的机器人基数
101
+ BOT_COPY: 0.15, // 跟风:已有机器人越多,跟进得越快
102
+ CONVERT: 0.5, // 新机器人里,由做爆米花的人转行过来的比例
103
+
104
+ TOP_LIFE: 3, // 进了最上层的内容在推荐流里待几天
105
+ };
106
+
107
+ // 与 模拟.py 相同的晋级概率(正态近似;试看人数少于 1 按 1 计)
108
+ function erf(x) { // Abramowitz–Stegun 7.1.26,误差 < 1.5e-7
109
+ const s = Math.sign(x); x = Math.abs(x);
110
+ const t = 1 / (1 + 0.3275911 * x);
111
+ const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
112
+ return s * y;
113
+ }
114
+ function cdf(x) { return 0.5 * (1 + erf(x / Math.SQRT2)); }
115
+ function passRate(p, n) { n = Math.max(n, 1); return 1 - cdf((P.CUT - p) / Math.sqrt(p * (1 - p) / n)); }
116
+
117
+ function rng(seed) { // 可复现的随机数
118
+ let s = seed >>> 0 || 1;
119
+ return function () { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
120
+ }
121
+
122
+ const TYPES = ["cake", "pop", "poop"];
123
+
124
+ // 一倍时各层的流入量(每条新内容 = 1),用来确定每层的固定试看预算
125
+ const BASE_MIX = { cake: 0.05, pop: 0.95, poop: 0 };
126
+ const BASE_IN = (function () {
127
+ let m = Object.assign({}, BASE_MIX); const ins = [];
128
+ for (const n of P.TIERS) {
129
+ ins.push(m.cake + m.pop + m.poop);
130
+ m = { cake: m.cake * passRate(P.COMPLETION.cake, n), pop: m.pop * passRate(P.COMPLETION.pop, n), poop: 0 };
131
+ }
132
+ return ins;
133
+ })();
134
+ const TRIAL_VIEWS_PER_ITEM0 = P.TIERS.reduce((a, n, i) => a + n * BASE_IN[i], 0); // 一倍时每条新内容平均消耗的试看次数
135
+ const V0 = P.U0 * P.VIEWS_PER_USER * P.TRIAL_SHARE / TRIAL_VIEWS_PER_ITEM0; // 一倍时每天的新内容条数
136
+
137
+ // 一层层筛:给定今天各类新内容条数和用户数,返回各层流入、最上层产出、试看分布
138
+ function sieve(counts, users) {
139
+ const budgetScale = users / P.U0; // 注意力 ∝ 用户数
140
+ let m = Object.assign({}, counts);
141
+ const tiers = [];
142
+ const trialViews = { cake: 0, pop: 0, poop: 0 };
143
+ for (let i = 0; i < P.TIERS.length; i++) {
144
+ const inflow = m.cake + m.pop + m.poop;
145
+ const budget = P.TIERS[i] * BASE_IN[i] * V0 * budgetScale; // 这一层每天的固定试看总量
146
+ const n = inflow > 0 ? budget / inflow : P.TIERS[i];
147
+ const next = {};
148
+ for (const t of TYPES) { trialViews[t] += m[t] * n; next[t] = m[t] * passRate(P.COMPLETION[t], n); } // 实际花掉的试看次数按预算分;晋级概率按 模拟.py 的口径(少于 1 人按 1 计)
149
+ tiers.push({ inflow, n, mix: Object.assign({}, m) });
150
+ m = next;
151
+ }
152
+ return { tiers, top: m, trialViews };
153
+ }
154
+
155
+ function makeWorld(opts) {
156
+ opts = opts || {};
157
+ const R = rng(opts.seed || 7);
158
+ const w = {
159
+ day: 0, k: 1, kTarget: opts.k || 1, aiDay: opts.aiDay == null ? 20 : opts.aiDay,
160
+ users: P.U0, sat: 0, creators: [], nextId: 1,
161
+ topPool: [], // 每天进最上层的 {cake,pop,poop}
162
+ log: [], events: { left: [], joined: [], usersLeft: 0, usersJoined: 0 },
163
+ today: null, R,
164
+ };
165
+ // 一倍时的稳态人数
166
+ const nCake = Math.round(P.ENTRY0.cake / P.RETIRE * 1.0);
167
+ const nPop = Math.round(P.ENTRY0.pop * P.PATIENCE.pop * 0.9);
168
+ for (let i = 0; i < nCake; i++) w.creators.push(newCreator(w, "cake", Math.floor(R() * 200)));
169
+ 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); }
170
+ // 预热:把最上层推荐流填成一倍时的样子
171
+ const warm = sieve({ cake: V0 * 0.05, pop: V0 * 0.95, poop: 0 }, P.U0).top;
172
+ for (let i = 0; i < P.TOP_LIFE; i++) w.topPool.push(Object.assign({}, warm));
173
+ w.sat = satisfaction(w, { cake: V0 * 0.05, pop: V0 * 0.95, poop: 0 });
174
+ w.sat0 = w.sat;
175
+ w.rev0 = P.U0 * P.VIEWS_PER_USER * P.AD_PRICE;
176
+ return w;
177
+ }
178
+
179
+ function newCreator(w, type, age) {
180
+ // luck:真人是运气(收入 ×0.6~1.4);做便便的号是成本高低(成本 ×0.2~1.8,有的号几乎不花钱)。patience:耐心 ×0.5~1.5,大家不会同一天一起走
181
+ 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() };
182
+ }
183
+
184
+ function feedMix(w) {
185
+ const s = { cake: 0, pop: 0, poop: 0 };
186
+ for (const d of w.topPool) for (const t of TYPES) s[t] += d[t];
187
+ const tot = s.cake + s.pop + s.poop;
188
+ 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 };
189
+ }
190
+
191
+ function satisfaction(w, newCounts) {
192
+ const f = feedMix(w);
193
+ const nt = newCounts.cake + newCounts.pop + newCounts.poop;
194
+ let s = 0;
195
+ for (const t of TYPES) s += P.JOY[t] * ((1 - P.TRIAL_SHARE) * f[t] + P.TRIAL_SHARE * (nt > 0 ? newCounts[t] / nt : 0));
196
+ return s;
197
+ }
198
+
199
+ function step(w) {
200
+ const R = w.R;
201
+ w.day++;
202
+ // AI 到来:第 aiDay 天起,倍数在两周内爬到目标值
203
+ if (w.day >= w.aiDay) w.k = Math.min(w.kTarget, w.k * Math.pow(w.kTarget, 1 / 14));
204
+ if (w.kTarget < w.k) w.k = w.kTarget;
205
+ const k = w.k;
206
+ const humanRate = Math.pow(k, P.HUMAN_RATE_EXP), talent = Math.pow(k, P.TALENT_EXP);
207
+
208
+
209
+ // 1. 出东西
210
+ const made = { cake: 0, pop: 0, poop: 0 };
211
+ for (const c of w.creators) {
212
+ const r = c.type === "poop" ? P.RATE0.poop * Math.pow(k, P.BOT_RATE_EXP) : P.RATE0[c.type] * humanRate;
213
+ c.todayItems = r; made[c.type] += r;
214
+ }
215
+ // 2. 筛
216
+ const sv = sieve(made, w.users);
217
+ w.topPool.push(sv.top); if (w.topPool.length > P.TOP_LIFE) w.topPool.shift();
218
+
219
+ // 3. 看:推荐流里各类内容的观看次数
220
+ const views = w.users * P.VIEWS_PER_USER;
221
+ const feedViews = views * (1 - P.TRIAL_SHARE);
222
+ const trialViews = views * P.TRIAL_SHARE;
223
+ const fm = feedMix(w);
224
+ // 每条新内容平均拿到的观看:试看 + 进了最上层后在推荐流里分到的
225
+ const topItems = fm.total; // 推荐流里同时在架的条数
226
+ const viewsPerTopItemPerDay = topItems > 0 ? feedViews / topItems : 0;
227
+ const trialPerItem = {}, topProb = {};
228
+ for (const t of TYPES) {
229
+ trialPerItem[t] = made[t] > 0 ? sv.trialViews[t] / made[t] : 0;
230
+ topProb[t] = made[t] > 0 ? sv.top[t] / made[t] : 0;
231
+ }
232
+ // 4. 钱
233
+ const revenue = views * P.AD_PRICE;
234
+ const pool = revenue * P.CREATOR_SHARE;
235
+ const yuanPerView = pool / views;
236
+ const cost = P.COST_FIXED + P.COST_PER_ITEM * (made.cake + made.pop + made.poop);
237
+ const perItemIncome = {};
238
+ for (const t of TYPES) perItemIncome[t] = yuanPerView * (trialPerItem[t] + topProb[t] * viewsPerTopItemPerDay * P.TOP_LIFE);
239
+ // 预期:如果有人做一条便便,它能挣多少(还没有机器人时也算得出来)
240
+ const n1 = sv.tiers[0].n, n2 = sv.tiers[1].n, n3 = sv.tiers[2].n;
241
+ const poopTopProb = passRate(P.COMPLETION.poop, n1) * passRate(P.COMPLETION.poop, n2) * passRate(P.COMPLETION.poop, n3);
242
+ const poopIncomeProspect = made.poop > 0 ? perItemIncome.poop : yuanPerView * (Math.max(n1, 1) + poopTopProb * viewsPerTopItemPerDay * P.TOP_LIFE);
243
+
244
+ // 5. 创作者去留
245
+ const left = [], joined = [];
246
+ const keep = [];
247
+ for (const c of w.creators) {
248
+ c.age++;
249
+ const inc = perItemIncome[c.type] * c.todayItems * (c.type === "poop" ? 1 : c.luck);
250
+ c.earned = 0.8 * c.earned + 0.2 * inc;
251
+ const need = c.type === "poop" ? c.costMul * P.WAGE / Math.pow(k, P.BOT_COST_EXP) : P.WAGE;
252
+ if (inc < need) c.broke++; else c.broke = Math.max(0, c.broke - 2);
253
+ let go = c.type !== "poop" && c.broke >= P.PATIENCE[c.type] * c.pat;
254
+ if (c.type !== "poop" && R() < P.RETIRE) go = true;
255
+ if (go) left.push(c); else keep.push(c);
256
+ }
257
+ w.creators = keep;
258
+ // 新人
259
+ 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); } };
260
+ add("cake", P.ENTRY0.cake * talent);
261
+ add("pop", P.ENTRY0.pop);
262
+ // 做便便的:成本几乎为零、买彩票心态,人数主要取决于 AI 让批量生产变得多容易(同 模拟.py:新增的量是批量内容),
263
+ // 只温和地跟着钱走:平台收入越少,来的人越少,但不会走光。一部分是做爆米花的人"装上天线"转过来的
264
+ const nBots = w.creators.reduce((a, c) => a + (c.type === "poop"), 0);
265
+ const botTarget = P.POOP_PER_K * (k - 1) * Math.sqrt(Math.max(0.02, revenue / w.rev0));
266
+ w.converted = [];
267
+ const gap = botTarget - nBots;
268
+ if (gap > 0) {
269
+ let n = Math.floor(gap * P.POOP_ARRIVE); if (R() < gap * P.POOP_ARRIVE - n) n++;
270
+ const pops = w.creators.filter(c => c.type === "pop"); let convLeft = Math.floor(pops.length * 0.02);
271
+ for (let i = 0; i < n; i++) {
272
+ 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; } }
273
+ const c = newCreator(w, "poop", 0); w.creators.push(c); joined.push(c);
274
+ }
275
+ } else if (gap < 0) {
276
+ let n = Math.floor(-gap * P.POOP_LEAVE); if (R() < -gap * P.POOP_LEAVE - n) n++;
277
+ if (n > 0) { const out = new Set(); const bots = w.creators.filter(c => c.type === "poop");
278
+ while (out.size < Math.min(n, bots.length)) out.add(bots[Math.floor(R() * bots.length)]);
279
+ w.creators = w.creators.filter(c => !out.has(c)); for (const c of out) left.push(c); }
280
+ }
281
+
282
+ // 6. 用户去留
283
+ const sToday = satisfaction(w, made);
284
+ w.sat = (1 - P.USER_MEMORY) * w.sat + P.USER_MEMORY * sToday;
285
+ // 用户忍耐度不一:满意度每差 1,留下的人按比例少一截,越往后剩下的越是不挑的人,走得越慢
286
+ const target = P.U0 * Math.exp(P.USER_SENS * (w.sat - w.sat0));
287
+ const before = w.users;
288
+ w.users = w.users + P.USER_REACT * (target - w.users);
289
+
290
+ const count = { cake: 0, pop: 0, poop: 0 };
291
+ for (const c of w.creators) count[c.type]++;
292
+ w.today = {
293
+ day: w.day, k, made, sv, fm, revenue, cost, pool, users: w.users, sat: w.sat,
294
+ count, perItemIncome, botTarget, poopIncomeProspect, converted: w.converted,
295
+ cakeShareTop: fm.cake, left, joined, usersDelta: w.users - before,
296
+ };
297
+ 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 });
298
+ return w.today;
299
+ }
300
+
301
+ const API = { P, makeWorld, step, sieve, passRate, V0, TYPES };
302
+ if (typeof module !== "undefined" && module.exports) module.exports = API; else root.PoolSim = API;
303
+ })(typeof window !== "undefined" ? window : globalThis);
304
+
305
+ </script>
306
+ <script>
307
+ (function(){
308
+ "use strict";
309
+ const S = window.PoolSim, P = S.P;
310
+ const cv = document.getElementById("c"), ctx = cv.getContext("2d");
311
+ const W = 1600, H = 900;
312
+ const DPR = Math.min(2, window.devicePixelRatio || 1); cv.width = W * DPR; cv.height = H * DPR;
313
+ const FONT = getComputedStyle(document.documentElement).getPropertyValue("--font");
314
+ 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" };
315
+ const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
316
+
317
+ // ---------- 文字 ----------
318
+ const T = {
319
+ zh:{ title:"推荐池", sub:"免费看 · 广告养 · 分成活", ai:"AI 产能", day:"第 {d} 天",
320
+ m:["用户","广告收入 / 天","平台成本 / 天","推荐池里的蛋糕"], unitYuan:"元",
321
+ stage:"创作者", cake:"做蛋糕的", pop:"做爆米花的", bot:"做便便的", stageNote:"台上小人按比例代表全部创作者(做便便的按账号算)",
322
+ tier:["第一层","第二层","第三层"], perItem:"每条 {n} 人试看", rejected:"没被推荐", feed:"推荐流", plaza:"用户",
323
+ plazaNote:"一个点 = 50 位用户", adv:"广告商", vault:"平台", keep:"净赚 {v} 元/天", lose:"亏 {v} 元/天",
324
+ server:"存储·分发·审核", toCreators:"分给创作者 {v} 元/天", adIn:"广告费 {v} 元/天", attention:"用户的时间,卖给广告商",
325
+ play:"开始", pause:"暂停", resume:"继续", reset:"重来", kLab:"AI 产能倍数", spLab:"速度", slow:"慢", normal:"正常", fast:"快", lang:"English",
326
+ note:"示意模型。筛选部分(三层试看、完播率、晋级线)沿用论文图 1 的参数;用户去留、分成、成本、创作者去留等循环部分的规则与参数未经实证校准,只用来展示方向。",
327
+ cap:{ intro:"一年前,AI 还没来。好内容被一层层试看挑出来,用户吃得开心。", ready:"拧好 AI 产能倍数,按「开始」。",
328
+ ai:"AI 来了:每个人的产能 ×{k}", bot:"装天线的来了:批量做,划算了。", flood:"推荐池里混进了大量便便。",
329
+ users:"用户开始走了,广告费跟着少了。", chefs:"做蛋糕的人挣不到钱,背上包走了。", botsGone:"钱少了,做便便的退了一批,剩下的赖着不走。",
330
+ popFlood:"爆米花挤满了推荐流,好内容被淹没。", calm:"产能涨了,推荐池还挑得出来。",
331
+ half:"上传的东西一半是便便,筛子还挡得住。", lottery:"每条只剩一两个人试看,筛子成了抽签。" },
332
+ endFail:"内容多了 {k} 倍,推荐池挑不出好内容了", endOk:"{k} 倍时,推荐池还挑得出好内容", endHead:"第 365 天", from:"一年前", to:"现在", again:"按「重来」再看一遍,或者换个倍数。" },
333
+ en:{ title:"The Recommendation Pool", sub:"Free to watch · ad-funded · revenue-shared", ai:"AI output", day:"Day {d}",
334
+ m:["Users","Ad revenue / day","Platform cost / day","Cake in the pool"], unitYuan:"¥",
335
+ stage:"Creators", cake:"Cake", pop:"Popcorn", bot:"Poop", stageNote:"Figures show the creator mix in proportion (poop makers counted by account)",
336
+ tier:["Tier 1","Tier 2","Tier 3"], perItem:"{n} trial viewers each", rejected:"Not recommended", feed:"Feed", plaza:"Users",
337
+ plazaNote:"one dot = 50 users", adv:"Advertisers", vault:"Platform", keep:"Nets ¥{v}/day", lose:"Loses ¥{v}/day",
338
+ server:"Storage · delivery · review", toCreators:"To creators ¥{v}/day", adIn:"Ad spend ¥{v}/day", attention:"Users' time, sold to advertisers",
339
+ play:"Start", pause:"Pause", resume:"Resume", reset:"Restart", kLab:"AI output multiple", spLab:"Speed", slow:"Slow", normal:"Normal", fast:"Fast", lang:"中文",
340
+ 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.",
341
+ 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.",
342
+ ai:"AI arrives: everyone's output ×{k}", bot:"Antennas show up: batch production now pays.", flood:"The pool is filling up with poop.",
343
+ 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.",
344
+ popFlood:"Popcorn crowds the feed and good content gets buried.", calm:"Output is up, and the pool still picks well.",
345
+ 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." },
346
+ 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." },
347
+ };
348
+ let lang = "zh";
349
+ 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; };
350
+ const fmtInt = v => Math.round(v).toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
351
+ const fmtK = k => k >= 10 ? Math.round(k).toString() : (Math.round(k * 10) / 10).toString();
352
+
353
+ // ---------- 手绘工具 ----------
354
+ 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; }
355
+ function jit(seed, a){ return (hash(seed) - 0.5) * 2 * a; }
356
+ 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(); }
357
+ 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(); } }
358
+ 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(); } }
359
+ 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); }
360
+ function textW(s,size,weight){ ctx.font = (weight||400) + " " + size + "px " + FONT; return ctx.measureText(s).width; }
361
+
362
+ // ---------- 三种东西 ----------
363
+ function drawItem(type,x,y,s,alpha){
364
+ ctx.save(); ctx.globalAlpha = alpha == null ? 1 : alpha; ctx.translate(x,y);
365
+ const lw = Math.max(1, s * 0.09);
366
+ if (type === "cake"){
367
+ 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();
368
+ ctx.fillStyle = C.cream; ctx.fill(); ctx.strokeStyle = C.ink; ctx.lineWidth = lw; ctx.stroke();
369
+ ctx.fillStyle = C.pink; ctx.fillRect(-s*0.46, s*0.06, s*0.92, s*0.09);
370
+ circ(0, -s*0.26, s*0.2, C.cake, C.ink, lw);
371
+ 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();
372
+ } else if (type === "pop"){
373
+ 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]];
374
+ for (const [px,py] of pts){ circ(px*s, py*s, s*0.2, C.pop, C.ink, lw); }
375
+ circ(-s*0.05, -s*0.14, s*0.07, C.popHi, false);
376
+ } else {
377
+ ctx.fillStyle = C.poop; ctx.strokeStyle = C.ink; ctx.lineWidth = lw;
378
+ ctx.beginPath(); ctx.ellipse(0, s*0.22, s*0.46, s*0.16, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
379
+ ctx.beginPath(); ctx.ellipse(0, s*0.02, s*0.34, s*0.14, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
380
+ ctx.beginPath(); ctx.ellipse(0, -s*0.16, s*0.22, s*0.12, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke();
381
+ 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();
382
+ 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);
383
+ 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(); }
384
+ }
385
+ ctx.restore();
386
+ }
387
+
388
+ // ---------- 小人 ----------
389
+ function drawPerson(x,y,type,opt){
390
+ opt = opt || {}; const seed = opt.seed || 1, mood = opt.mood == null ? 1 : opt.mood;
391
+ ctx.save(); ctx.translate(x,y); if (opt.flip) ctx.scale(-1,1); ctx.globalAlpha = opt.alpha == null ? 1 : opt.alpha;
392
+ const lw = 2.2;
393
+ const walk = opt.walk || 0;
394
+ // 腿
395
+ line(0,-6, -7 + Math.sin(walk)*6, 14, seed, lw); line(0,-6, 7 - Math.sin(walk)*6, 14, seed+9, lw);
396
+ // 身子
397
+ line(0,-30, 0,-6, seed+20, lw);
398
+ // 背包
399
+ if (opt.pack){ rrect(-16,-30,11,18,seed+40,"#c9b48a",C.ink,1.8); }
400
+ // 左手:摊开
401
+ 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();
402
+ // 右手:往前推
403
+ const push = opt.push == null ? 0 : opt.push, blur = opt.blur || 0;
404
+ 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;
405
+ 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(); } }
406
+ else { line(0,-24, 8 + 14*push, -22 + 4*push, seed+60, lw); }
407
+ // 头
408
+ const headFill = type === "poop" ? "#eef1f6" : "#ffffff";
409
+ circ(0,-44, 14, headFill, C.ink, lw);
410
+ 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); }
411
+ 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;
412
+ 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(); }
413
+ // 头顶
414
+ 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();
415
+ 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); }
416
+ 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); }
417
+ 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(); } }
418
+ ctx.restore();
419
+ }
420
+
421
+ // ---------- 版面坐标 ----------
422
+ const L = {
423
+ stage:{ x:28, y:150, w:450, h:500 },
424
+ slots:[], // 填在下面
425
+ sieve:[ {x:730,y:225}, {x:730,y:385}, {x:730,y:545} ], bowlW:240,
426
+ bin:{ x:730, y:640 },
427
+ feed:{ x1:1060, x2:1572, y:598 },
428
+ plaza:{ x:1040, y:150, w:540, h:420 },
429
+ adv:{ x:1500, y:810 }, vault:{ x:830, y:800 }, server:{ x:1050, y:820 },
430
+ };
431
+ const COLS = 6, ROWS = 5;
432
+ 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 });
433
+ const NSLOT = L.slots.length;
434
+
435
+ // ---------- 状态 ----------
436
+ let world, today, kSel = 100, running = false, started = false, ended = false, speed = 1, dayAcc = 0;
437
+ const DAY_SEC = 0.16;
438
+ let slots = [], walkers = [], items = [], pops = [], coins = [], dots = [], reacts = [], hist = [], belt = [];
439
+ let caps = [], capShown = null, capT = 0, flags = {}, peakBots = 0, startStats = null, endStats = null;
440
+
441
+ function reset(){
442
+ world = S.makeWorld({ k: kSel, seed: 7 }); world.kTarget = kSel;
443
+ today = probeToday();
444
+ running = false; started = false; ended = false; dayAcc = 0;
445
+ slots = []; walkers = []; items = []; pops = []; coins = []; dots = []; reacts = []; hist = []; belt = [];
446
+ caps = []; capShown = { key:"intro" }; capT = 0; flags = {}; peakBots = 0; endStats = null;
447
+ layoutSlots(true); layoutDots(true);
448
+ startStats = { users: P.U0, revenue: today.revenue, cost: today.cost, cake: today.cakeShareTop };
449
+ pushHist(today);
450
+ setPlayLabel();
451
+ }
452
+ // 一倍时的"今天":复制世界跑一步拿数,不推进真正的世界
453
+ function probeToday(){
454
+ const w2 = S.makeWorld({ k: 1, seed: 7 }); w2.kTarget = 1; return S.step(w2);
455
+ }
456
+ function pushHist(t){ hist.push({ users:t.users, revenue:t.revenue, cost:t.cost, cake:t.cakeShareTop }); }
457
+
458
+ // 台上的小人:按人数比例分 30 个位子,有人就至少 1 个位子
459
+ function targetSlots(count){
460
+ const tot = count.cake + count.pop + count.poop || 1; const types = ["cake","pop","poop"]; const out = {}; let used = 0; const rem = [];
461
+ 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]); }
462
+ rem.sort((a,b) => b[0]-a[0]); let i = 0;
463
+ while (used < NSLOT){ out[rem[i % 3][1]]++; used++; i++; }
464
+ while (used > NSLOT){ const t = types.slice().sort((a,b) => out[b]-out[a])[0]; out[t]--; used--; }
465
+ return out;
466
+ }
467
+ function layoutSlots(instant, converted){
468
+ const count = today ? today.count : world.creators.reduce((o,c) => (o[c.type]++, o), {cake:0,pop:0,poop:0});
469
+ const tgt = targetSlots(count);
470
+ if (instant || slots.length === 0){
471
+ const order = []; for (const t of ["cake","pop","poop"]) for (let i = 0; i < tgt[t]; i++) order.push(t);
472
+ // 打散,让三种人混站
473
+ 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]]; }
474
+ slots = order.map((t,i) => ({ type:t, seed:i*31+5, phase:hash(i*13), pop:1, born:-9 }));
475
+ return;
476
+ }
477
+ const have = { cake:0, pop:0, poop:0 }; for (const s of slots) have[s.type]++;
478
+ let conv = converted || 0;
479
+ // 先让做爆米花的就地装天线
480
+ while (conv > 0 && have.poop < tgt.poop && have.pop > tgt.pop){
481
+ const cand = slots.filter(s => s.type === "pop"); if (!cand.length) break; const s = cand[Math.floor(Math.random()*cand.length)];
482
+ s.type = "poop"; s.spark = 1.2; have.pop--; have.poop++; conv--;
483
+ }
484
+ for (const t of ["cake","pop","poop"]){
485
+ while (have[t] > tgt[t]){
486
+ const cand = slots.map((s,i) => [s,i]).filter(([s]) => s.type === t && !s.leaving); if (!cand.length) break;
487
+ const [s,i] = cand[Math.floor(Math.random()*cand.length)];
488
+ walkers.push({ type:t, x:L.slots[i].x, y:L.slots[i].y, seed:s.seed, t:0 });
489
+ s.type = null; have[t]--;
490
+ }
491
+ }
492
+ const empty = slots.filter(s => s.type === null);
493
+ const need = []; for (const t of ["cake","pop","poop"]) for (let i = have[t]; i < tgt[t]; i++) need.push(t);
494
+ 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; }
495
+ }
496
+
497
+ // 用户的点
498
+ function layoutDots(instant){
499
+ const n = Math.max(0, Math.round((today ? today.users : P.U0) / 50));
500
+ const cols = 20, cw = 26, rh = 38;
501
+ while (dots.filter(d => !d.leaving).length > n){
502
+ const live = dots.filter(d => !d.leaving); const d = live[Math.floor(Math.random()*live.length)]; d.leaving = true;
503
+ }
504
+ let live = dots.filter(d => !d.leaving).length;
505
+ while (live < n){
506
+ // 找空位
507
+ const used = new Set(dots.filter(d => !d.leaving).map(d => d.slot)); let slot = 0; while (used.has(slot)) slot++;
508
+ 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;
509
+ 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 });
510
+ live++;
511
+ }
512
+ }
513
+
514
+ // ---------- 一天 ----------
515
+ function advanceDay(){
516
+ if (world.day >= 365){ finish(); return; }
517
+ today = S.step(world);
518
+ pushHist(today);
519
+ layoutSlots(false, (today.converted || []).length);
520
+ layoutDots(false);
521
+ events();
522
+ if (world.day >= 365) finish();
523
+ }
524
+ function queue(key, vars){ if (flags[key]) return; flags[key] = true; caps = [{ key, vars }]; } // 只留最新的一条
525
+ function events(){
526
+ const t = today, d = world.day;
527
+ peakBots = Math.max(peakBots, t.count.poop);
528
+ if (d === world.aiDay) queue("ai", { k: fmtK(kSel) });
529
+ if (t.count.poop > 0) queue("bot");
530
+ { 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"); }
531
+ if (t.sv.tiers[0].n < 2 && d > world.aiDay) queue("lottery");
532
+ if (t.fm.poop > 0.25) queue("flood");
533
+ if (t.users < 0.85 * P.U0) queue("users");
534
+ if (t.count.cake < 30 && d > world.aiDay) queue("chefs");
535
+ if (peakBots >= 10 && t.count.poop < peakBots * 0.3) queue("botsGone");
536
+ if (t.fm.pop > 0.5) queue("popFlood");
537
+ if (d === world.aiDay + 80 && t.users > 0.9 * P.U0 && t.cakeShareTop > 0.8) queue("calm");
538
+ }
539
+ function finish(){
540
+ running = false; ended = true; setPlayLabel();
541
+ endStats = { users: today.users, revenue: today.revenue, cost: today.cost, cake: today.cakeShareTop };
542
+ }
543
+
544
+ // ---------- 动画 ----------
545
+ function stageTarget(){ return { x: L.sieve[0].x - 40 + Math.random()*80, y: L.sieve[0].y - 22 }; }
546
+ function tierLoad(i){ const n = today ? today.sv.tiers[i].n : P.TIERS[i]; return P.TIERS[i] / Math.max(n, 0.5); }
547
+ function emitFrom(i){
548
+ const s = slots[i]; if (!s.type) return;
549
+ const p = L.slots[i]; const tg = stageTarget();
550
+ if (items.length > 520) return;
551
+ 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 });
552
+ }
553
+ 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); }
554
+ const VIS = 0.43; // 每天 1 条 ≈ 每秒 0.43 次推
555
+ function update(dt){
556
+ const simDt = dt * speed;
557
+ if (running && !ended){ dayAcc += simDt; while (dayAcc >= DAY_SEC){ dayAcc -= DAY_SEC; advanceDay(); if (ended) break; } }
558
+ // 推
559
+ for (let i = 0; i < slots.length; i++){
560
+ const s = slots[i]; if (!s.type) continue;
561
+ s.pop = Math.min(1, s.pop + dt*2.5); if (s.spark) s.spark = Math.max(0, s.spark - dt);
562
+ const f = personRate(s.type) * VIS; s.f = f;
563
+ s.phase += dt * f;
564
+ while (s.phase >= 1){ s.phase -= 1; if (Math.random() < 1) emitFrom(i); }
565
+ }
566
+ // 东西
567
+ pileCount = [0,0,0]; for (const it of items) if (it.st === "pile") pileCount[it.tier]++;
568
+ const fall = [];
569
+ for (const it of items){
570
+ it.t += dt;
571
+ 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); } }
572
+ else if (it.st === "pile"){ if (it.t >= it.dwell){ judge(it); } }
573
+ else if (it.st === "drop"){ it.vy += 900*dt; it.y += it.vy*dt; it.x += it.vx*dt;
574
+ 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); }
575
+ else if (it.next == null && it.y >= L.bin.y - 6){ it.st = "dead"; binCount[it.type] = (binCount[it.type]||0) + 1; } }
576
+ 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"; } }
577
+ else if (it.st === "feed"){ it.x += dt * 70; if (it.x > L.feed.x2){ it.st = "dead"; } }
578
+ }
579
+ items = items.filter(it => it.st !== "dead");
580
+ // 堆:每层最多显示 140 个,多的从底下消掉(仍按规则判)
581
+ // 推荐流里太挤就去掉旧的
582
+ // 推荐流传送带:上面每样东西的比例 = 当天推荐池里的真实比例
583
+ for (const b of belt) b.x += dt * 150 * Math.max(1, speed);
584
+ belt = belt.filter(b => b.x < L.feed.x2 - 6);
585
+ if (!belt.length || belt[belt.length-1].x > L.feed.x1 + 30){
586
+ if (!belt.length){ for (let x = L.feed.x2 - 20; x > L.feed.x1; x -= 30) belt.unshift({ x, type: sampleFeed() }); }
587
+ belt.push({ x: L.feed.x1 + 4, type: sampleFeed() });
588
+ }
589
+ // 走的人
590
+ for (const w of walkers){ w.t += dt; w.x -= dt*80; }
591
+ walkers = walkers.filter(w => w.x > -60);
592
+ // 用户
593
+ for (const d of dots){
594
+ if (d.leaving){ d.x += dt * 110; continue; }
595
+ d.x += (d.hx - d.x) * Math.min(1, dt*2.2); d.y += (d.hy - d.y) * Math.min(1, dt*2.2);
596
+ }
597
+ dots = dots.filter(d => d.x < W + 40);
598
+ // 反应
599
+ if (today){ const live = dots.filter(d => !d.leaving); const rate = live.length * 0.06 * dt * (REDUCED ? 0.3 : 1);
600
+ let n = Math.floor(rate); if (Math.random() < rate - n) n++;
601
+ 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();
602
+ 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 }); } }
603
+ for (const r of reacts){ r.t += dt; r.y -= dt*22; }
604
+ reacts = reacts.filter(r => r.t < 1.4);
605
+ // 金币
606
+ if (today){
607
+ const base = startStats.revenue;
608
+ spawnCoins("ad", today.revenue / base * 5 * dt);
609
+ spawnCoins("share", today.pool / base * 5 * dt * 2.2);
610
+ spawnCoins("cost", today.cost / base * 5 * dt * 2.2);
611
+ }
612
+ for (const c of coins){ c.t += dt; }
613
+ coins = coins.filter(c => c.t < c.dur);
614
+ // 字幕
615
+ capT += dt;
616
+ if (caps.length && (!capShown || capT > 1.4 || capShown.key === "intro" || capShown.key === "ready")){ capShown = caps.shift(); capT = 0; }
617
+ }
618
+ const binCount = {}; let pileCount = [0,0,0];
619
+ function mixSeen(){ const f = today.fm, m = today.made, nt = m.cake + m.pop + m.poop || 1; const ts = P.TRIAL_SHARE;
620
+ return { cake:(1-ts)*f.cake + ts*m.cake/nt, poop:(1-ts)*f.poop + ts*m.poop/nt }; }
621
+ function placeOnPile(it, tier){
622
+ const s = L.sieve[tier]; let idx = pileCount[tier];
623
+ let row = 0, perRow = 15; while (idx >= perRow && row < 9){ idx -= perRow; row++; perRow = Math.max(3, 15 - row*1.5) | 0; }
624
+ if (row >= 9){ it.tier = tier; it.t = 0; judge(it); return; } // 堆满了:不再往上堆,直接筛
625
+ const span = L.bowlW * 0.84 * perRow / 15;
626
+ 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]++;
627
+ }
628
+ function judge(it, silent){
629
+ const n = today ? today.sv.tiers[it.tier].n : P.TIERS[it.tier];
630
+ const pass = Math.random() < S.passRate(P.COMPLETION[it.type], n);
631
+ if (silent){ it.st = "dead"; return; }
632
+ if (pass){
633
+ 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; }
634
+ else { it.st = "chute"; it.t = 0; it.sx = L.sieve[2].x + L.bowlW/2; it.sy = L.sieve[2].y + 2; }
635
+ } 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; }
636
+ }
637
+ function spawnCoins(kind, expected){
638
+ let n = Math.floor(expected); if (Math.random() < expected - n) n++;
639
+ 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 });
640
+ }
641
+ // 金币路径
642
+ const PATH = {
643
+ ad:[[L.adv.x-40, L.adv.y-10],[L.vault.x+70, L.vault.y-10]],
644
+ share:[[L.vault.x-70, L.vault.y-10],[452, L.vault.y-10],[452, 650]],
645
+ cost:[[L.vault.x+40, L.vault.y+45],[L.server.x-50, L.server.y+30]],
646
+ };
647
+ function along(path, u){
648
+ 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; }
649
+ 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]; }
650
+ return path[path.length-1];
651
+ }
652
+
653
+ // ---------- 画 ----------
654
+ function drawPaper(){
655
+ ctx.fillStyle = C.paper; ctx.fillRect(0,0,W,H);
656
+ ctx.strokeStyle = C.grid; ctx.lineWidth = 1;
657
+ 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();
658
+ }
659
+ function sparkline(x,y,w,h,key,base){
660
+ if (hist.length < 2) return;
661
+ const vals = hist.map(o => o[key]); const max = Math.max(base*1.15, ...vals);
662
+ 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); }
663
+ ctx.strokeStyle = C.accent; ctx.lineWidth = 2; ctx.stroke();
664
+ 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([]);
665
+ }
666
+ function drawTop(){
667
+ ctx.fillStyle = "#ffffffcc"; ctx.fillRect(0,0,W,112); line(0,112,W,112,3,2.5);
668
+ text(tr("title"), 30, 52, lang === "zh" ? 40 : 30, C.ink, "left", 800);
669
+ text(tr("sub"), 32, 86, 18, C.soft);
670
+ // 倍数和天数
671
+ const k = today ? today.k : 1;
672
+ text(tr("ai"), 500, 44, 18, C.soft);
673
+ text("×" + fmtK(started ? k : kSel), 500, 92, 46, k > 1.05 ? C.bad : C.ink, "left", 800);
674
+ text(tr("day", { d: world.day }), 690, 92, 22, C.ink, "left", 600);
675
+ if (!started){ text(lang === "zh" ? "(即将到来)" : "(coming)", 690, 60, 15, C.soft); }
676
+ // 四个表
677
+ const t = today;
678
+ const ms = [
679
+ { v: fmtInt(t.users), key:"users", base:P.U0, bad: t.users < 0.85*P.U0 },
680
+ { v: (lang==="zh" ? "" : "¥") + fmtInt(t.revenue) + (lang==="zh" ? " 元" : ""), key:"revenue", base:startStats.revenue, bad: t.revenue < 0.85*startStats.revenue },
681
+ { v: (lang==="zh" ? "" : "¥") + fmtInt(t.cost) + (lang==="zh" ? " 元" : ""), key:"cost", base:startStats.cost, bad: t.cost > 1.5*startStats.cost },
682
+ { v: Math.round(t.cakeShareTop*100) + "%", key:"cake", base:1, bad: t.cakeShareTop < 0.5 },
683
+ ];
684
+ for (let i = 0; i < 4; i++){
685
+ const x = 840 + i*188, y = 12, w = 176;
686
+ rrect(x, y, w, 90, 100+i*9, "#fff", C.ink, 1.6);
687
+ text(tr("m")[i], x+12, y+24, 15, C.soft);
688
+ text(ms[i].v, x+12, y+58, 27, ms[i].bad ? C.bad : C.ink, "left", 800);
689
+ sparkline(x+12, y+64, w-24, 20, ms[i].key, ms[i].base);
690
+ }
691
+ }
692
+ function drawCaption(){
693
+ if (!capShown) return;
694
+ const s = tr("cap." + capShown.key, capShown.vars);
695
+ const size = 24, w = textW(s, size, 700) + 44, x = W/2 - w/2 - 60, y = 124;
696
+ ctx.save(); ctx.globalAlpha = Math.min(1, capT*4 + (capShown.key==="intro"?1:0));
697
+ rrect(x, y, w, 42, 999, "#fff8d6", C.ink, 1.8);
698
+ text(s, x + w/2, y + 30, size, C.ink, "center", 700); ctx.restore();
699
+ }
700
+ function drawStage(){
701
+ const st = L.stage;
702
+ // 台子
703
+ rrect(st.x+6, 620, st.w-20, 22, 11, "#e8dcc2", C.ink, 2.2);
704
+ 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);
705
+ text(tr("stage"), st.x+14, 168, 20, C.ink, "left", 700);
706
+ text(tr("stageNote"), st.x+14 + textW(tr("stage"), 20, 700) + 10, 168, 13, C.soft);
707
+ for (let i = 0; i < slots.length; i++){
708
+ const s = slots[i]; if (!s.type) continue; const p = L.slots[i];
709
+ 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);
710
+ ctx.save(); const sc = 0.4 + 0.6 * easeOut(s.pop); ctx.translate(p.x, p.y); ctx.scale(sc, sc);
711
+ drawPerson(0, 0, s.type, { seed: s.seed, push, blur, mood: 1, blink: (performance.now()/400 + i) % 2 < 1, spark: s.spark > 0 });
712
+ ctx.restore();
713
+ }
714
+ 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)) }); }
715
+ // 人数
716
+ const c = today.count;
717
+ const legend = [["cake", tr("cake"), c.cake], ["pop", tr("pop"), c.pop], ["poop", tr("bot"), c.poop]];
718
+ // 竖排,一行一种,图标放大到看得清
719
+ legend.forEach(([type, name, n], i) => {
720
+ const y = 706 + i * 68;
721
+ drawItem(type, st.x + 46, y, 50);
722
+ const col = type === "poop" && n > 0 ? C.bad : C.ink;
723
+ text(name, st.x + 92, y + 10, 26, col, "left", 700);
724
+ text(fmtInt(n), st.x + 92 + textW(name, 26, 700) + 14, y + 10, 30, col, "left", 800);
725
+ });
726
+ }
727
+ function easeOut(u){ return 1 - Math.pow(1-u, 3); }
728
+ function drawSieves(){
729
+ for (let i = 0; i < 3; i++){
730
+ const s = L.sieve[i], bw = L.bowlW;
731
+ // 碗
732
+ 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();
733
+ 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();
734
+ 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();
735
+ line(s.x - bw/2 - 10, s.y, s.x + bw/2 + 10, s.y, 300+i, 2.6);
736
+ // 试吃的人
737
+ const n = today.sv.tiers[i].n; const tasters = Math.max(1, Math.round(5 * today.users / P.U0));
738
+ 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;
739
+ 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); }
740
+ const lab = tr("tier")[i];
741
+
742
+ }
743
+ // 没被推荐
744
+ 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();
745
+ text(tr("rejected"), L.bin.x, L.bin.y + 42, 14, C.soft, "center");
746
+ // 滑道
747
+ 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);
748
+ }
749
+ function drawTierLabels(){
750
+ for (let i = 0; i < 3; i++){ const s = L.sieve[i], n = today.sv.tiers[i].n, lab = tr("tier")[i];
751
+ const nTxt = n >= 10 ? fmtInt(n) : n >= 0.1 ? (Math.round(n*10)/10).toString() : (lang === "zh" ? "不到 0.1" : "<0.1");
752
+ const lt = lab + " " + tr("perItem", { n: nTxt }); const lw2 = textW(lt, 14, 700) + 20;
753
+ rrect(s.x - lw2/2, s.y + 46, lw2, 26, 320+i, "#fffffff2", C.ink, 1.2);
754
+ text(lab, s.x - lw2/2 + 10, s.y + 64, 14, C.ink, "left", 800);
755
+ 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); }
756
+ }
757
+ 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"; }
758
+ function drawItems(){
759
+ 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); }
760
+ for (const b of belt) drawItem(b.type, b.x, L.feed.y - 2, 22);
761
+ }
762
+ function drawPlaza(){
763
+ // 推荐流
764
+ text(tr("feed"), L.feed.x1, L.feed.y + 46, 16, C.ink, "left", 700);
765
+ line(L.feed.x1 - 8, L.feed.y + 16, L.feed.x2 + 6, L.feed.y + 16, 500, 2.4);
766
+ 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); }
767
+ text(tr("plaza"), L.plaza.x + 10, L.plaza.y + 18, 18, C.ink, "left", 700);
768
+ text(tr("plazaNote"), L.plaza.x + 10 + textW(tr("plaza"), 18, 700) + 10, L.plaza.y + 18, 13, C.soft);
769
+ const sat = today ? today.sat : 0.9;
770
+ for (const d of dots){
771
+ const m = sat + d.off; const mood = m > 0.55 ? 1 : m > -0.1 ? 0 : -1;
772
+ ctx.save(); ctx.translate(d.x, d.y); const fill = mood > 0 ? "#ffeef0" : mood < 0 ? "#e9e3dc" : "#ffffff";
773
+ 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);
774
+ 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();
775
+ ctx.restore();
776
+ }
777
+ for (const r of reacts){ ctx.save(); ctx.globalAlpha = Math.max(0, 1 - r.t/1.4);
778
+ 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(); }
779
+ 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); }
780
+ ctx.restore(); }
781
+ }
782
+ function drawMoney(){
783
+ const t = today;
784
+ // 注意力:用户 → 广告商
785
+ ctx.setLineDash([6,6]); line(1420, 628, L.adv.x - 6, L.adv.y - 70, 600, 1.8, C.accent, 0.5); ctx.setLineDash([]);
786
+ text(tr("attention"), 1455, 672, 14, C.accent, "right", 600);
787
+ // 管道
788
+ 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"; };
789
+ const base = startStats.revenue;
790
+ pipe(PATH.ad, 6 + 14 * Math.min(1.5, t.revenue/base), 1);
791
+ pipe(PATH.share, 3 + 6 * Math.min(1.5, t.pool/(base*P.CREATOR_SHARE)), 2);
792
+ pipe(PATH.cost, 3 + 5 * Math.min(4, t.cost/startStats.cost), 3);
793
+ 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); }
794
+ // 广告商
795
+ drawPerson(L.adv.x, L.adv.y + 30, "pop", { seed: 901, mood: t.revenue > 0.85*base ? 1 : 0, push: 0 });
796
+ 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();
797
+ rrect(L.adv.x + 14, L.adv.y + 2, 24, 18, 902, "#8f6b3e", C.ink, 1.6);
798
+ text(tr("adv"), L.adv.x, L.adv.y + 72, 16, C.ink, "center", 700);
799
+ 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);
800
+ // 金库
801
+ rrect(L.vault.x - 60, L.vault.y - 50, 120, 96, 910, "#e7ecf3", C.ink, 2.4);
802
+ 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);
803
+ text(tr("vault"), L.vault.x, L.vault.y - 60, 16, C.ink, "center", 700);
804
+ const net = t.revenue * (1 - P.CREATOR_SHARE) - t.cost;
805
+ 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);
806
+ // 服务器
807
+ const racks = Math.min(6, Math.max(1, Math.round(t.cost / startStats.cost)));
808
+ 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); }
809
+ text(tr("server"), L.server.x + 60, L.server.y + 14, 14, C.soft, "left");
810
+ 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);
811
+ // 分给创作者
812
+ text(tr("toCreators", { v: fmtInt(t.pool) }), 480, L.vault.y - 26, 15, C.ink, "left", 600);
813
+ }
814
+ function drawEnd(){
815
+ if (!ended || !endStats) return;
816
+ const fail = endStats.users < 0.75*P.U0 || endStats.cake < 0.5;
817
+ const head = fail ? tr("endFail", { k: fmtK(kSel) }) : tr("endOk", { k: fmtK(kSel) });
818
+ ctx.save(); ctx.fillStyle = "#fbfbf7e6"; ctx.fillRect(0, 112, W, H-112);
819
+ const x = 330, y = 210, w = 940, h = 470;
820
+ rrect(x, y, w, h, 1001, "#fff", C.ink, 2.6);
821
+ text(tr("endHead"), x + 40, y + 58, 20, C.soft, "left", 700);
822
+ const hs = textW(head, 34, 800) > w - 80 ? 26 : 34;
823
+ text(head, x + 40, y + 108, hs, C.ink, "left", 800);
824
+ const rows = [
825
+ [tr("m")[0], fmtInt(startStats.users), fmtInt(endStats.users)],
826
+ [tr("m")[1], fmtInt(startStats.revenue), fmtInt(endStats.revenue)],
827
+ [tr("m")[2], fmtInt(startStats.cost), fmtInt(endStats.cost)],
828
+ [tr("m")[3], Math.round(startStats.cake*100) + "%", Math.round(endStats.cake*100) + "%"],
829
+ ];
830
+ text(tr("from"), x + 520, y + 168, 16, C.soft, "right", 700); text(tr("to"), x + 760, y + 168, 16, C.soft, "right", 700);
831
+ 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);
832
+ 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); });
833
+ text(tr("again"), x + 40, y + h - 30, 15, C.soft, "left");
834
+ ctx.restore();
835
+ }
836
+ function render(){
837
+ ctx.setTransform(DPR,0,0,DPR,0,0);
838
+ drawPaper(); drawStage(); drawSieves(); drawPlaza(); drawMoney(); drawItems(); drawTierLabels(); drawTop(); drawCaption(); drawEnd();
839
+ }
840
+
841
+ // ---------- 循环 ----------
842
+ let last = performance.now();
843
+ function frame(now){
844
+ const dt = Math.min(0.05, (now - last) / 1000); last = now;
845
+ update(dt); render(); requestAnimationFrame(frame);
846
+ }
847
+
848
+ // ---------- 控件 ----------
849
+ const btnPlay = document.getElementById("play"), btnReset = document.getElementById("reset"), kIn = document.getElementById("k"), kOut = document.getElementById("kOut"), btnLang = document.getElementById("lang");
850
+ const presets = [1, 3, 10, 30, 100]; const presetBox = document.getElementById("presets");
851
+ presets.forEach(v => { const b = document.createElement("button"); b.type = "button"; b.textContent = "×" + v; b.addEventListener("click", () => { setK(v); }); presetBox.appendChild(b); });
852
+ function niceK(v){ if (v < 10) return Math.round(v*10)/10; return Math.round(v); }
853
+ 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) {/* 运行中改倍数:立刻生效 */} }
854
+ kIn.addEventListener("input", () => setK(Math.pow(10, +kIn.value)));
855
+ btnPlay.addEventListener("click", () => {
856
+ if (ended){ reset(); }
857
+ if (!started){ started = true; running = true; capShown = { key:"intro" }; capT = 0; }
858
+ else running = !running;
859
+ setPlayLabel();
860
+ });
861
+ btnReset.addEventListener("click", () => { reset(); });
862
+ 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")); }));
863
+ btnLang.addEventListener("click", () => setLang(lang === "zh" ? "en" : "zh"));
864
+ function setPlayLabel(){ btnPlay.textContent = ended ? tr("play") : !started ? tr("play") : running ? tr("pause") : tr("resume"); }
865
+ function setLang(l){ lang = l; document.documentElement.lang = l === "zh" ? "zh-CN" : "en";
866
+ btnLang.textContent = tr("lang"); btnReset.textContent = tr("reset"); document.getElementById("kLab").textContent = tr("kLab"); document.getElementById("spLab").textContent = tr("spLab");
867
+ const sp = document.querySelectorAll("[data-speed]"); sp[0].textContent = tr("slow"); sp[1].textContent = tr("normal"); sp[2].textContent = tr("fast");
868
+ document.getElementById("note").textContent = tr("note"); cv.setAttribute("aria-label", l === "zh" ? "推荐池模拟画面" : "Recommendation pool simulation"); setPlayLabel(); }
869
+
870
+ // 截图用:快进到某一天,并让画面上的东西先流动一会儿
871
+ window.demo = {
872
+ jump(k, day, warmSec){ kSel = k; kIn.value = Math.log10(k); kOut.textContent = "×" + fmtK(k); reset(); started = true; running = false;
873
+ while (world.day < day){ today = S.step(world); pushHist(today); } peakBots = 0; layoutSlots(true); layoutDots(true); caps = []; capShown = null; ended = false;
874
+ const steps = Math.round((warmSec || 4) / 0.02); for (let i = 0; i < steps; i++) update(0.02); },
875
+ setLang, get today(){ return today; }, get world(){ return world; },
876
+ finishAt(k){ this.jump(k, 365, 3); finish(); },
877
+ };
878
+
879
+ reset(); setK(kSel);
880
+ setLang("en");
881
+ capShown = { key:"ready" };
882
+ requestAnimationFrame(frame);
883
+ })();
884
+ </script>