内容四轴灌装到量表(菜谱 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>
509 lines
32 KiB
JavaScript
509 lines
32 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|