lili a10945d74f feat(polaris): 《夜市一条街》M3——内容一期到量+economy_curve 千局网格定标
内容四轴灌装到量表(菜谱 30/顾客 19/事件 12/任务 52+成就);千局网格揪修三真缺陷:破产死锁
(11/25 生涯中期现金归零永锁→relief.floor_coins 地板+autoRestock 均衡轮转,根因=旧表序贪婪把救济金砸单一食材)、
卡点张力缺席(判据 2 首测 n=0→地块 900/2200/4500+树尾 22 节点×1.6+设备×1.6+员工升级基价×3)、
离线封顶 2h→75min(判据 5 解析收敛 44.4%→33.3%)。
门证据:上界安全门 7 配置×7000 营业日全过(判据 1 零死锁窗/判据 2 基线卡点中位 15min∈[15,45],
主会话复跑逐值一致)+30 分钟真玩口径三 seed 零卡死零 uncaught+扰动翼安全边界探明(小费 0.03 塌/0.04 绿、
薪涨 1.15 塌/1.12 绿,域外塌方翼零死锁=relief 兜底最坏参数仍保能玩);数值定标报告归档
docs/m3-数值定标报告.md(26 锚逐项保留/调整/废弃+三缺陷修复史+M5 输入+driver 两课);消耗率@10h 95.7%(判据 3 归 M5 观测)。
回归:logic-smoke 95 断言(⑤解锁阈值按设计 §3 首小时消耗带 ≥6→≥4、机制不放水)+tree-check 15 查
+单测 30/30+M2 千局零死锁+富三门接线未动;主会话真跑复验。M4 闸=风格 A 暖夜霓虹已拍。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 04:25:46 -07:00

509 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* scripts/logic-smoke.mjs —— 《夜市一条街》纯逻辑冒烟(node 直跑 · 无浏览器 · M0/M1 回归 + M2 门证据件)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §9 M0/M1/M2 · §10 U0/U8a/U3-U7
*
* 【M0 回归四段】(M1 生涯账本口径下逐条重述,不变量不放水:
* M0「coins 从 0 起」→ M1 生涯账自 starting_coins 起,故「零收入」断言 = coins 恒等初始资金;
* 勤快席新增「缺料就地进货」= 三资源食材环的真实玩法,非放水——不进货放着必翻台。)
* ① 勤快席路:出餐 ≥20、净入为正、连击 ≥3、口碑不塌、day_end latch 不回弹且账本封账。
* ② 放任席路:零出餐、翻台成串、口碑落地板、金币恒等初始资金、日终仍 latch、零崩溃。
* ③ 确定性:同 seed 双跑终态账本逐字段一致(仿真器可复现前提,底稿 §2.C)。
* ④ 单局墙钟:整营业日快进 node 实测毫秒(U8a 锚)。
* 【M1 四段】
* ⑤ 跨日串跑 5 营业日(U8a 扩展):日始策略 = 解锁「够得着最便宜」直到买不起 + 自动补货;
* 断言生涯推进(金币/声望/解锁/菜单扩容)+ 墙钟落 dist/m1-sim-report.json。
* ⑥ 存档往返确定性:5 日连跑 vs 3 日→导出→新核导入→续跑 2 日,终态账本逐字段一致。
* ⑦ 离线收益口径:速率 = 近 3 日每分净入均值×10%,封顶 2h(economy_curve 单源对账)。
* ⑧ 信封负例:未来版本拒读不毁档 / 坏信封拒读 / 0 版旧档迁移补缺省。
* 【M2 七段】(八系统机制面;每系统最小闭环真跑,效果全经解释器回声对账)
* ⑨ 扩张:门户未解锁拒购 → 解锁 → 盘地(容量/坐标池真扩)→ 设备入槽(修饰真生效/满槽拒)。
* ⑩ 研发:缺食材拒 → 补料 → 解锁消耗库存(FIFO)→ 菜单真扩容。
* ⑪ 员工:雇后厨(倍速回声)/帮工整日代打(零 driver 仍出餐)→ 日结扣薪(dayNet 如实)→ 升级曲线。
* ⑫ 事件:偶数日潮汐窗口修饰真生效(窗口内外回声对照)+ 评论家有界天数内真登场、好评加声望。
* ⑬ 变现位:加速 filled 生效/到期还原/冷却;结算双倍语境校验+入账;失败路静默无奖;pending 超时兜底。
* ⑭ 成就图鉴:收集态真累积、成就达成可领、重复领拒、未达领拒。
* ⑮ M2 存档往返:四系统态(地块/设备/员工/图鉴/成就/事件签)存-读-续与连跑逐字段一致;
* M1 v1 旧档(无 M2 字段)导入缺省兜底。
*
* 用法:node scripts/logic-smoke.mjs (退出码 0=全过)
*/
'use strict';
import { performance } from 'node:perf_hooks';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createGameCore } from '../src/core/game-core.js';
import { createSeededRandom } from '../src/core/seeded-random.js';
import { RECIPES } from '../src/data/recipes.js';
import { ECONOMY_CURVE } from '../src/data/economy-curve.js';
let failures = 0;
function assert(cond, name, detail) {
if (cond) console.log(` PASS ${name}${detail ? ` (${detail})` : ''}`);
else { console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); failures++; }
}
const DT = 0.1; // 快进步长(耐心最短 3.5s(5×0.7)≫dt,时序保真)
const DAY_STEPS = 6001; // 600s 营业日 + 收尾一步
const START_COINS = ECONOMY_CURVE.starting_coins;
const RECIPE_BY_ID = new Map(RECIPES.map((r) => [r.id, r]));
/** 勤快席 driver:点「耐心最紧」的等待顾客;缺料就地按缺口进货(食材环的真实经营决策)。 */
function diligentDriver(core, st) {
if (!st.tapTargets.length) return;
if (!st.stoves.some((s) => !s.busy)) return; // 无空灶不空点
const byUrgency = [...st.tapTargets].sort((a, b) => a.patienceLeft - b.patienceLeft);
for (const target of byUrgency) {
if (!target.servable) {
// 缺料:按这单的配方缺口精准补货(白盒冒烟允许读表;买不起就看下一位)
const cu = st.customers.find((c) => c.id === target.customerId);
const recipe = cu && RECIPE_BY_ID.get(cu.orderRecipeId);
if (!recipe) continue;
const stockOf = new Map(st.stockSummary.map((s) => [s.id, s.units]));
let bought = true;
for (const ing of recipe.ingredients) {
const shortfall = ing.qty - (stockOf.get(ing.id) || 0);
if (shortfall > 0 && !core.buyIngredient(ing.id, shortfall).ok) { bought = false; break; }
}
if (!bought) continue;
}
if (core.tapCustomer(target.customerId).ok) return; // 一帧一招(真玩节奏)
}
}
/** 跑一个营业日(不含跨日推进)。driver: (core, state)=>void 每帧出招;返回墙钟。 */
function runDayInPlace(core, driver) {
const t0 = performance.now();
for (let f = 0; f < DAY_STEPS && core.readState().phase === 'play'; f++) {
if (driver) driver(core, core.readState());
core.update(DT);
core.drainFx(); // headless 消费掉 juice 队列(渲染缺席时的取走方)
}
return performance.now() - t0;
}
/** 建核跑单日(M0 形态)。 */
function runDay(seed, driver) {
const core = createGameCore({ random: createSeededRandom(seed) });
core.start();
const wallMs = runDayInPlace(core, driver);
return { core, st: core.readState(), wallMs };
}
/** 日始策略(⑤⑥共用,确定性):先补货、留应急进货金,余钱才解锁「够得着最便宜」。
* (首版曾先解锁后补货 → 金币花光断供 → 后续日零出餐死锁;经营顺序修正为补货优先。) */
const EMERGENCY_RESERVE = 200; // 日中就地进货应急金(策略常量,非游戏数值)
function dayStartPolicy(core) {
core.autoRestock();
for (;;) {
const aff = core.affordableUnlocks();
const st = core.readState();
const pick = aff.find((n) => st.coins - n.cost_coins >= EMERGENCY_RESERVE);
if (!pick) break;
core.unlockNode(pick.id);
}
}
/** 生涯连跑 days 个营业日(勤快席+日始策略);core 需处于 title/日初。返回逐日墙钟。 */
function runCareer(core, days, nowMsBase) {
const perDay = [];
core.start();
for (let d = 0; d < days; d++) {
dayStartPolicy(core);
perDay.push(runDayInPlace(core, diligentDriver));
const st = core.readState();
if (st.phase !== 'day_end') throw new Error(`第 ${st.day} 日未收钟(phase=${st.phase})`);
if (d < days - 1) core.settleAndAdvanceDay({ nowMs: nowMsBase + (d + 1) * 3600_000 });
}
return perDay;
}
/** 终态账本指纹(⑥确定性比对;覆盖生涯全轴)。 */
function ledgerKey(st) {
return JSON.stringify([st.day, st.coins, st.prestige, st.reputation, st.comboBest,
st.served, st.missed, st.revenue, st.tips, st.career, st.unlockedCount, st.menuSize,
st.stockSummary.map((s) => s.units)]);
}
console.log('[night-market logic-smoke] M0 回归 + M1 经济骨架冒烟(600s 营业日快进,dt=0.1)');
// ── ① 勤快席路(M0 回归;生涯账口径)───────────────────────────────────────
const A = runDay(20260706, diligentDriver);
assert(A.st.phase === 'day_end', '① 营业日到点收钟(phase=day_end)', `t=${A.st.timeInDay.toFixed(1)}s`);
assert(A.st.served >= 20, '① 勤快席出餐 ≥20', `served=${A.st.served}`);
assert(A.st.coins > START_COINS && A.st.dayNet > 0, '① 经营净入为正(利润+小费>进货)', `coins=${A.st.coins} dayNet=${A.st.dayNet} 流水=${A.st.revenue} 小费=${A.st.tips}`);
assert(A.st.comboBest >= 3, '① 连击真发生(comboBest≥3)', `comboBest=${A.st.comboBest}`);
assert(A.st.reputation >= 50, '① 勤快经营口碑不塌(≥开局 50)', `rep=${A.st.reputation} missed=${A.st.missed}`);
// latch 不回弹:day_end 后再推 20 帧 phase 恒定、账本不再变(跨日只经 settleAndAdvanceDay)
const coinsAtEnd = A.st.coins;
let bounced = false;
for (let k = 0; k < 20; k++) { A.core.update(DT); if (A.core.readState().phase !== 'day_end') bounced = true; }
assert(!bounced && A.core.readState().coins === coinsAtEnd, '① day_end latch 不回弹、账本封账');
// ── ② 放任席路(M0 回归)────────────────────────────────────────────────────
const B = runDay(999, null);
assert(B.st.phase === 'day_end', '② 放任席营业日仍收钟', `t=${B.st.timeInDay.toFixed(1)}s`);
assert(B.st.served === 0, '② 不点即零出餐', `served=${B.st.served}`);
assert(B.st.missed >= 10, '② 翻台成串(missed≥10)', `missed=${B.st.missed}`);
assert(B.st.reputation === 0, '② 口碑落地板(min=0)', `rep=${B.st.reputation}`);
assert(B.st.coins === START_COINS, '② 零收入(金币恒等初始资金)', `coins=${B.st.coins}`);
// ── ③ 确定性(M0 回归:同 seed 双跑终态一致;driver 含进货决策仍逐位一致)──
const C1 = runDay(4242, diligentDriver);
const C2 = runDay(4242, diligentDriver);
assert(ledgerKey(C1.st) === ledgerKey(C2.st), '③ 同 seed 双跑终态账本一致(确定性)', `coins=${C1.st.coins} served=${C1.st.served}`);
// ── ④ 单局墙钟(U8a 锚;含 core 建局,不含 node 冷启动)─────────────────────
console.log(` INFO ④ 单局快进墙钟:勤快席 ${A.wallMs.toFixed(1)}ms / 放任席 ${B.wallMs.toFixed(1)}ms(600s 营业日,6000 步)`);
// ── ⑤ 跨日串跑 5 营业日(U8a 扩展 = M1 门)─────────────────────────────────
const T0 = 1_755_000_000_000; // 注入墙钟基点(核内零 Date.now;任意固定 epoch ms)
const E = createGameCore({ random: createSeededRandom(20260706) });
const perDay = runCareer(E, 5, T0);
const e5 = E.readState();
assert(e5.day === 5 && e5.phase === 'day_end', '⑤ 五营业日串跑到位(day=5 收钟)', `day=${e5.day}`);
assert(e5.coins > START_COINS, '⑤ 生涯金币正增长', `coins=${e5.coins}`);
assert(e5.prestige >= 4, '⑤ 里程碑真发声望(≥4)', `prestige=${e5.prestige} 生涯=${e5.career.revenue}流水/${e5.career.served}单`);
// 阈值口径:M1 内容量(40 节点)下曾断 ≥6;M3 内容一期(46 节点/19 客型)客流混合变化后,
// 5 日(≈1.25h)解锁 4–5 个恰落设计书 §3 首小时 ~8–10% 消耗带——本断言只证「解锁真发生」,
// 节奏判定归 M3 上界安全门(scripts/scan-calibrate.mjs 判据 1/2)真管,不在冒烟双写。
assert(e5.unlockedCount >= 4, '⑤ 解锁树真消耗(≥4 节点;机制断言,节奏归上界安全门)', `unlocked=${e5.unlockedCount}/${e5.treeSize}`);
assert(e5.menuSize > 8, '⑤ 菜单经解锁扩容(>M0 基础 8 道)', `menu=${e5.menuSize}/${RECIPES.length}`);
const fiveDayTotal = perDay.reduce((s, x) => s + x, 0);
console.log(` INFO ⑤ 跨日墙钟:5 日合计 ${fiveDayTotal.toFixed(1)}ms,均 ${(fiveDayTotal / 5).toFixed(1)}ms/日 → 千日序列外推 ${((fiveDayTotal / 5)).toFixed(0)}s 级/千日`);
// ── ⑥ 存档往返确定性(5 日连跑 vs 3 日→导出→导入→续 2 日)──────────────────
const F1 = createGameCore({ random: createSeededRandom(777) });
runCareer(F1, 5, T0);
const F2a = createGameCore({ random: createSeededRandom(777) });
runCareer(F2a, 3, T0);
F2a.settleAndAdvanceDay({ nowMs: T0 + 3 * 3600_000 }); // 收口第 3 日 → 第 4 日日初检查点
const saved = F2a.exportSave(T0 + 3 * 3600_000);
const F2b = createGameCore({ random: createSeededRandom(1) }); // 故意用不同 seed 建核:导入必须恢复随机游标
const imp = F2b.importSave(saved, { nowMs: T0 + 3 * 3600_000 }); // 同刻导入(elapsed=0,不触发离线)
assert(imp.ok === true && imp.offline.credited === 0, '⑥ 存档导入成功(同刻恢复零离线)', JSON.stringify(imp));
{
// 续跑第 4/5 日(与连跑同一策略同一节奏;连跑侧第 4 日推进墙钟 = T0+4h,续跑侧一致)
F2b.start();
for (let d = 0; d < 2; d++) {
dayStartPolicy(F2b);
runDayInPlace(F2b, diligentDriver);
if (d < 1) F2b.settleAndAdvanceDay({ nowMs: T0 + 4 * 3600_000 });
}
assert(ledgerKey(F1.readState()) === ledgerKey(F2b.readState()),
'⑥ 存档往返确定性:连跑与「存-读-续」终态逐字段一致',
`day=${F2b.readState().day} coins=${F2b.readState().coins}(连跑=${F1.readState().coins})`);
}
// ── ⑦ 离线收益口径(economy_curve 单源对账)─────────────────────────────────
{
const G = createGameCore({ random: createSeededRandom(31415) });
runCareer(G, 2, T0);
// 显式解锁值夜摊灯(根节点,50 币;不赌策略解锁顺序——本段测的是离线口径,不是策略)
const un = G.unlockNode('n_yedeng');
assert(un.ok === true || un.reason === 'already', '⑦ 值夜摊灯可解锁', JSON.stringify(un));
G.settleAndAdvanceDay({ nowMs: T0 }); // 第 2 日收口,lastSeenAt=T0
const st = G.readState();
assert(st.offlineEnabled === true, '⑦ 离线功能启用(offline_enable 生效)', `unlocked=${st.unlockedCount}`);
const env = G.exportSave(T0);
// 期望速率:近 ≤3 日每分净入均值 × 10%(dayLog 在导出信封里,白盒直读对账)
const logs = env.data.dayLog;
const rate = Math.max(0, logs.reduce((s, x) => s + x.net / x.minutes, 0) / logs.length) * ECONOMY_CURVE.offline_yield.rate_of_online;
const capMin = ECONOMY_CURVE.offline_yield.cap_hours * 60;
// 3 小时离线 → 触封顶(cap_hours 曲线单源;M3 定标 = 75 分钟)
const H1 = createGameCore({ random: createSeededRandom(1) });
const r1 = H1.importSave(env, { nowMs: T0 + 3 * 3600_000 });
assert(r1.ok === true && r1.offline.credited === Math.floor(capMin * rate),
`⑦ 3h 离线按封顶 ${capMin} 分钟结算(min(时长,封顶)×速率)`, `credited=${r1.offline.credited} 期望=${Math.floor(capMin * rate)}`);
// 30 分钟离线 → 不封顶
const H2 = createGameCore({ random: createSeededRandom(1) });
const r2 = H2.importSave(env, { nowMs: T0 + 30 * 60_000 });
assert(r2.ok === true && r2.offline.credited === Math.floor(30 * rate),
'⑦ 30min 离线按实长结算', `credited=${r2.offline.credited} 期望=${Math.floor(30 * rate)}`);
}
// ── ⑧ 信封负例(版本纪律:拒读不毁档 / 迁移兜底)────────────────────────────
{
const K = createGameCore({ random: createSeededRandom(1) });
const before = ledgerKey(K.readState());
const rNewer = K.importSave({ schemaVersion: 2, data: { day: 99 } }, { nowMs: T0 });
assert(rNewer.ok === false && rNewer.reason === 'newer_schema' && ledgerKey(K.readState()) === before,
'⑧ 未来版本拒读且核态不毁', JSON.stringify(rNewer));
const rBad = K.importSave({ schemaVersion: 1, data: [1, 2, 3] }, { nowMs: T0 });
assert(rBad.ok === false && rBad.reason === 'bad_envelope' && ledgerKey(K.readState()) === before,
'⑧ 坏信封(data 非纯对象)拒读', JSON.stringify(rBad));
const rV0 = K.importSave({ schemaVersion: 0, data: { coins: 123 } }, { nowMs: 0 });
assert(rV0.ok === true && rV0.migratedFrom === 0 && K.readState().coins === 123 && K.readState().day === 1,
'⑧ 0 版旧档迁移进链(缺字段按缺省兜底)', `coins=${K.readState().coins} day=${K.readState().day}`);
}
// ═══ M2 七段(八系统机制面)═══════════════════════════════════════════════════
import { defaultData } from '../src/core/save-codec.js';
import { STAFF_POOL } from '../src/data/staff.js';
import { PLOTS } from '../src/data/plots.js';
/** 注资建核(白盒:v1 信封注入生涯态,绕过长跑;M1 单测同法)。 */
function richCore(seed, patch) {
const c = createGameCore({ random: createSeededRandom(seed) });
const env = { schemaVersion: 1, data: { ...defaultData(), coins: 5000, prestige: 12, ...patch } };
const r = c.importSave(env, {});
if (!r.ok) throw new Error('richCore 注资失败:' + r.reason);
return c;
}
/** 修饰回声(解释器合成值对账)。 */
const echo = (c) => c._forensicsView().econEcho();
// ── ⑨ 扩张:门户→盘地→设备入槽(容量与修饰全经解释器)────────────────────────
{
const c = richCore(11);
assert(c.buyPlot('p_xiangkou').reason === 'system_locked', '⑨ 门户未解锁拒盘地(system_locked)');
assert(c.unlockNode('n_zao3').ok && c.unlockNode('n_sys_expand').ok, '⑨ 扩张门户可解锁(n_zao3→n_sys_expand)');
const before = echo(c);
const seatsBefore = c.readState().seatSites.length;
const rp = c.buyPlot('p_xiangkou');
assert(rp.ok === true, '⑨ 盘下巷口小摊', JSON.stringify(rp));
const after = echo(c);
assert(after.seatCount === before.seatCount + 2 && after.stoveSlots === before.stoveSlots + 1,
'⑨ 地块容量真入解释器(座+2 灶+1)', `seat ${before.seatCount}→${after.seatCount} stove ${before.stoveSlots}→${after.stoveSlots}`);
assert(c.readState().seatSites.length === seatsBefore + 2, '⑨ 座位坐标池同步扩(driver/渲染同源)');
assert(c.buyPlot('p_xiangkou').reason === 'already', '⑨ 重复盘地拒');
// 设备:修饰真生效 + 槽位约束(巷口 1 槽;一号摊 base 2 槽)
const r1 = c.buyEquipment('e_menghuozao', 'p_xiangkou');
assert(r1.ok === true && echo(c).cookSpeed > 1.079 && echo(c).cookSpeed < 1.081, '⑨ 猛火灶头入槽,cookSpeed=1.08 回声');
const r2 = c.buyEquipment('e_baowentai', 'p_xiangkou');
assert(r2.ok === false && r2.reason === 'no_slot', '⑨ 巷口单槽已满拒(no_slot)');
const r3 = c.buyEquipment('e_baowentai'); // 缺省 = 首个空槽(一号摊)
assert(r3.ok === true && r3.plotId === 'p_yihao', '⑨ 缺省入首个空槽(一号摊)', JSON.stringify(r3));
assert(Math.abs(echo(c).cookSpeed - 1.08 * 1.06) < 1e-9, '⑨ 双设备倍速连乘(1.08×1.06)', `cookSpeed=${echo(c).cookSpeed}`);
}
// ── ⑩ 研发:食材组合下单消耗(系统③)────────────────────────────────────────────
{
// 空库注资:研发前置链(甜品线到蛋花酥)+ 门户
const c = richCore(13, { stock: {} });
for (const id of ['n_r_zhimaqiu', 'n_r_kaohongshu', 'n_r_nuomici', 'n_r_danhuasu', 'n_sys_rnd']) {
assert(c.unlockNode(id).ok === true, `⑩ 前置链解锁 ${id}`);
}
const r0 = c.unlockNode('n_r_guihuashanzha');
assert(r0.ok === false && r0.reason === 'insufficient_ingredients', '⑩ 缺食材拒研发(insufficient_ingredients)');
assert(c.buyIngredient('i_guihua', 3).ok && c.buyIngredient('i_shanzha', 6).ok, '⑩ 按配方补料');
const menuBefore = c.readState().menuSize;
const stockOf = (id) => c.readState().stockSummary.find((s) => s.id === id).units;
const r1 = c.unlockNode('n_r_guihuashanzha');
assert(r1.ok === true, '⑩ 研发解锁成功(桂花山楂饮)');
assert(c.readState().menuSize === menuBefore + 1, '⑩ 菜单真扩容');
assert(stockOf('i_guihua') === 0 && stockOf('i_shanzha') === 0, '⑩ 研发下单真消耗食材(FIFO 扣净)');
}
// ── ⑪ 员工:雇佣/代打/日薪/升级(系统④)─────────────────────────────────────────
{
const c = richCore(17);
assert(c.hireStaff('s_laozhou').reason === 'system_locked', '⑪ 门户未解锁拒雇(system_locked)');
for (const id of ['n_zuo7', 'n_zuo8', 'n_sys_staff']) assert(c.unlockNode(id).ok === true, `⑪ 门户链 ${id}`);
assert(c.hireStaff('s_laozhou').ok === true, '⑪ 雇后厨老周');
assert(Math.abs(echo(c).cookSpeed - 1.10) < 1e-9, '⑪ 后厨倍速 1.10 回声');
assert(c.hireStaff('s_axiang').ok === true && echo(c).autoServeRate > 0.149, '⑪ 雇帮工阿香,auto_serve_rate 回声');
// 帮工整日代打:零 driver 仍出餐(自动化真发生,走同一 tapCustomer 链)
// (注资信封库存为空 → 先补货;帮工严守「不可做不点」,缺料代打不出餐是正确行为)
c.start();
c.autoRestock();
runDayInPlace(c, null);
const stD = c.readState();
assert(stD.served > 0, '⑪ 帮工代打真出餐(零 driver)', `served=${stD.served}`);
assert(c._forensicsView().counts().staffServes > 0, '⑪ staffServes 取证计数真累积');
// 日结扣薪:wage(老周30+阿香24=54,1 级)入 dayNet
const coinsBefore = stD.coins;
const rep = c.settleAndAdvanceDay({ nowMs: 1 });
const wageExpect = STAFF_POOL.find((s) => s.id === 's_laozhou').wage_per_day + STAFF_POOL.find((s) => s.id === 's_axiang').wage_per_day;
assert(rep.wages.due === wageExpect && rep.wages.paid === wageExpect, `⑪ 日薪如实扣(${wageExpect})`, JSON.stringify(rep.wages));
assert(c.readState().coins === coinsBefore - wageExpect, '⑪ 金币账与薪资对平');
// 升级:效果与薪资按曲线走
const up = c.upgradeStaff('s_laozhou');
assert(up.ok === true && up.level === 2, '⑪ 老周升 2 级');
assert(Math.abs(echo(c).cookSpeed - (1 + 0.10 * 1.12)) < 1e-9, '⑪ 升级效果按 ×1.12/级 曲线', `cookSpeed=${echo(c).cookSpeed}`);
}
// ── ⑫ 事件:潮汐窗口修饰对照 + 评论家登场加声望(系统⑤)──────────────────────────
{
const c = richCore(19);
for (const id of ['n_yaohe', 'n_koubei', 'n_sys_event']) assert(c.unlockNode(id).ok === true, `⑫ 门户链 ${id}`);
let milestonePrestige = 0; // 里程碑声望账(评论家 = 第二来源的对账基线;每次日结都记)
const advance = (nowMs) => {
const rep = c.settleAndAdvanceDay({ nowMs });
milestonePrestige += rep.milestones.reduce((s, m) => s + m.prestige, 0);
return rep;
};
c.start(); // day1(奇数):潮汐不触发 → 推进到 day2
c.autoRestock();
runDayInPlace(c, diligentDriver);
advance(1);
// day2:潮汐(2%2==0)必中;窗口 [0.5,0.7]×600s。推到窗口前采样,再推进窗口内采样。
assert(c.readState().day === 2, '⑫ 到第 2 日(潮汐日历日)');
while (c.readState().timeInDay < 290 && c.readState().phase === 'play') c.update(0.1);
const outWin = echo(c);
while (c.readState().timeInDay < 310 && c.readState().phase === 'play') c.update(0.1);
const inWin = echo(c);
assert(outWin.onscreenDelta === 0 && inWin.onscreenDelta === 2, '⑫ 潮汐窗口内 onscreen_delta=2(窗口外 0)',
`out=${outWin.onscreenDelta} in=${inWin.onscreenDelta}`);
assert(inWin.patienceMult < outWin.patienceMult, '⑫ 潮汐耐心收紧(patience_mult 真生效)',
`out=${outWin.patienceMult} in=${inWin.patienceMult}`);
assert(c.readState().activeEvents.some((e) => e.id === 'ev_chaoxi'), '⑫ 活动横幅可读(readState.activeEvents)');
assert(echo(c).eventsSeen >= 1, '⑫ 事件图鉴计数真累积');
// 评论家:第 3 日起 30%/日;固定 seed 有界天数内必登场(确定性;好评 = 声望第二来源)
let criticSeen = 0;
runDayInPlace(c, diligentDriver); // 收完 day2
advance(2);
for (let d = 0; d < 12 && !criticSeen; d++) {
dayStartPolicy(c); // 补货防缺料(策略只花钱不碰声望)
runDayInPlace(c, diligentDriver);
const counts = c._forensicsView().counts();
criticSeen = counts.criticServed + counts.criticMissed;
advance(3 + d);
}
const cnt = c._forensicsView().counts();
assert(criticSeen > 0, '⑫ 评论家有界天数内真登场(seed 19 确定性)', `served=${cnt.criticServed} missed=${cnt.criticMissed}`);
if (cnt.criticServed > 0) {
assert(c.readState().prestige === 12 + milestonePrestige + cnt.criticServed,
'⑫ 评论家好评 = 声望第二来源(注资12+里程碑+评论家逐一对账)',
`prestige=${c.readState().prestige} 里程碑=${milestonePrestige} critic=${cnt.criticServed}`);
}
}
// ── ⑬ 变现位:加速/双倍/失败路/超时兜底(系统⑦ mock)─────────────────────────────
{
const c = richCore(23);
c.start();
c.update(0.1);
// 加速:filled → 倍速回声 → 到期还原 → 冷却拒
assert(c.requestAd('ad_cook_boost').ok === true, '⑬ 营业中请求加速位');
assert(c.resolveAd('ad_cook_boost', 'filled').ok === true, '⑬ filled 发奖');
assert(Math.abs(echo(c).cookSpeed - ECONOMY_CURVE.ad.speedup_mult) < 1e-9, '⑬ 加速 ×1.5 经解释器生效');
for (let i = 0; i < 700; i++) c.update(0.1); // 70s > 60s 时长
assert(Math.abs(echo(c).cookSpeed - 1) < 1e-9, '⑬ 加速到期还原(60s)');
assert(c.requestAd('ad_cook_boost').reason === 'cooldown', '⑬ 冷却中拒(120s)');
// 失败路:不计冷却限次,静默无奖
const c2 = richCore(29);
c2.start(); c2.update(0.1);
assert(c2.requestAd('ad_cook_boost').ok === true && c2.resolveAd('ad_cook_boost', 'failed').ok === true, '⑬ failed 路收场');
assert(Math.abs(echo(c2).cookSpeed - 1) < 1e-9 && c2.requestAd('ad_cook_boost').ok === true, '⑬ 失败无奖不计冷却,可立即重试');
c2.resolveAd('ad_cook_boost', 'nofill');
// 超时兜底:宿主永不回调 → pending_timeout_s 后自动收场
assert(c2.requestAd('ad_cook_boost').ok === true, '⑬ 三请求(nofill 后)');
for (let i = 0; i < 90; i++) c2.update(0.1); // 9s > 8s 兜底
assert(c2.requestAd('ad_cook_boost').ok === true, '⑬ pending 超时自动收场(绝不卡死)');
// 结算双倍:语境校验 + 入账
assert(c2.requestAd('ad_endday_double').reason === 'wrong_phase', '⑬ 营业中拒结算双倍(wrong_phase)');
runDayInPlace(c2, diligentDriver);
const st2 = c2.readState();
assert(st2.phase === 'day_end' && st2.dayNet > 0, '⑬ 勤快日收钟有净入');
assert(c2.requestAd('ad_endday_double').ok === true, '⑬ day_end 请求双倍');
const coinsBefore2 = c2.readState().coins;
const rr = c2.resolveAd('ad_endday_double', 'filled');
assert(rr.ok === true && rr.rewarded.credited === st2.dayNet, '⑬ 双倍入账 = 今日净入等额', JSON.stringify(rr.rewarded));
assert(c2.readState().coins === coinsBefore2 + st2.dayNet && c2.readState().dayNet === st2.dayNet,
'⑬ 变现外快不改 dayNet(离线速率不被广告放大)');
assert(c2.requestAd('ad_endday_double').reason === 'daily_cap', '⑬ 双倍每日 1 次(daily_cap)');
assert(c2.requestAd('iap_decor_reserved').reason === 'slot_disabled', '⑬ 内购预留结构在位、入口不亮');
}
// ── ⑭ 成就图鉴:收集累积/达成领取(⑥进度面)────────────────────────────────────
{
const c = richCore(31);
c.start();
for (let d = 0; d < 2; d++) { dayStartPolicy(c); runDayInPlace(c, diligentDriver); c.settleAndAdvanceDay({ nowMs: d + 1 }); }
const codex = c.codexView();
assert(codex.recipes.filter((r) => r.collected).length >= 5, '⑭ 菜谱图鉴真累积(做过的菜)',
`${codex.recipes.filter((r) => r.collected).length}/${codex.recipes.length}`);
assert(codex.customers.filter((t) => t.collected).length >= 4, '⑭ 顾客图鉴真累积(服务过的客)');
const srv50 = codex.achievements.find((a) => a.id === 'a_srv50');
assert(srv50 && srv50.achieved === true && srv50.claimable === true, '⑭ 五十单成就达成可领', JSON.stringify(srv50));
const coinsB = c.readState().coins;
assert(c.claimAchievement('a_srv50').ok === true && c.readState().coins === coinsB + srv50.reward, '⑭ 领奖入账(只发金币)');
assert(c.claimAchievement('a_srv50').reason === 'already', '⑭ 重复领拒');
// a_dikuai2(地块≥2)在本段必不可达:扩张门户未解锁,盘不了地 → 确定性负例
assert(c.claimAchievement('a_dikuai2').reason === 'not_achieved', '⑭ 未达领拒(not_achieved)');
assert(c.claimAchievement('a_bushi').reason === 'not_found', '⑭ 未知成就拒');
}
// ── ⑮ M2 存档往返:四系统态确定性 + 旧档兜底──────────────────────────────────────
{
/** M2 日始策略:M1 策略 + 雇人/盘地/买设备(表序确定性;四系统态全用起来)。 */
function m2Policy(core) {
dayStartPolicy(core);
for (const s of STAFF_POOL) core.hireStaff(s.id); // 拒绝无害(锁/钱不够/已雇)
for (const p of PLOTS) core.buyPlot(p.id);
core.buyEquipment('e_menghuozao');
core.buyEquipment('e_shoukuanling');
for (const a of core.codexView().achievements) if (a.claimable) core.claimAchievement(a.id);
}
/** M2 账本指纹 = M1 ledgerKey + 四系统轴。 */
function m2Key(core) {
const st = core.readState();
const e = echo(core);
return ledgerKey(st) + '|' + JSON.stringify([st.plotsOwned, st.staffHired, st.collections,
st.claimableAchievements, e.cookSpeed, e.autoServeRate, e.eventsSeen, e.seatCount, e.stoveSlots]);
}
const seedHead = { unlocked: ['n_zao3', 'n_zuo7', 'n_zuo8', 'n_yaohe', 'n_koubei', 'n_r_zhimaqiu', 'n_r_kaohongshu', 'n_r_nuomici', 'n_r_danhuasu', 'n_sys_expand', 'n_sys_rnd', 'n_sys_staff', 'n_sys_event'] };
const G1 = richCore(777, seedHead);
G1.start();
for (let d = 0; d < 4; d++) {
m2Policy(G1); runDayInPlace(G1, diligentDriver);
if (d < 3) G1.settleAndAdvanceDay({ nowMs: T0 + (d + 1) * 3600_000 });
}
const G2a = richCore(777, seedHead);
G2a.start();
for (let d = 0; d < 2; d++) {
m2Policy(G2a); runDayInPlace(G2a, diligentDriver);
G2a.settleAndAdvanceDay({ nowMs: T0 + (d + 1) * 3600_000 });
}
const savedM2 = G2a.exportSave(T0 + 2 * 3600_000);
const G2b = createGameCore({ random: createSeededRandom(1) });
const impM2 = G2b.importSave(savedM2, { nowMs: T0 + 2 * 3600_000 });
assert(impM2.ok === true, '⑮ M2 存档导入成功', JSON.stringify(impM2.offline));
G2b.start();
for (let d = 0; d < 2; d++) {
m2Policy(G2b); runDayInPlace(G2b, diligentDriver);
if (d < 1) G2b.settleAndAdvanceDay({ nowMs: T0 + 3 * 3600_000 });
}
assert(m2Key(G1) === m2Key(G2b), '⑮ M2 四系统态存-读-续与连跑逐字段一致(事件签靠游标重放)',
`day=${G2b.readState().day} plots=${G2b.readState().plotsOwned} staff=${G2b.readState().staffHired}`);
// M1 v1 旧档(无 M2 字段)导入:缺省兜底,四系统态归零可玩
const K2 = createGameCore({ random: createSeededRandom(1) });
const old = { schemaVersion: 1, data: { day: 6, coins: 888, prestige: 3, reputation: 60, milestonesHit: ['m_rev_1'], unlocked: ['n_zuo7'], stock: {}, dayLog: [], careerRevenue: 500, careerServed: 60, careerMissed: 5, careerTips: 40, comboBest: 4, rngCursor: 42, lastSeenAtMs: 0 } };
const rOld = K2.importSave(old, { nowMs: T0 });
assert(rOld.ok === true && K2.readState().plotsOwned === 1 && K2.readState().staffHired === 0
&& K2.readState().collections.recipes.collected === 0,
'⑮ M1 旧档导入 M2 核:additive 缺省兜底(基础摊 1/员工 0/图鉴 0)', `day=${K2.readState().day}`);
}
// ── 报告落盘(dist 不入库;U8a 墙钟锚 + M1 生涯采样)───────────────────────────
{
const HERE = dirname(fileURLToPath(import.meta.url));
const DIST = join(HERE, '..', 'dist');
mkdirSync(DIST, { recursive: true });
writeFileSync(join(DIST, 'm1-sim-report.json'), JSON.stringify({
measuredAt: 'M1-U8a-extended',
dtSeconds: DT, dayLengthS: ECONOMY_CURVE.day.length_s,
singleDayWallMs: { diligent: +A.wallMs.toFixed(1), idle: +B.wallMs.toFixed(1) },
fiveDaySerial: { totalMs: +fiveDayTotal.toFixed(1), perDayMs: perDay.map((x) => +x.toFixed(1)) },
careerAtDay5: {
coins: e5.coins, prestige: e5.prestige, unlocked: e5.unlockedCount, menu: e5.menuSize,
careerRevenue: e5.career.revenue, careerServed: e5.career.served,
},
note: '树 40 节点为 140 骨架子集;当前成本/里程碑锚下的消耗节奏如实采样,40 日进度曲线定标归 U9(非 M1 门)。',
}, null, 2));
console.log(' INFO 报告已落 dist/m1-sim-report.json');
}
// ── 汇总 ──
if (failures === 0) {
console.log('[night-market logic-smoke] 全过:M0 四段回归 + M1 四段(跨日/存档/离线/信封)+ M2 七段(扩张/研发/员工/事件/变现位/成就图鉴/M2 存档往返)。');
process.exit(0);
} else {
console.error(`[night-market logic-smoke] ${failures} 项 FAIL。`);
process.exit(1);
}