feat(polaris): 《夜市一条街》M1——三资源经济骨架+解锁树40节点+SaveEnvelope存档+九门接线件

三资源流转(金币生涯账/声望=12里程碑唯一来源不可购买/食材批次FIFO进货-库存-保鲜环,缺料no_stock前置拒);
解锁树40节点(16菜谱+灶台+座位+经营被动+离线根节点+4个M2系统前瞻honest登记,效果解释器单点施效);
save_state v1=SaveEnvelope契约形状+日初检查点口径防存档刷币+未来版本拒读不毁档+0版迁移进链,影子介质IndexedDB(M5切正式save:槽零迁移);
离线收益=近3日每分净入均值×10%封顶2h(economy_curve单源);
九门接线件全套:business-serve driver+runDriver分发+spec.urlQuery门参数化+play-spec+宿主探针钩(CDP真跑排mini-desktop窗口)。
证据:静态可达门8/8(DAG拓扑/菜谱双射/定价cost=Σ食材对账)、冒烟29断言(M0四段回归+5日串跑23.1ms/日+存档往返确定性:5日连跑vs3日存-读-续终态逐字段一致+离线对账+三负例全拒)、单测15/15;主会话真跑复验。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-06 11:10:36 -07:00
parent 6dadc70173
commit 3b64fa3834
21 changed files with 1770 additions and 192 deletions

View File

@ -1,35 +1,67 @@
# 《夜市一条街》——北极星顶级线①(模拟经营)
创始人 2026-07-06 批产的三款顶级长线游戏之一。规格单源 = [开发设计书](../../../docs/agent-specs/2026-07-06-北极星顶级线-夜市一条街-开发设计书.md)(底稿 = 复杂游戏北极星件 §1);本目录是它的开发工程,当前进度 **M0(U0 单)**。
创始人 2026-07-06 批产的三款顶级长线游戏之一。规格单源 = [开发设计书](../../../docs/agent-specs/2026-07-06-北极星顶级线-夜市一条街-开发设计书.md)(底稿 = 复杂游戏北极星件 §1);本目录是它的开发工程,当前进度 **M1(经济骨架+存档)**。
## 跑起来
```bash
# 纯逻辑冒烟(node 直跑,无浏览器;M0 门证据件)
# 纯逻辑冒烟(node 直跑,无浏览器;M0 回归四段 + M1 四段,29 断言)
node scripts/logic-smoke.mjs
# 解锁树静态可达门(M1 门:无死环/依赖可达/三表对账,八查)
node scripts/tree-check.mjs
# 单测(经济骨架边界 + 九门 driver 接线,15 用例)
node --test test/m1-core.test.mjs test/driver-wiring.test.mjs
# 构建(esbuild iife,Phaser 经垫片走宿主预载不进 bundle)+ 两本包体账实测
node scripts/build.mjs
# 本机手玩(构建后;file:// 下 CDN 可用,严格环境用静态服)
python3 -m http.server 8080 # 然后开 http://localhost:8080/index.html
# URL 参数:?seed=整数 ?day=秒(短营业日) ?tier=high|mid|low ?reset=1(清存档)
```
九门 CDP 真跑(mini-desktop,本机禁 chrome;spec 见 `play-spec.json` 头注):
```bash
# 仓根起静态服:python3 -m http.server 4320
cd game-runtime && node ../tier2/harness/play-phaser.cdp.cjs night-market \
--base=http://localhost:4320/tier2 \
--spec=../tier2/games/night-market/play-spec.json \
--out=../tier2/games/night-market/evidence
```
## 工程形态(设计书 §8)
- `src/core/` 纯逻辑内核:营业日时钟、经济结算(金币/口碑/连击)、latch 终态、readState/_forensicsView 契约、受控随机、逻辑坐标单源(layout)。**零 DOM/canvas/引擎,node 可直跑**——快进仿真器(U8a/U8b)的生存前提。
- `src/systems/` 一文件一系统,不 import core、经事件与 core 通信(M0 只有系统①服务循环)。
- `src/data/` 表驱动零魔法数(M0 以 JS 模块承载,U1 批转 JSON+schema 校验)。
- `src/scenes/``src/ui/` Phaser 表现层:只读 readState、只发 tapAt 命令;M0 全程序化占位美术(零二进制资产),风格换装归 M4。
- `src/core/` 纯逻辑内核:营业日时钟、三资源经济(金币/声望/食材)、解锁树效果解释器、
存档编解码(save-codec,SaveEnvelope 契约形状)、latch 终态、readState/_forensicsView 契约、
受控随机(带游标导出/恢复)、逻辑坐标单源(layout)。**零 DOM/canvas/引擎,node 可直跑**。
- `src/systems/` 一文件一系统,不 import core、经事件与 core 通信(系统①服务循环;菜单/容量/修饰全走函数注入动态化)。
- `src/data/` 表驱动零魔法数:economy_curve / 菜谱 24(基础 8+解锁 16,带配方)/ 食材 12 / 解锁树 40 节点 / 里程碑 12(M1 以 JS 模块承载,U1 批转 JSON+schema 校验)。
- `src/scenes/``src/ui/` Phaser 表现层:只读 readState、只发 tapAt/日结面板三命令(补货/解锁/开市);M1 仍程序化占位美术,风格换装归 M4。
- `src/host-shadow-store.js` 影子存档(IndexedDB 直写;**开发期影子口径**,M5 切正式 SDK `save:` 槽零迁移——信封同形状)。
- `play-spec.json` + `tier2/harness/business-serve.driver.js` 九门经营 driven 接线(与 business-sim/card-battle 并列的品类 driver;富三门经营-serve 语义归 M2,M1 走「门待建」占位不套 merge 语义)。
## M0 门状态(如实)
## 存档口径(M1)
导出快照 = **日初检查点**(含受控随机游标):日中退出回当日开局重打,不存日中瞬态——防"存档-重打"刷币,
且同 seed 恢复后随机流逐位续接(冒烟 ⑥ 以「5 日连跑 vs 3 日存-读-续 2 日终态逐字段一致」实证)。
信封 = `{schemaVersion:1, data}`(contracts/sdk-interface.d.ts SaveEnvelope);未来版本拒读不毁档,0 版旧档迁移进链。
## M0/M1 门状态(如实)
| 门 | 状态 |
|---|---|
| headless 双路冒烟(勤快/放任 + latch + 确定性) | ✅ 全绿(scripts/logic-smoke.mjs) |
| 构建 + bundle 顶层求值/工厂在位 | ✅(esbuild + vm 浏览器语义 sanity) |
| 两本包体账实测 | ✅ 落 `dist/m0-bytes.json`(账一 assets=0;账二 bundle 28.9KB,引擎 CDN 体积〔来源缺口〕待联网实测) |
| 九门 driven(经营 driver) | ⏳ 待接线(独立工程接 tier2 门线 = M0 收口批余项) |
| 首反馈 <2s / 中端机 60fps | 待浏览器真跑窗口(本机/mini-desktop) |
| M0 headless 双路冒烟(勤快/放任 + latch + 确定性) | ✅ 回归绿(生涯账口径重述,scripts/logic-smoke.mjs ①–④) |
| M1 跨日串跑(U8a 扩展:5 营业日 + 墙钟) | ✅ 全绿(冒烟 ⑤;报告落 dist/m1-sim-report.json) |
| M1 静态可达门(树无死环/依赖可达/三表对账) | ✅ 全绿(scripts/tree-check.mjs 八查) |
| M1 存档往返 + 离线口径 + 信封负例 | ✅ 全绿(冒烟 ⑥⑦⑧ + test/m1-core 10 用例) |
| 九门经营 driver 接线(本机可验部分) | ✅ 全绿(driver 决策/runDriver 分发/spec 契约,test/driver-wiring 5 用例;harness node --check 过) |
| 构建 + bundle 顶层求值/工厂在位 | ✅(esbuild + vm 浏览器语义 sanity;bundle 76.7KB) |
| 两本包体账实测 | ✅ 落 `dist/m0-bytes.json`(账一 assets=0;引擎 CDN 体积〔来源缺口〕待联网实测) |
| **九门 CDP 真跑(driven 全绿)** | ⏳ 排 mini-desktop 窗口(spec/driver/宿主探针钩全部就位,含 45s 短日+固定 seed 门参数化) |
| 首反馈 <2s / 中端机 60fps | 同上窗口 |
单营业日(600s)纯逻辑快进实测 **~12ms**(6000 步,node),千日序列外推十秒级——U8a 墙钟锚的第一个数据点。
单营业日(600s)纯逻辑快进实测 ~31ms(勤快席,6000 步,node;M0 12ms 为放任近似,勤快含出招与进货决策)
→ 5 日串跑 ~116ms,千日序列外推 ~23s 级——U8a 墙钟锚第二批数据点。

View File

@ -1,30 +1,77 @@
<!DOCTYPE html>
<!--
index.html —— 《夜市一条街》本机手玩页(M0 占位;正式装载走平台 GamePackage 通道)
index.html —— 《夜市一条街》本机手玩页 + 九门探针宿主(M1;正式装载走平台 GamePackage 通道)
用法:先构建(node scripts/build.mjs),再起静态服(python3 -m http.server)打开本页。
Phaser 经 CDN 预载 = 两本账账二「引擎冷载」项;pin 3.80.1 与 tier2/harness 同源。
【九门探针钩(play-phaser.cdp.cjs 契约,A6)】本页承担宿主侧全部信号:
__tier2Booted/__tier2BootError(A_boot)· __tier2Frame/__phaserGame(C_frame)·
__gameState(语义 state)· __engineCalls(F_wiring 命令取证)· __tier2Internals.uncaught(B_uncaught)。
【URL 参数(门与测试参数化)】?seed=整数(受控种子,缺省 20260706)· ?day=秒(短营业日,CDP 窗口用)
· ?tier=high|mid|low · ?reset=1(清影子存档开新局)。
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>夜市一条街 · M0</title>
<title>夜市一条街 · M1</title>
<style>
html, body { margin: 0; padding: 0; background: #05070f; height: 100%; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script>
// 未捕获错误收集(B 门;须在一切脚本之前挂,引擎装载失败也要抓到)
window.__tier2Internals = { uncaught: [] };
window.addEventListener('error', (e) => {
if (window.__tier2Internals.uncaught.length < 10) window.__tier2Internals.uncaught.push(String(e.message || e.error || 'error'));
});
window.addEventListener('unhandledrejection', (e) => {
if (window.__tier2Internals.uncaught.length < 10) window.__tier2Internals.uncaught.push('unhandledrejection: ' + String(e.reason));
});
window.__engineCalls = []; // F 门命令取证(工厂 callTrace 供给)
</script>
</head>
<body>
<div id="game"></div>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<script src="dist/bundle.iife.js"></script>
<script>
// 工厂建局 + 取证视图挂 window(harness 探针惯例:_forensicsView 宿主侧挂载)
const inst = window.__NightMarketBundle.default
? window.__NightMarketBundle.default({ parent: 'game' })
: window.__NightMarketBundle({ parent: 'game' });
window._forensicsView = inst._forensicsView;
window.__nightMarket = inst;
(async function boot() {
try {
const B = window.__NightMarketBundle;
const factory = B.default || B;
const q = new URLSearchParams(location.search);
// 影子存档(IndexedDB 直写;?reset=1 清档开新局)
const store = B.createShadowStore();
if (q.get('reset') === '1') await store.clear();
const envelope = await store.get();
const inst = factory({
parent: 'game',
seed: q.has('seed') ? Number(q.get('seed')) : undefined,
deviceTier: q.get('tier') || undefined,
dayLengthS: q.has('day') ? Number(q.get('day')) : undefined,
resume: envelope ? { envelope, nowMs: Date.now() } : undefined,
persist: (env) => { store.put(env); },
callTrace: (id) => { if (window.__engineCalls.length < 4096) window.__engineCalls.push(id); },
onReady: () => { window.__tier2Booted = true; },
});
// 探针钩(harness 惯例:宿主侧挂载,工厂只透出)
window.__nightMarket = inst;
window._forensicsView = inst._forensicsView;
window.__gameState = () => inst.readState();
window.__phaserGame = inst.game;
window.__tier2Frame = () => inst.game.loop.frame;
// 离场兜底回写(影子口径 best-effort;正式通道语义归 M5)
window.addEventListener('pagehide', () => { try { store.put(inst.exportSave(Date.now())); } catch (_) {} });
} catch (e) {
window.__tier2BootError = String((e && e.message) || e);
window.__tier2Internals.uncaught.push('boot: ' + window.__tier2BootError);
console.error('[night-market] 装载失败', e);
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,26 @@
{
"_comment": "《夜市一条街》M1 九门经营 driven 接线。driver 形状 = business-serve(tapTargets 服务循环,自带模块经 driver.modulePath 在 spec 目录内解析);富三门按 harness 品类占位「门待建」走(economy/tripleLink 的经营-serve 语义归 M2 定义,不套 mini-肥鹅 merge 语义防假绿——M1 验收口径 = 九门全绿,与设计书 §9 M1『九门回归』一致、富三门 M2 才入门)。真跑必须在 mini-desktop(6c6g 禁 chrome):cd game-runtime && node ../tier2/harness/play-phaser.cdp.cjs night-market --base=http://localhost:4320/tier2 --spec=../tier2/games/night-market/play-spec.json --out=../tier2/games/night-market/evidence(静态服在仓根起:python3 -m http.server 4320)",
"taskId": "polaris-night-market-m1",
"traceId": "polaris-night-market-m1-gates",
"round": 0,
"structureOk": true,
"_structureOkNote": "结构层证据 = 手作工程 scripts/build.mjs 构建绿 + bundle vm 顶层求值/工厂形状过(harness 只判可运行层,结构层经 spec 透传,同 mini-肥鹅先例)",
"urlQuery": "seed=20260706&day=45&tier=mid&reset=1",
"_urlQueryNote": "门参数化:固定 seed(G 门同 seed 对照的确定性前提)+ 45s 短营业日(600s 真日超 CDP 窗口,day_end latch 要在窗口内可达)+ reset 清影子存档(可复现);数值缺省仍 economy_curve 单源,此处只是注入口",
"driver": {
"type": "business-serve",
"steps": 130,
"stepMs": 400,
"terminalPhases": ["day_end"]
},
"_driverNote": "driver 实现 = tier2/harness/business-serve.driver.js(runDriver 按 type 分发,与 business-sim/card-battle 并列的官方配方)",
"expectLatch": true,
"latchTerminals": ["day_end"],
"assertAfterPlay": [
{ "path": "coins", "op": "increased", "why": "出餐结算真入账(服务循环正循环)" },
{ "path": "served", "op": "increased", "why": "真输入驱动出餐(非空转)" },
{ "path": "phase", "op": "in", "value": ["play", "day_end"], "why": "真玩中或已收钟,非卡死" }
],
"expectedEngineCallPrefixes": ["core.tapAt"],
"_controlCheckNote": "经营档离散点击、终态忽略输入,I_control 缺省 SKIP(harness 文档口径,同 mini-肥鹅 spec 先例)"
}

View File

@ -1,13 +1,20 @@
/**
* scripts/logic-smoke.mjs 夜市一条街纯逻辑冒烟(node 直跑 · 无浏览器 · M0 门证据件)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §9 M0 / §10 U0
* scripts/logic-smoke.mjs 夜市一条街纯逻辑冒烟(node 直跑 · 无浏览器 · M0 回归 + M1 门证据件)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §9 M0/M1 · §10 U0/U8a
*
* 验什么(对齐 mini-fei-e logic-smoke 形态:双路径 + latch + 确定性 + 墙钟)
* 勤快席路:每帧点耐心最紧的等待顾客(有空灶才点) 出餐 20金币正增长连击 3
* 口碑不塌营业日到点 phase='day_end' latch 不回弹
* 放任席路:全程不点 出餐 0翻台成串口碑落地板日终仍 latch零崩溃
* M0 回归四段(M1 生涯账本口径下逐条重述,不变量不放水:
* M0coins 0 M1 生涯账自 starting_coins ,零收入断言 = coins 恒等初始资金;
* 勤快席新增缺料就地进货= 三资源食材环的真实玩法,非放水不进货放着必翻台)
* 勤快席路:出餐 20净入为正连击 3口碑不塌day_end latch 不回弹且账本封账
* 放任席路:零出餐翻台成串口碑落地板金币恒等初始资金日终仍 latch零崩溃
* 确定性: seed 双跑终态账本逐字段一致(仿真器可复现前提,底稿 §2.C)
* 单局墙钟:整营业日快进(dt=0.1×6000 ) node 实测毫秒U8a 墙钟锚的第一个数据点
* 单局墙钟:整营业日快进 node 实测毫秒(U8a )
* M1 四段
* 跨日串跑 5 营业日(U8a 扩展):日始策略 = 解锁够得着最便宜直到买不起 + 自动补货;
* 断言生涯推进(金币/声望/解锁/菜单扩容)+ 墙钟落 dist/m1-sim-report.json
* 存档往返确定性:5 日连跑 vs 3 导出新核导入续跑 2 ,终态账本逐字段一致
* 离线收益口径:速率 = 3 日每分净入均值×10%,封顶 2h(economy_curve 单源对账)
* 信封负例:未来版本拒读不毁档 / 坏信封拒读 / 0 版旧档迁移补缺省
*
* 用法:node scripts/logic-smoke.mjs (退出码 0=全过)
*/
@ -15,8 +22,13 @@
'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) {
@ -26,64 +38,217 @@ function assert(cond, name, detail) {
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: (core, state)=>void 每帧出招;返回终态与墙钟。 */
function runDay(seed, driver) {
const core = createGameCore({ random: createSeededRandom(seed) });
core.start();
/** 勤快席 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 队列(渲染缺席时的取走方)
}
const wallMs = performance.now() - t0;
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 };
}
/** 勤快席 driver:有空灶就点「耐心最紧」的等待顾客(真玩家的优先级判断)。 */
function diligentDriver(core, st) {
if (!st.tapTargets.length) return;
if (!st.stoves.some((s) => !s.busy)) return; // 无空灶不空点
const target = [...st.tapTargets].sort((a, b) => a.patienceLeft - b.patienceLeft)[0];
core.tapCustomer(target.customerId);
/** (,):,便
* (首版曾先解锁后补货 金币花光断供 后续日零出餐死锁;经营顺序修正为补货优先) */
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);
}
}
console.log('[night-market logic-smoke] M0 纯逻辑冒烟(600s 营业日快进,dt=0.1)');
/** 生涯连跑 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 > 0, '① 金币正增长(利润+小费入账)', `coins=${A.st.coins} 流水=${A.st.revenue} 小费=${A.st.tips}`);
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 恒定、账本不再变
// 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 === 0, '② 零收入(无出餐即无入账)', `coins=${B.st.coins}`);
assert(B.st.coins === START_COINS, '② 零收入(金币恒等初始资金)', `coins=${B.st.coins}`);
// ── ③ 确定性(同 seed 双跑终态一致)────────────────────────────────────────
// ── ③ 确定性(M0 回归:同 seed 双跑终态一致;driver 含进货决策仍逐位一致)──
const C1 = runDay(4242, diligentDriver);
const C2 = runDay(4242, diligentDriver);
const key = (s) => JSON.stringify([s.coins, s.served, s.missed, s.reputation, s.comboBest, s.revenue, s.tips]);
assert(key(C1.st) === key(C2.st), '③ 同 seed 双跑终态账本一致(确定性)', key(C1.st));
assert(ledgerKey(C1.st) === ledgerKey(C2.st), '③ 同 seed 双跑终态账本一致(确定性)', `coins=${C1.st.coins} served=${C1.st.served}`);
// ── ④ 单局墙钟(U8a 锚第一个数据点;含 core 建局,不含 node 冷启动)────────
// ── ④ 单局墙钟(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}`);
assert(e5.unlockedCount >= 6, '⑤ 解锁树真消耗(≥6 节点;速率定标归 U9,此断机制非节奏)', `unlocked=${e5.unlockedCount}/40`);
assert(e5.menuSize > 8, '⑤ 菜单经解锁扩容(>M0 基础 8 道)', `menu=${e5.menuSize}/24`);
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 小时离线 → 封顶 2h
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 离线按封顶 2h 结算(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}`);
}
// ── 报告落盘(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] 全过:服务循环双路径可达 + day_end latch + 确定性 + 墙钟实测。');
console.log('[night-market logic-smoke] 全过:M0 四段回归 + 跨日串跑 + 存档往返 + 离线口径 + 信封负例。');
process.exit(0);
} else {
console.error(`[night-market logic-smoke] ${failures} 项 FAIL。`);

View File

@ -0,0 +1,141 @@
/**
* scripts/tree-check.mjs 解锁树静态可达门(M1 验收门之一;node 直跑可重复,退出码 0=全过)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §9 M1静态可达门(树无死环/依赖可达)+ §4 前置静态门
*
* 八查(结构判定,纯静态;真玩平衡归动态门,质量 SoT §8 处置矩阵,不混)
* 依赖存在:每个 deps 指向真实节点
* DAG 无环:拓扑排序可完成(死环 = 永不可达)
* 全体可达:从根(deps=[])沿依赖可达每个节点
* 菜谱对账:unlock_recipe 目标存在于菜谱表恰覆盖全部 base:false (无孤儿菜/无重复解锁)
* 声望可达:各节点 min_prestige 里程碑可获声望总额(MAX_PRESTIGE)
* 字段完备:kind 合法cost_coins 非负数system 节点必带 arrivesAt
* 配方引用:菜谱 ingredients 引用的食材皆在食材表
* 定价对账:菜谱 cost = Σ(食材单价×qty) |cost price×(1margin)| 1(利润率纪律)
*
* 用法:node scripts/tree-check.mjs
*/
'use strict';
import { UNLOCK_TREE, NODE_KINDS } from '../src/data/unlock-tree.js';
import { RECIPES } from '../src/data/recipes.js';
import { INGREDIENTS } from '../src/data/ingredients.js';
import { MILESTONES, MAX_PRESTIGE } from '../src/data/milestones.js';
import { ECONOMY_CURVE } from '../src/data/economy-curve.js';
let failures = 0;
function check(cond, name, detail) {
if (cond) console.log(` PASS ${name}${detail ? ` (${detail})` : ''}`);
else { console.error(` FAIL ${name}${detail ? `${detail}` : ''}`); failures++; }
}
console.log(`[night-market tree-check] 解锁树静态可达门(${UNLOCK_TREE.length} 节点 / ${RECIPES.length} 菜谱 / ${INGREDIENTS.length} 食材 / 里程碑声望上界 ${MAX_PRESTIGE})`);
const byId = new Map(UNLOCK_TREE.map((n) => [n.id, n]));
// ① 依赖存在
{
const bad = [];
for (const n of UNLOCK_TREE) for (const d of n.deps) if (!byId.has(d)) bad.push(`${n.id}${d}`);
check(bad.length === 0, '① 依赖全部指向真实节点', bad.join(' ') || `${UNLOCK_TREE.length} 节点`);
}
// ② DAG 无环(Kahn 拓扑)
{
const indeg = new Map(UNLOCK_TREE.map((n) => [n.id, n.deps.length]));
const out = new Map(UNLOCK_TREE.map((n) => [n.id, []]));
for (const n of UNLOCK_TREE) for (const d of n.deps) if (out.has(d)) out.get(d).push(n.id);
const q = UNLOCK_TREE.filter((n) => n.deps.length === 0).map((n) => n.id);
let seen = 0;
while (q.length) {
const id = q.shift(); seen++;
for (const nx of out.get(id)) { indeg.set(nx, indeg.get(nx) - 1); if (indeg.get(nx) === 0) q.push(nx); }
}
check(seen === UNLOCK_TREE.length, '② DAG 无环(拓扑可完成)', `topo=${seen}/${UNLOCK_TREE.length}`);
}
// ③ 全体可达(②过 = 全可达,独立再证一遍:从根沿边扩散)
{
const reach = new Set(UNLOCK_TREE.filter((n) => n.deps.length === 0).map((n) => n.id));
let grew = true;
while (grew) {
grew = false;
for (const n of UNLOCK_TREE) {
if (!reach.has(n.id) && n.deps.every((d) => reach.has(d))) { reach.add(n.id); grew = true; }
}
}
const unreachable = UNLOCK_TREE.filter((n) => !reach.has(n.id)).map((n) => n.id);
check(unreachable.length === 0, '③ 全体节点自根可达', unreachable.join(' ') || `根=${UNLOCK_TREE.filter((n) => !n.deps.length).length}`);
}
// ④ 菜谱对账:unlock_recipe ↔ base:false 双射
{
const recipeIds = new Set(RECIPES.map((r) => r.id));
const lockedIds = new Set(RECIPES.filter((r) => !r.base).map((r) => r.id));
const targets = UNLOCK_TREE.filter((n) => n.kind === 'unlock_recipe').map((n) => n.target);
const badTarget = targets.filter((t) => !recipeIds.has(t));
const dup = targets.filter((t, i) => targets.indexOf(t) !== i);
const orphan = [...lockedIds].filter((id) => !targets.includes(id));
const unlockBase = targets.filter((t) => recipeIds.has(t) && !lockedIds.has(t));
check(badTarget.length === 0 && dup.length === 0 && orphan.length === 0 && unlockBase.length === 0,
'④ unlock_recipe ↔ 非基础菜 恰一一对应',
`targets=${targets.length} locked=${lockedIds.size}${badTarget.length ? ' 假目标:' + badTarget : ''}${orphan.length ? ' 孤儿菜:' + orphan : ''}${dup.length ? ' 重复:' + dup : ''}${unlockBase.length ? ' 解锁基础菜:' + unlockBase : ''}`);
}
// ⑤ 声望可达
{
const over = UNLOCK_TREE.filter((n) => n.min_prestige > MAX_PRESTIGE).map((n) => `${n.id}(${n.min_prestige})`);
check(over.length === 0, `⑤ 声望门全部 ≤ 里程碑上界(${MAX_PRESTIGE})`, over.join(' ') || `最高门=${Math.max(...UNLOCK_TREE.map((n) => n.min_prestige))}`);
}
// ⑥ 字段完备
{
const bad = [];
for (const n of UNLOCK_TREE) {
if (!NODE_KINDS.includes(n.kind)) bad.push(`${n.id}:kind=${n.kind}`);
if (typeof n.cost_coins !== 'number' || n.cost_coins < 0) bad.push(`${n.id}:cost`);
if (typeof n.min_prestige !== 'number' || n.min_prestige < 0) bad.push(`${n.id}:prestige`);
if (n.kind === 'system' && !n.arrivesAt) bad.push(`${n.id}:system缺arrivesAt`);
if (n.kind === 'unlock_recipe' && !n.target) bad.push(`${n.id}:缺target`);
}
check(bad.length === 0, '⑥ 字段完备(kind/cost/prestige/arrivesAt/target)', bad.join(' ') || 'ok');
}
// ⑦ 配方引用:菜谱食材皆在表
{
const ingIds = new Set(INGREDIENTS.map((r) => r.id));
const bad = [];
for (const r of RECIPES) {
if (!Array.isArray(r.ingredients) || r.ingredients.length === 0) { bad.push(`${r.id}:无配方`); continue; }
for (const ing of r.ingredients) if (!ingIds.has(ing.id)) bad.push(`${r.id}${ing.id}`);
}
check(bad.length === 0, '⑦ 菜谱配方食材引用全部可解', bad.join(' ') || `${RECIPES.length} 道全带配方`);
}
// ⑧ 定价对账:cost=Σ食材 且 利润率带内
{
const priceOf = new Map(INGREDIENTS.map((r) => [r.id, r.price]));
const margins = ECONOMY_CURVE.recipe_profit.margin_by_rarity;
const bad = [];
for (const r of RECIPES) {
const sum = (r.ingredients || []).reduce((s, ing) => s + (priceOf.get(ing.id) || 0) * ing.qty, 0);
if (sum !== r.cost) bad.push(`${r.id}:Σ食材=${sum}≠cost=${r.cost}`);
const expect = r.price * (1 - margins[r.rarity]);
if (Math.abs(r.cost - expect) > 1) bad.push(`${r.id}:cost=${r.cost} 离 price×(1margin)=${expect.toFixed(1)} 超1`);
}
check(bad.length === 0, '⑧ 定价对账(cost=Σ食材 ∧ 利润率带内)', bad.join(' ') || '24 道全对');
}
// 附:里程碑轴合法性(声望唯一来源的数据面自检)
{
const bad = MILESTONES.filter((m) => !['revenue', 'served'].includes(m.axis) || m.threshold <= 0 || m.prestige <= 0);
check(bad.length === 0, '附 里程碑表字段合法', bad.map((m) => m.id).join(' ') || `${MILESTONES.length}`);
}
if (failures === 0) {
console.log('[night-market tree-check] 全过:解锁树无死环、依赖可达、菜谱/食材/定价三表对账一致。');
process.exit(0);
} else {
console.error(`[night-market tree-check] ${failures} 项 FAIL。`);
process.exit(1);
}

View File

@ -1,22 +1,26 @@
/**
* src/core/game-core.js 夜市一条街纯逻辑内核(营业日时钟 + 经济结算 + 连击口碑 + latch + readState 契约)
* owner:北极星顶级线①(W-POLARIS)· 开发设计书 §8 工程结构 · M0(U0 )
* src/core/game-core.js 夜市一条街纯逻辑内核(营业日时钟 + 三资源经济 + 解锁树 + 存档 + latch + readState 契约)
* owner:北极星顶级线①(W-POLARIS)· 开发设计书 §8 工程结构 · M0(U0)+ M1(经济骨架+存档)
*
*
* 职责编排服务循环系统 + 持有账本(金币/口碑/连击)+ 营业日时钟 + 终态 latch +
* 可观测契约(readState/_forensicsView)渲染层(scenes/)只读本核心只发命令
* 职责编排服务循环系统 + 持有三资源账本(金币/声望/食材)+ 解锁树效果解释器 + 营业日时钟与
* 跨日推进 + 终态 latch + 存档编解码(save_state v1)+ 可观测契约(readState/_forensicsView)
* 渲染层(scenes/)只读本核心只发命令
*
* 三资源分工(设计书 §4)金币 = 循环软币(营业赚进货与解锁花);声望 = 解锁硬轴(只从
* 经营成就里程碑来,M2 起加评论家事件,**不可购买**);食材 = 消耗品(进货-库存-保鲜环,
* 下锅一刻消耗过期日结废弃)
*
* 纯逻辑铁律(设计书代码纪律第 1 ,O-4 已拍硬约束,快进仿真器生存前提)
* DOM/canvas/引擎;时间经 update(dtSeconds) 注入;随机经受控源注入( Math.random/Date.now)
* 本文件与 systems/ 可在 node 直跑(scripts/logic-smoke.mjs 即证)
* DOM/canvas/引擎;时间经 update(dtSeconds) 注入;随机经受控源注入( Math.random/Date.now);
* 离线收益的墙钟时刻(epoch ms)一律由宿主注入参数(nowMs),核内不取时
*
* latch 终态(mini-fei-e A4/A6 同款铁律)
* phase: 'title' 'play' 'day_end'(M0 单营业日终态;M1 跨日循环接管后 day_end 转日结算态)
* 终态一旦落定焊死不回弹,宿主/harness 轮询 readState().phase
* latch 终态phase: 'title' 'play' 'day_end'(单日终态 latch,不回弹);
* 跨日推进只经显式 settleAndAdvanceDay()(M1 日结算态的接管,设计书 M0 注释兑现)
*
* readState 契约(设计书代码纪律第 2 :driver/仿真器/九门共用一个契约)
* tapTargets 带可点逻辑坐标(layout 单源),driver 据此出招;字段形状对齐经营 driver 惯例
* (business-sim.driver state 自适应)
* 存档口径(M1 影子存档)导出快照 = **日初检查点**(含受控随机游标):日中退出即回到当日开局重打,
* 不存日中瞬态(顾客/灶台)既防"存档-重打"刷币,又保 seed 恢复后逐位复现的确定性
* 信封形状 = SaveEnvelope 契约(save-codec.js);M5 切正式 SDK `save:` 槽零迁移
*
*/
@ -25,14 +29,19 @@
import { ECONOMY_CURVE } from '../data/economy-curve.js';
import { RECIPES } from '../data/recipes.js';
import { CUSTOMER_TYPES } from '../data/customer-types.js';
import { SEATS, TAP_RADIUS } from './layout.js';
import { INGREDIENTS } from '../data/ingredients.js';
import { UNLOCK_TREE } from '../data/unlock-tree.js';
import { MILESTONES } from '../data/milestones.js';
import { SEATS, STOVES, BASE_SEATS, TAP_RADIUS } from './layout.js';
import { createServiceLoop } from '../systems/service-loop.js';
import { encodeSave, decodeSave, defaultData } from './save-codec.js';
/**
* 创建游戏核心
* @param {Object} deps
* @param {{next:()=>number,range:(a,b)=>number,pick:(a)=>any}} deps.random 受控随机源(必传)
* @param {{next:()=>number,range:(a,b)=>number,pick:(a)=>any,state?:()=>number,restore?:(c:number)=>void}} deps.random 受控随机源(必传)
* @param {string} [deps.deviceTier='mid'] 设备档('high'|'mid'|'low',screen_cap 钳制)
* @param {number} [deps.dayLengthS] 营业日时长覆盖(;/测试参数化注入口,缺省 economy_curve 单源)
* @returns {GameCore}
*/
export function createGameCore(deps) {
@ -41,69 +50,207 @@ export function createGameCore(deps) {
throw new Error('[night-market core] 必须注入受控随机源 random({next,range,pick})'); // 错误路径留痕
}
const econ = ECONOMY_CURVE;
const dayLengthS = (deps && deps.dayLengthS) || econ.day.length_s;
// 系统①服务循环(单向依赖:core 调系统,系统不知 core)
const loop = createServiceLoop({
random: rng, econ, recipes: RECIPES, customerTypes: CUSTOMER_TYPES,
seats: SEATS, deviceTier: deps.deviceTier || 'mid',
});
// ── 账本与顶层状态 ────────────────────────────────────────────────────────
// ── 三资源账本与顶层状态 ──────────────────────────────────────────────────
const state = {
phase: 'title', // 'title' | 'play' | 'day_end'(latch)
phase: 'title', // 'title' | 'play' | 'day_end'(单日 latch;跨日经 settleAndAdvanceDay)
day: 1,
timeInDay: 0,
coins: 0, // 本日净入(利润+小费;M0 从 0 起记,进货/初始资金归 M1 经济骨架)
coins: econ.starting_coins, // 生涯金币(M1 起生涯账;M0 单日从 0 → 差异记 smoke 头注)
dayNet: 0, // 本日经营净入(营业收入 进货成本;离线速率的数据源)
prestige: 0, // 声望(只从里程碑来,不可购买)
reputation: econ.reputation.start,
combo: 0, // 当前连击层数
comboBest: 0,
served: 0,
missed: 0,
revenue: 0, // 流水(含成本,复盘用)
tips: 0,
combo: 0,
comboBest: 0, // 生涯最佳连击
served: 0, missed: 0, // 本日计数(HUD 日视图)
revenue: 0, tips: 0, // 本日流水/小费
};
let _terminal = false;
let _lastServeAt = -Infinity; // 连击窗口判定(经 timeInDay,纯逻辑时间)
/** 渲染 juice 事件外发队列(飘字/音效;渲染层 drainFx 取走)。 */
const fx = [];
/** 取证:结算调用计数(仿真器/门探针读)。 */
const settleCounts = { serveSettled: 0, missSettled: 0 };
const career = { revenue: 0, served: 0, missed: 0, tips: 0 }; // 生涯累计(里程碑判定轴)
/** 库存批次:{ [ingredientId]: [{qty, freshLeft}] }(oldest-first,FIFO 消耗)。 */
let stock = {};
/** 已解锁节点 id 集。 */
let unlocked = new Set();
/** 已达成里程碑 id 集。 */
let milestonesHit = new Set();
/** 近 3 营业日日志(离线速率数据源)。 */
let dayLog = [];
/** 最后在场时刻(epoch ms,宿主注入)。 */
let lastSeenAtMs = 0;
/** 结算一单出餐:利润+小费入账、连击推进、口碑上调(economy_curve 单源,零魔法数)。 */
let _terminal = false;
let _lastServeAt = -Infinity;
const fx = [];
const settleCounts = { serveSettled: 0, missSettled: 0, unlocks: 0, buys: 0, expiredUnits: 0, offlineCredited: 0 };
// ── 解锁树效果解释器(唯一施效点;渲染层零效果逻辑)─────────────────────────
const NODE_BY_ID = new Map(UNLOCK_TREE.map((n) => [n.id, n]));
const ING_BY_ID = new Map(INGREDIENTS.map((r) => [r.id, r]));
let mod = null; // 当前修饰集(recomputeModifiers 产出)
let unlockedRecipeIds = new Set();
/** 由 unlocked 集重算全部修饰(解锁/导入后调;幂等)。 */
function recomputeModifiers() {
mod = {
stoveSlots: econ.stove.slots, seatCount: BASE_SEATS,
patienceBonusS: 0, tipBonus: 0, spawnMult: 1, freshBonus: 0, discount: 1,
comboWindowBonus: 0, comboStackBonus: 0, repShield: 0, offlineEnabled: false,
};
unlockedRecipeIds = new Set();
for (const id of unlocked) {
const n = NODE_BY_ID.get(id);
if (!n) continue; // 防御:存档带未知节点(未来版本回读)→ 忽略不炸
switch (n.kind) {
case 'unlock_recipe': unlockedRecipeIds.add(n.target); break;
case 'stove_slot': mod.stoveSlots = Math.min(mod.stoveSlots + n.value, STOVES.length); break;
case 'seat': mod.seatCount = Math.min(mod.seatCount + n.value, SEATS.length); break;
case 'patience_bonus': mod.patienceBonusS += n.value; break;
case 'tip_bonus': mod.tipBonus += n.value; break;
case 'spawn_rate': mod.spawnMult *= n.value; break;
case 'freshness_bonus': mod.freshBonus += n.value; break;
case 'market_discount': mod.discount = Math.min(mod.discount, n.value); break; // 取最优折扣,不叠乘
case 'combo_window': mod.comboWindowBonus += n.value; break;
case 'combo_stack': mod.comboStackBonus += n.value; break;
case 'rep_shield': mod.repShield += n.value; break;
case 'offline_enable': mod.offlineEnabled = true; break;
case 'system': break; // M2 前瞻节点,M1 无实效(honest arrivesAt)
default: break;
}
}
}
recomputeModifiers();
/** 当前菜单(base 菜 + 已解锁菜;service-loop 动态消费)。 */
function menu() { return RECIPES.filter((r) => r.base || unlockedRecipeIds.has(r.id)) }
// ── 库存(食材环)────────────────────────────────────────────────────────
function totalUnits(ingId) {
const b = stock[ingId];
if (!b) return 0;
let s = 0; for (const x of b) s += x.qty;
return s;
}
/** 库存足以做这道菜(service-loop 前置判定)。 */
function canServe(recipeId) {
const r = RECIPES.find((x) => x.id === recipeId);
if (!r || !Array.isArray(r.ingredients)) return true; // 防御:无配方字段按可做
return r.ingredients.every((ing) => totalUnits(ing.id) >= ing.qty);
}
/** 扣减库存(FIFO 旧批先耗;cook_start 时 service-loop 调)。 */
function consumeFor(recipeId) {
const r = RECIPES.find((x) => x.id === recipeId);
if (!r || !Array.isArray(r.ingredients)) return;
for (const ing of r.ingredients) {
let need = ing.qty;
const batches = stock[ing.id] || [];
while (need > 0 && batches.length) {
const b = batches[0];
const take = Math.min(b.qty, need);
b.qty -= take; need -= take;
if (b.qty <= 0) batches.shift();
}
}
}
/** 开业赠料(day1;stock_target 全额免费入库,不扣初始资金)。 */
function grantStartingStock() {
if (!econ.starting_stock.grant_full_target) return;
for (const row of INGREDIENTS) {
stock[row.id] = [{ qty: row.stock_target, freshLeft: row.freshness_days }];
}
}
grantStartingStock();
// ── 系统①服务循环(动态 deps 注入;单向依赖:core 调系统,系统不知 core)──
const loop = createServiceLoop({
random: rng, econ, customerTypes: CUSTOMER_TYPES,
menu, seats: SEATS,
seatCount: () => mod.seatCount,
stoveSlots: () => mod.stoveSlots,
patienceBonusS: () => mod.patienceBonusS,
spawnMult: () => mod.spawnMult,
canServe, consumeFor,
deviceTier: (deps && deps.deviceTier) || 'mid',
});
// ── 日初检查点(存档快照源;含 rng 游标)────────────────────────────────────
let checkpoint = null;
function captureCheckpoint() {
checkpoint = {
...defaultData(),
day: state.day,
coins: state.coins,
prestige: state.prestige,
reputation: state.reputation,
milestonesHit: [...milestonesHit],
unlocked: [...unlocked],
stock: JSON.parse(JSON.stringify(stock)),
dayLog: dayLog.map((d) => ({ ...d })),
careerRevenue: career.revenue, careerServed: career.served,
careerMissed: career.missed, careerTips: career.tips,
comboBest: state.comboBest,
rngCursor: typeof rng.state === 'function' ? rng.state() : 1,
lastSeenAtMs,
};
}
captureCheckpoint(); // day1 开局检查点(赠料后、未消耗随机)
/** 结算一单出餐:利润+小费入账、连击推进、口碑上调(economy_curve+解锁修饰,零魔法数)。 */
function settleServe(ev) {
// 连击:距上次出餐 ≤ 窗口 → 层数+1(封顶),否则重开为 1
if (state.timeInDay - _lastServeAt <= econ.combo.window_s) {
state.combo = Math.min(state.combo + 1, econ.combo.max_stack);
const comboWindow = econ.combo.window_s + mod.comboWindowBonus;
const comboMax = econ.combo.max_stack + mod.comboStackBonus;
if (state.timeInDay - _lastServeAt <= comboWindow) {
state.combo = Math.min(state.combo + 1, comboMax);
} else {
state.combo = 1;
}
_lastServeAt = state.timeInDay;
state.comboBest = Math.max(state.comboBest, state.combo);
// 小费 = 售价 × 类型小费系数 × (1 + 连击加成);四舍五入到整币
const tip = Math.round(ev.price * ev.tipCoef * (1 + state.combo * econ.combo.tip_bonus_per_stack));
const tip = Math.round(ev.price * (ev.tipCoef + mod.tipBonus) * (1 + state.combo * econ.combo.tip_bonus_per_stack));
const profit = ev.price - ev.cost;
state.coins += profit + tip;
state.dayNet += profit + tip;
state.revenue += ev.price;
state.tips += tip;
state.served += 1;
career.revenue += ev.price;
career.served += 1;
career.tips += tip;
state.reputation = Math.min(econ.reputation.max, state.reputation + econ.reputation.serve_delta);
settleCounts.serveSettled++;
fx.push({ type: 'serve', seat: ev.seat, recipeName: ev.recipeName, amount: profit + tip, tip, combo: state.combo });
}
/** 结算一次翻台:扣口碑、断连击(耐心耗尽的代价;底稿 ①)。 */
/** 结算一次翻台:扣口碑(rep_shield 减免)、断连击(耐心耗尽的代价;底稿 ①)。 */
function settleMiss(ev) {
state.missed += 1;
career.missed += 1;
state.combo = 0;
state.reputation = Math.max(econ.reputation.min, state.reputation + econ.reputation.miss_delta);
const delta = Math.min(0, econ.reputation.miss_delta + mod.repShield); // 减免后仍 ≤0(不许翻台涨口碑)
state.reputation = Math.max(econ.reputation.min, state.reputation + delta);
settleCounts.missSettled++;
fx.push({ type: 'miss', seat: ev.seat, typeName: ev.typeName });
}
/** 日结里程碑判定:未达成者按生涯轴对账,达成即 +声望(一次性)。返回达成列表。 */
function checkMilestones() {
const hit = [];
for (const m of MILESTONES) {
if (milestonesHit.has(m.id)) continue;
const v = m.axis === 'revenue' ? career.revenue : career.served;
if (v >= m.threshold) {
milestonesHit.add(m.id);
state.prestige += m.prestige;
hit.push({ id: m.id, name: m.name, prestige: m.prestige });
fx.push({ type: 'milestone', name: m.name, prestige: m.prestige });
}
}
return hit;
}
const core = {
state,
/** 开局:title → play(渲染层点开始/自动开局都走这)。 */
/** 开局/续局:title → play(渲染层点开始/自动开局都走这)。 */
start() {
if (state.phase === 'title') state.phase = 'play';
return state.phase;
@ -133,33 +280,203 @@ export function createGameCore(deps) {
/**
* 推进 dt (纯逻辑;渲染帧循环与 headless 仿真同一入口)
* 营业日到点 phase='day_end' latch(在场未服务顾客不再结算,复盘计入)
* 营业日到点 phase='day_end' latch(跨日推进只经 settleAndAdvanceDay)
*/
update(dt) {
if (state.phase !== 'play' || _terminal) return;
state.timeInDay = Math.min(state.timeInDay + dt, econ.day.length_s);
const progress = state.timeInDay / econ.day.length_s;
state.timeInDay = Math.min(state.timeInDay + dt, dayLengthS);
const progress = state.timeInDay / dayLengthS;
loop.update(dt, progress);
// 消费系统结算事件(单消费方,顺序即时间序)
for (const ev of loop.drainEvents()) {
if (ev.type === 'serve') settleServe(ev);
else if (ev.type === 'miss') settleMiss(ev);
// spawn/cook_start 只透传渲染
else fx.push(ev);
else fx.push(ev); // spawn/cook_start 只透传渲染
}
// 营业日收钟 → latch
if (state.timeInDay >= econ.day.length_s) {
if (state.timeInDay >= dayLengthS) {
state.phase = 'day_end';
_terminal = true;
fx.push({ type: 'day_end', coins: state.coins, served: state.served, missed: state.missed });
fx.push({ type: 'day_end', day: state.day, coins: state.coins, dayNet: state.dayNet, served: state.served, missed: state.missed });
}
// fx 有界:headless 无渲染方取走时丢最旧(防仿真长跑内存涨爆)
if (fx.length > 512) fx.splice(0, fx.length - 512);
if (fx.length > 512) fx.splice(0, fx.length - 512); // headless 无渲染方取走时丢最旧
},
// ── M1 经济骨架:进货 / 解锁 / 跨日 ────────────────────────────────────
/**
* 进货一种食材(单价 × 折扣修饰,向上取整;新批次按保鲜天数+修饰入库)
* @returns {{ok:true, spent:number} | {ok:false, reason:string}}
*/
buyIngredient(ingId, units) {
const row = ING_BY_ID.get(ingId);
if (!row || !Number.isInteger(units) || units <= 0) return { ok: false, reason: 'bad_args' };
const total = Math.ceil(units * row.price * mod.discount);
if (state.coins < total) return { ok: false, reason: 'insufficient_coins' };
state.coins -= total;
state.dayNet -= total; // 进货是经营成本,入日净(离线速率如实反映净收益)
(stock[ingId] = stock[ingId] || []).push({ qty: units, freshLeft: row.freshness_days + mod.freshBonus });
settleCounts.buys++;
fx.push({ type: 'buy', ingId, units, spent: total });
return { ok: true, spent: total };
},
/**
* 自动补货:按食材表序补到 stock_target(economy_curve.restock 缺省策略;资金不足补到耗尽,确定性顺序)
* @returns {{spent:number, bought:Array<{id:string, units:number}>}}
*/
autoRestock() {
let spent = 0; const bought = [];
for (const row of INGREDIENTS) {
let need = row.stock_target - totalUnits(row.id);
if (need <= 0) continue;
// 按余钱缩量(整单向上取整口径下,先试满额,不够则逐步缩)
let units = need;
let total = Math.ceil(units * row.price * mod.discount);
while (units > 0 && total > state.coins) { units--; total = Math.ceil(units * row.price * mod.discount); }
if (units <= 0) continue;
state.coins -= total;
state.dayNet -= total;
(stock[row.id] = stock[row.id] || []).push({ qty: units, freshLeft: row.freshness_days + mod.freshBonus });
settleCounts.buys++;
spent += total; bought.push({ id: row.id, units });
}
if (spent > 0) fx.push({ type: 'restock', spent, kinds: bought.length });
return { spent, bought };
},
/**
* 解锁一个节点(金币购价 + 声望门 + 前置依赖;声望不可购买 = 进度纯度红线)
* @returns {{ok:true, node:Object} | {ok:false, reason:string}}
*/
unlockNode(nodeId) {
const n = NODE_BY_ID.get(nodeId);
if (!n) return { ok: false, reason: 'not_found' };
if (unlocked.has(nodeId)) return { ok: false, reason: 'already' };
if (!n.deps.every((d) => unlocked.has(d))) return { ok: false, reason: 'deps_unmet' };
if (state.prestige < n.min_prestige) return { ok: false, reason: 'prestige_low' };
if (state.coins < n.cost_coins) return { ok: false, reason: 'insufficient_coins' };
state.coins -= n.cost_coins; // 解锁是资本性支出,不入 dayNet(经营净入口径)
unlocked.add(nodeId);
recomputeModifiers();
settleCounts.unlocks++;
fx.push({ type: 'unlock', nodeId, name: n.name, kind: n.kind });
return { ok: true, node: n };
},
/** 当前"够得着且买得起"的节点(UI 高亮/驱动策略用;按价升序,确定性)。 */
affordableUnlocks() {
return UNLOCK_TREE
.filter((n) => !unlocked.has(n.id)
&& n.deps.every((d) => unlocked.has(d))
&& state.prestige >= n.min_prestige
&& state.coins >= n.cost_coins)
.sort((a, b) => a.cost_coins - b.cost_coins || (a.id < b.id ? -1 : 1));
},
/**
* 日结并推进到下一营业日(day_end 才可调):保鲜衰减日志里程碑计数复位检查点
* @param {{nowMs?:number}} [opts] 宿主墙钟(epoch ms; lastSeenAt,离线口径的起点)
* @returns {{ok:true, settledDay:number, net:number, expired:Array, milestones:Array, nextDay:number} | {ok:false, reason:string}}
*/
settleAndAdvanceDay(opts) {
if (state.phase !== 'day_end') return { ok: false, reason: 'not_day_end' };
const nowMs = (opts && opts.nowMs) || 0;
// ① 保鲜衰减:批次 freshLeft1,到 0 废弃(食材环的损耗压力)
const expired = [];
for (const row of INGREDIENTS) {
const batches = stock[row.id];
if (!batches || !batches.length) continue;
let waste = 0;
for (const b of batches) { b.freshLeft -= 1; if (b.freshLeft <= 0) waste += b.qty; }
stock[row.id] = batches.filter((b) => b.freshLeft > 0);
if (waste > 0) { expired.push({ id: row.id, name: row.name, units: waste }); settleCounts.expiredUnits += waste; }
}
// ② 日志(近 3 日;离线速率数据源)
dayLog.push({ day: state.day, net: state.dayNet, minutes: dayLengthS / 60 });
if (dayLog.length > 3) dayLog = dayLog.slice(-3);
// ③ 里程碑 → 声望
const hit = checkMilestones();
// ④ 日切复位
const report = { ok: true, settledDay: state.day, net: state.dayNet, expired, milestones: hit, nextDay: state.day + 1 };
state.day += 1;
state.timeInDay = 0;
state.dayNet = 0;
state.served = 0; state.missed = 0; state.revenue = 0; state.tips = 0;
state.combo = 0;
_lastServeAt = -Infinity;
_terminal = false;
state.phase = 'play';
loop.resetDay();
lastSeenAtMs = nowMs;
captureCheckpoint(); // 新一日日初检查点(存档快照源)
fx.push({ type: 'day_start', day: state.day });
return report;
},
// ── M1 存档(save_state v1;影子存储由宿主层负责,核只管编解码)──────────
/**
* 导出存档(SaveEnvelope v1)= 当前日初检查点 + 此刻离场时间戳
* @param {number} [nowMs] 宿主墙钟(epoch ms;不传沿用检查点值 = 不触发离线计时)
*/
exportSave(nowMs) {
const data = { ...checkpoint, lastSeenAtMs: typeof nowMs === 'number' ? nowMs : checkpoint.lastSeenAtMs };
return encodeSave(data);
},
/**
* 导入存档:校验+迁移 重建生涯态(落日初phase='title') 离线收益结算
* @param {any} envelope SaveEnvelope
* @param {{nowMs?:number}} [opts] 宿主墙钟(epoch ms;离线时长 = nowMs lastSeenAtMs)
* @returns {{ok:true, migratedFrom?:number, offline:{elapsedMs:number, credited:number}} | {ok:false, reason:string}}
*/
importSave(envelope, opts) {
const d = decodeSave(envelope);
if (!d.ok) return d; // newer_schema / bad_envelope:拒读不毁档(调用方保留原值)
const data = d.data;
// 重建生涯态
state.day = data.day;
state.coins = data.coins;
state.prestige = data.prestige;
state.reputation = data.reputation;
state.comboBest = data.comboBest;
career.revenue = data.careerRevenue; career.served = data.careerServed;
career.missed = data.careerMissed; career.tips = data.careerTips;
milestonesHit = new Set(data.milestonesHit);
unlocked = new Set(data.unlocked);
stock = JSON.parse(JSON.stringify(data.stock));
dayLog = data.dayLog.map((x) => ({ ...x }));
lastSeenAtMs = data.lastSeenAtMs;
recomputeModifiers();
// 日中瞬态清零,落日初(存档口径:日中退出回当日开局)
state.timeInDay = 0; state.dayNet = 0;
state.served = 0; state.missed = 0; state.revenue = 0; state.tips = 0;
state.combo = 0; _lastServeAt = -Infinity;
_terminal = false;
state.phase = 'title';
loop.resetDay();
if (typeof rng.restore === 'function') rng.restore(data.rngCursor); // 随机流逐位续接
// 离线收益(功能节点已解锁 + 有历史日志 + 时间前进了才计)
const nowMs = (opts && opts.nowMs) || 0;
const offline = { elapsedMs: 0, credited: 0 };
if (mod.offlineEnabled && data.lastSeenAtMs > 0 && nowMs > data.lastSeenAtMs && dayLog.length > 0) {
const oy = econ.offline_yield;
let rateSum = 0;
for (const dLog of dayLog) rateSum += dLog.net / dLog.minutes;
const ratePerMin = Math.max(0, rateSum / dayLog.length) * oy.rate_of_online;
const elapsedMin = Math.min((nowMs - data.lastSeenAtMs) / 60000, oy.cap_hours * 60);
offline.elapsedMs = nowMs - data.lastSeenAtMs;
offline.credited = Math.floor(elapsedMin * ratePerMin);
if (offline.credited > 0) {
state.coins += offline.credited;
settleCounts.offlineCredited += offline.credited;
fx.push({ type: 'offline', credited: offline.credited, elapsedMin: Math.floor(elapsedMin) });
}
}
captureCheckpoint(); // 导入即成新检查点(再导出一致)
return d.migratedFrom != null ? { ok: true, migratedFrom: d.migratedFrom, offline } : { ok: true, offline };
},
/**
* 可观测状态快照(readState 契约:driver/仿真器/九门/HUD 共用)
* tapTargets = 等待中顾客的可点逻辑坐标(driver 出招用;与渲染命中同源 layout)
* tapTargets = 等待中顾客的可点逻辑坐标 + servable(库存可做;driver 优先点可做单)
*/
readState() {
const v = loop.view();
@ -167,15 +484,27 @@ export function createGameCore(deps) {
phase: state.phase,
day: state.day,
timeInDay: state.timeInDay,
dayLength: econ.day.length_s,
dayLength: dayLengthS,
coins: state.coins,
dayNet: state.dayNet,
prestige: state.prestige,
reputation: state.reputation,
combo: state.combo, comboBest: state.comboBest,
served: state.served, missed: state.missed,
revenue: state.revenue, tips: state.tips,
career: { ...career },
customers: v.customers,
stoves: v.stoves,
tapTargets: v.customers.filter((c) => c.state === 'waiting').map((c) => ({ customerId: c.id, x: c.x, y: c.y, patienceLeft: c.patienceLeft })),
tapTargets: v.customers
.filter((c) => c.state === 'waiting')
.map((c) => ({ customerId: c.id, x: c.x, y: c.y, patienceLeft: c.patienceLeft, servable: canServe(c.orderRecipeId) })),
menuSize: menu().length,
unlockedCount: unlocked.size,
offlineEnabled: mod.offlineEnabled,
stockSummary: INGREDIENTS.map((r) => {
const units = totalUnits(r.id);
return { id: r.id, name: r.name, units, target: r.stock_target, low: units < r.stock_target * 0.25 };
}),
};
},
@ -190,7 +519,12 @@ export function createGameCore(deps) {
return {
state: () => core.readState(),
counts: () => ({ ...loop.counts(), ...settleCounts }),
econEcho: () => ({ dayLength: econ.day.length_s, stoveSlots: econ.stove.slots, comboWindow: econ.combo.window_s }),
econEcho: () => ({
dayLength: dayLengthS, stoveSlots: mod.stoveSlots, seatCount: mod.seatCount,
comboWindow: econ.combo.window_s + mod.comboWindowBonus,
menuSize: menu().length, treeSize: UNLOCK_TREE.length,
offlineRate: econ.offline_yield.rate_of_online, offlineCapH: econ.offline_yield.cap_hours,
}),
};
},
};

View File

@ -12,15 +12,19 @@
export const VIEW_W = 390;
export const VIEW_H = 844;
/** 顾客座位(街边摊前两排,6 席位 = M0 高峰同屏上限 4 + 余量;从左到右、前排优先)。 */
/** 顾客座位(街边摊前三排,9 坐标上限;开局启用前 BASE_SEATS 席,79 席经解锁树 seat 节点逐个启用)。 */
export const SEATS = Object.freeze([
{ x: 75, y: 470 }, { x: 195, y: 470 }, { x: 315, y: 470 },
{ x: 75, y: 590 }, { x: 195, y: 590 }, { x: 315, y: 590 },
{ x: 75, y: 700 }, { x: 195, y: 700 }, { x: 315, y: 700 },
]);
/** 开局启用座位数(M0 口径不变;扩容走解锁树,不改本常量)。 */
export const BASE_SEATS = 6;
/** 灶台位(摊位操作台上,M0 双灶 = economy_curve.stove.slots)。 */
/** 灶台位(坐标上限 5;开局双灶 = economy_curve.stove.slots,第 35 灶经解锁树 stove_slot 节点启用)。 */
export const STOVES = Object.freeze([
{ x: 130, y: 300 }, { x: 260, y: 300 },
{ x: 65, y: 300 }, { x: 325, y: 300 }, { x: 195, y: 245 },
]);
/** 摊位主体(渲染参考:操作台矩形)。 */

View File

@ -0,0 +1,85 @@
/**
* src/core/save-codec.js save_state v1 信封编解码(SaveEnvelope 契约形状;纯逻辑可 node 直测)
* owner:北极星顶级线①(W-POLARIS)· 开发设计书 §8 代码纪律第 6 (存档版本纪律)
*
* 契约对齐信封形状 = contracts/sdk-interface.d.ts SaveEnvelope(W-SAVE-CAP 2026-07-06):
* { schemaVersion: 整数1, data: 纯对象 }M1 存储介质 = 影子存档(IndexedDB 直写,src/host-shadow-store.js);
* M5 切正式 SDK Storage `save:{gameId}:{versionId}` 槽时信封原样搬运,零迁移(设计书 §8 复用面)
* 版本纪律additive 优先(缺省兜底不升版);读侧逐级迁移(01),缺迁移器按缺省兜底;
* 未来版本(schemaVersion > CURRENT)拒读不毁档(降档读新档,调用方保留原值)
* 口径与 game-runtime save-progress 插件 v1.1.0 setVersioned/getVersioned 同族,实现独立
* (本作是 Phaser 独立工程,不依赖 LittleJS 插件库)
*/
'use strict';
/** 当前存档 schema 版本。 */
export const SCHEMA_VERSION = 1;
/** data 缺省骨架(v1;迁移链的兜底基座——任何缺字段按此补齐,additive 纪律的读侧保证)。 */
export function defaultData() {
return {
day: 1,
coins: 0,
prestige: 0,
reputation: 50,
milestonesHit: [],
unlocked: [],
/** 库存批次:{ [ingredientId]: [{qty, freshLeft}] }(oldest-first)。 */
stock: {},
/** 近 3 营业日日志:[{day, net, minutes}](离线速率的数据源)。 */
dayLog: [],
careerRevenue: 0,
careerServed: 0,
careerMissed: 0,
careerTips: 0,
comboBest: 0,
/** 受控随机游标(恢复后随机流逐位续接)。 */
rngCursor: 1,
/** 最后在场时刻(epoch ms,宿主注入;离线时长的起点)。0 = 未知(不计离线)。 */
lastSeenAtMs: 0,
};
}
/** 逐级迁移器:fromVersion → 变换(data)=>data。v1 起版,0 = 无版本旧档(按缺省补齐)。 */
const MIGRATIONS = {
/** 0→1:无版本旧档,一切缺省兜底(additive 纪律:字段缺失不报错)。 */
0: (data) => ({ ...defaultData(), ...(data && typeof data === 'object' && !Array.isArray(data) ? data : {}) }),
};
/**
* 编码:业务 data SaveEnvelope(v1)
* @param {Object} data 业务存档正文(纯对象)
* @returns {{schemaVersion:number, data:Object}}
*/
export function encodeSave(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('[save-codec] data 必须是纯对象(SaveEnvelope 契约)'); // 错误路径留痕
}
return { schemaVersion: SCHEMA_VERSION, data };
}
/**
* 解码:SaveEnvelope 迁移到当前版本的业务 data
* @param {any} envelope 待读信封
* @returns {{ok:true, data:Object, migratedFrom?:number} | {ok:false, reason:string}}
* - newer_schema:未来版本,拒读不毁档(调用方保留原存档,提示升级)
* - bad_envelope:形状非法(非对象/ schemaVersion/非整数/负数/data 非纯对象)
*/
export function decodeSave(envelope) {
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return { ok: false, reason: 'bad_envelope' };
const v = envelope.schemaVersion;
if (!Number.isInteger(v) || v < 0) return { ok: false, reason: 'bad_envelope' };
if (v > SCHEMA_VERSION) return { ok: false, reason: 'newer_schema' }; // 降档读新档:不毁档
let data = envelope.data;
if (v >= 1 && (!data || typeof data !== 'object' || Array.isArray(data))) return { ok: false, reason: 'bad_envelope' };
// 逐级迁移(缺迁移器 = 结构未变,直接过;additive 纪律)
let from = v;
for (let i = v; i < SCHEMA_VERSION; i++) {
const mig = MIGRATIONS[i];
data = mig ? mig(data) : data;
}
// 终检:缺字段按缺省补齐(哪怕同版本——防手工编辑残档)
data = { ...defaultData(), ...(data && typeof data === 'object' && !Array.isArray(data) ? data : {}) };
return v < SCHEMA_VERSION ? { ok: true, data, migratedFrom: from } : { ok: true, data };
}

View File

@ -31,5 +31,9 @@ export function createSeededRandom(seed) {
pick(arr) { return arr.length ? arr[Math.floor(next() * arr.length)] : undefined; },
/** 重设种子(仿真器扫描复用同一实例)。 */
reseed(newSeed) { s = (newSeed >>> 0) || 1; },
/** 导出内部游标(save_state v1:恢复后随机流逐位续接 = 跨存档确定性的地基)。 */
state() { return s; },
/** 恢复内部游标(importSave 消费;与 state() 成对)。 */
restore(cursor) { s = (cursor >>> 0) || 1; },
};
}

View File

@ -55,4 +55,12 @@ export const ECONOMY_CURVE = Object.freeze({
plot_price: { minutes_min: 25, minutes_max: 35 },
/** 声望三档阶梯(金标锚 22/55/100 的生涯化;U2 解锁树消费)。〔草案〕 */
prestige_threshold: { tiers: [22, 55, 100] },
// ── M1 经济骨架消费键(additive;〔草案·首轮仿真回填后锁〕)──────────────────
/** 初始资金:开业本钱(进货用;M0 从 0 记账 → M1 生涯账本从此起)。 */
starting_coins: 40,
/** 开业赠料:day1 免费入库 = 各食材 stock_target 全额(食材表单源);不扣初始资金。 */
starting_stock: { grant_full_target: true },
/** 日结自动补货缺省策略:补到 stock_target;资金不足按表序补到耗尽(确定性顺序)。 */
restock: { to_target: true },
});

View File

@ -0,0 +1,27 @@
/**
* src/data/ingredients.js 食材表(M1 子集:12 · 三资源之食材的数据面)
* owner:北极星顶级线①(W-POLARIS)· 开发设计书 §4 三资源分工(食材 = 消耗品,进货-库存-保鲜)
*
* 字段口径:id / 名称 / 单价(进货价,金币) / 保鲜天数(freshness_days:入库批次可跨的营业日数,
* 过期批次日结时废弃 = 保鲜环的经济压力) / stock_target(自动补货目标件数,day1 开业赠料同值)
* 定价纪律:菜谱 cost 必须 = Σ(食材单价×用量),tree-check 机器对账(单源无双写)
* 全量 40 种归 U13 灌装批;M1 12 种覆盖 24 道菜谱的组合空间内容全原创(clean-room)
* 草案·首轮仿真回填后锁
*/
'use strict';
export const INGREDIENTS = Object.freeze([
{ id: 'i_mianfen', name: '面粉', price: 1, freshness_days: 4, stock_target: 100 },
{ id: 'i_jidan', name: '鸡蛋', price: 2, freshness_days: 3, stock_target: 60 },
{ id: 'i_zaliang', name: '杂粮', price: 1, freshness_days: 4, stock_target: 60 },
{ id: 'i_tang', name: '白糖', price: 1, freshness_days: 6, stock_target: 60 },
{ id: 'i_shanzha', name: '山楂', price: 1, freshness_days: 3, stock_target: 40 },
{ id: 'i_laliao', name: '辣料', price: 2, freshness_days: 6, stock_target: 100 },
{ id: 'i_fentiao', name: '粉条', price: 2, freshness_days: 5, stock_target: 60 },
{ id: 'i_zhuqian', name: '竹签', price: 1, freshness_days: 9, stock_target: 100 },
{ id: 'i_youyu', name: '鱿鱼', price: 5, freshness_days: 2, stock_target: 20 },
{ id: 'i_guihua', name: '桂花酿', price: 4, freshness_days: 5, stock_target: 12 },
{ id: 'i_nuomi', name: '糯米', price: 2, freshness_days: 4, stock_target: 30 },
{ id: 'i_shishu', name: '时蔬', price: 1, freshness_days: 2, stock_target: 80 },
]);

View File

@ -0,0 +1,41 @@
/**
* src/data/milestones.js 经营成就里程碑表(M1 · 声望的唯一来源)
* owner:北极星顶级线①(W-POLARIS)· 开发设计书 §4 三资源分工
*
* 声望纪律声望 = 解锁硬轴,只从经营成就(本表)与评论家事件(M2 事件系统),**不可购买**
* (进度纯度红线,设计书 §4)M1 里程碑两轴:生涯流水(revenue)/ 生涯出餐(served);
* 判定时机 = 日结(settleAndAdvanceDay),达成即 +prestige,一次性不重复
*
* 单源纪律流水前三档直接取 economy_curve.prestige_threshold.tiers(22/55/100),
* 后段为其 ×5/×20 的生涯化放大列(设计书 §4跨日放大)本表 import 曲线表推导,零双写
* 草案·首轮仿真回填后锁
*/
'use strict';
import { ECONOMY_CURVE } from './economy-curve.js';
const T = ECONOMY_CURVE.prestige_threshold.tiers; // [22, 55, 100]
/** :{id, name, axis:'revenue'|'served', threshold, prestige}
* 放大列 ×20/×100/×400:tiers 是单局金标尺度(mini-肥鹅 20100 ),本作单营业日流水 数千,
* 首日达成第一组三五日第二组旬日第三组的节奏取营业日尺度放大草案·仿真回填后锁 */
export const MILESTONES = Object.freeze([
// 生涯流水九档(tiers ×20 / ×100 / ×400 生涯化)
{ id: 'm_rev_1', name: '第一桶金', axis: 'revenue', threshold: T[0] * 20, prestige: 1 },
{ id: 'm_rev_2', name: '小摊初旺', axis: 'revenue', threshold: T[1] * 20, prestige: 1 },
{ id: 'm_rev_3', name: '站稳脚跟', axis: 'revenue', threshold: T[2] * 20, prestige: 1 },
{ id: 'm_rev_4', name: '街坊常客', axis: 'revenue', threshold: T[0] * 100, prestige: 1 },
{ id: 'm_rev_5', name: '夜市有名', axis: 'revenue', threshold: T[1] * 100, prestige: 1 },
{ id: 'm_rev_6', name: '一条街招牌', axis: 'revenue', threshold: T[2] * 100, prestige: 1 },
{ id: 'm_rev_7', name: '日进斗金', axis: 'revenue', threshold: T[0] * 400, prestige: 1 },
{ id: 'm_rev_8', name: '生意兴隆', axis: 'revenue', threshold: T[1] * 400, prestige: 1 },
{ id: 'm_rev_9', name: '传说摊主', axis: 'revenue', threshold: T[2] * 400, prestige: 1 },
// 生涯出餐三档
{ id: 'm_srv_1', name: '百单达成', axis: 'served', threshold: 100, prestige: 1 },
{ id: 'm_srv_2', name: '三百单老手', axis: 'served', threshold: 300, prestige: 1 },
{ id: 'm_srv_3', name: '八百单宗师', axis: 'served', threshold: 800, prestige: 1 },
]);
/** 里程碑可获声望总额(tree-check ⑤ 可达性上界)。 */
export const MAX_PRESTIGE = MILESTONES.reduce((s, m) => s + m.prestige, 0);

View File

@ -1,26 +1,74 @@
/**
* src/data/recipes.js 菜谱表(M0 子集:8 · 底稿 §1.1 recipe 表字段的开发装载)
* src/data/recipes.js 菜谱表(M1 子集:24 = M0 基础 8 + 解锁 16 · 底稿 §1.1 recipe 表字段的开发装载)
* owner:北极星顶级线①(W-POLARIS)· 开发设计书 §3 内容量表(全量 60 道归 U13 灌装批)
*
* 字段口径(底稿 recipe ):id / 名称 / 烹饪时长秒 / 售价 / 成本 / 稀有度(03) / 偏好客群 tag[]
* 定价纪律:利润率按 economy_curve.recipe_profit 四档(25%/35%/48%/60%),cost = round(price×(1margin))
* 食材组合/解锁条件字段随 U1(12 表批) U2(研发树)接入,M0 不带服务循环不消费它们
* 内容全原创(clean-room:不引用任何对标作文案)
* 字段口径(底稿 recipe + M1 增量):id / 名称 / 烹饪时长秒 / 售价 / 成本 / 稀有度(03) /
* 偏好客群 tag[] / base(true=开局可用,false=经解锁树 unlock_recipe 节点解锁) /
* ingredients[{id,qty}](M1 三资源:出餐消耗库存;cost 必须 = Σ单价×qty,tree-check 机器对账)
* 定价纪律:利润率按 economy_curve.recipe_profit 四档(25%/35%/48%/60%),|cost price×(1margin)| 1
* 内容全原创(clean-room:不引用任何对标作文案)草案·首轮仿真回填后锁
*/
'use strict';
export const RECIPES = Object.freeze([
// ── M0 基础 8 道(base:true 开局可用;id/定价与 M0 逐字不变 = 回归底座)────
// rarity 0(margin 25%)——开局常备,快出餐走量
{ id: 'r_jianbing', name: '煎饼果子', cook_s: 2.0, price: 8, cost: 6, rarity: 0, tags: ['quick', 'classic'] },
{ id: 'r_tanghulu', name: '冰糖葫芦', cook_s: 1.5, price: 6, cost: 4, rarity: 0, tags: ['sweet', 'quick'] },
{ id: 'r_kaolengmian', name: '烤冷面', cook_s: 2.5, price: 10, cost: 7, rarity: 0, tags: ['classic'] },
{ id: 'r_jianbing', name: '煎饼果子', cook_s: 2.0, price: 8, cost: 6, rarity: 0, tags: ['quick', 'classic'], base: true,
ingredients: [{ id: 'i_mianfen', qty: 2 }, { id: 'i_jidan', qty: 1 }, { id: 'i_zaliang', qty: 2 }] },
{ id: 'r_tanghulu', name: '冰糖葫芦', cook_s: 1.5, price: 6, cost: 4, rarity: 0, tags: ['sweet', 'quick'], base: true,
ingredients: [{ id: 'i_shanzha', qty: 2 }, { id: 'i_tang', qty: 1 }, { id: 'i_zhuqian', qty: 1 }] },
{ id: 'r_kaolengmian', name: '烤冷面', cook_s: 2.5, price: 10, cost: 7, rarity: 0, tags: ['classic'], base: true,
ingredients: [{ id: 'i_mianfen', qty: 3 }, { id: 'i_jidan', qty: 1 }, { id: 'i_laliao', qty: 1 }] },
// rarity 1(margin 35%)——中价位主力
{ id: 'r_zhachuan', name: '现炸串串', cook_s: 3.0, price: 14, cost: 9, rarity: 1, tags: ['spicy', 'classic'] },
{ id: 'r_suanlafen', name: '酸辣粉', cook_s: 3.5, price: 15, cost: 10, rarity: 1, tags: ['spicy'] },
{ id: 'r_malatang', name: '麻辣烫', cook_s: 4.0, price: 18, cost: 12, rarity: 1, tags: ['spicy', 'hot'] },
{ id: 'r_zhachuan', name: '现炸串串', cook_s: 3.0, price: 14, cost: 9, rarity: 1, tags: ['spicy', 'classic'], base: true,
ingredients: [{ id: 'i_shishu', qty: 2 }, { id: 'i_laliao', qty: 2 }, { id: 'i_zhuqian', qty: 3 }] },
{ id: 'r_suanlafen', name: '酸辣粉', cook_s: 3.5, price: 15, cost: 10, rarity: 1, tags: ['spicy'], base: true,
ingredients: [{ id: 'i_fentiao', qty: 3 }, { id: 'i_laliao', qty: 2 }] },
{ id: 'r_malatang', name: '麻辣烫', cook_s: 4.0, price: 18, cost: 12, rarity: 1, tags: ['spicy', 'hot'], base: true,
ingredients: [{ id: 'i_fentiao', qty: 2 }, { id: 'i_shishu', qty: 3 }, { id: 'i_laliao', qty: 2 }, { id: 'i_zhuqian', qty: 1 }] },
// rarity 2(margin 48%)——慢工高利
{ id: 'r_tieban', name: '铁板鱿鱼', cook_s: 5.0, price: 25, cost: 13, rarity: 2, tags: ['seafood', 'hot'] },
{ id: 'r_tieban', name: '铁板鱿鱼', cook_s: 5.0, price: 25, cost: 13, rarity: 2, tags: ['seafood', 'hot'], base: true,
ingredients: [{ id: 'i_youyu', qty: 2 }, { id: 'i_laliao', qty: 1 }, { id: 'i_zhuqian', qty: 1 }] },
// rarity 3(margin 60%)——招牌甜品(稀有客最爱)
{ id: 'r_guihua', name: '桂花糖水', cook_s: 4.5, price: 20, cost: 8, rarity: 3, tags: ['sweet', 'rare_fav'] },
{ id: 'r_guihua', name: '桂花糖水', cook_s: 4.5, price: 20, cost: 8, rarity: 3, tags: ['sweet', 'rare_fav'], base: true,
ingredients: [{ id: 'i_guihua', qty: 1 }, { id: 'i_tang', qty: 2 }, { id: 'i_nuomi', qty: 1 }] },
// ── M1 解锁 16 道(base:false;每道对应解锁树恰一个 unlock_recipe 节点,tree-check ④ 对账)──
// rarity 0 ×4
{ id: 'r_kaohongshu', name: '烤红薯', cook_s: 3.0, price: 8, cost: 6, rarity: 0, tags: ['sweet', 'classic'], base: false,
ingredients: [{ id: 'i_zaliang', qty: 4 }, { id: 'i_tang', qty: 2 }] },
{ id: 'r_jiroujuan', name: '鸡蛋灌饼', cook_s: 2.5, price: 12, cost: 9, rarity: 0, tags: ['quick', 'classic'], base: false,
ingredients: [{ id: 'i_mianfen', qty: 3 }, { id: 'i_jidan', qty: 2 }, { id: 'i_shishu', qty: 2 }] },
{ id: 'r_congyoubing', name: '葱油饼', cook_s: 2.0, price: 8, cost: 6, rarity: 0, tags: ['quick', 'classic'], base: false,
ingredients: [{ id: 'i_mianfen', qty: 4 }, { id: 'i_shishu', qty: 2 }] },
{ id: 'r_zhimaqiu', name: '芝麻球', cook_s: 1.5, price: 6, cost: 4, rarity: 0, tags: ['sweet', 'quick'], base: false,
ingredients: [{ id: 'i_nuomi', qty: 1 }, { id: 'i_tang', qty: 2 }] },
// rarity 1 ×6
{ id: 'r_liangpi', name: '麻酱凉皮', cook_s: 2.5, price: 14, cost: 9, rarity: 1, tags: ['spicy', 'quick'], base: false,
ingredients: [{ id: 'i_mianfen', qty: 3 }, { id: 'i_laliao', qty: 2 }, { id: 'i_shishu', qty: 2 }] },
{ id: 'r_chaomian', name: '铁板炒面', cook_s: 3.0, price: 15, cost: 10, rarity: 1, tags: ['classic', 'hot'], base: false,
ingredients: [{ id: 'i_mianfen', qty: 4 }, { id: 'i_shishu', qty: 2 }, { id: 'i_jidan', qty: 2 }] },
{ id: 'r_jianjiao', name: '脆底煎饺', cook_s: 3.5, price: 16, cost: 10, rarity: 1, tags: ['classic', 'hot'], base: false,
ingredients: [{ id: 'i_mianfen', qty: 3 }, { id: 'i_shishu', qty: 3 }, { id: 'i_jidan', qty: 2 }] },
{ id: 'r_shaokao', name: '烤蔬菜串', cook_s: 3.0, price: 12, cost: 8, rarity: 1, tags: ['spicy'], base: false,
ingredients: [{ id: 'i_shishu', qty: 4 }, { id: 'i_laliao', qty: 1 }, { id: 'i_zhuqian', qty: 2 }] },
{ id: 'r_nuomici', name: '红糖糍粑', cook_s: 2.5, price: 14, cost: 9, rarity: 1, tags: ['sweet'], base: false,
ingredients: [{ id: 'i_nuomi', qty: 3 }, { id: 'i_tang', qty: 3 }] },
{ id: 'r_danhuasu', name: '蛋花酥', cook_s: 3.0, price: 15, cost: 10, rarity: 1, tags: ['sweet', 'classic'], base: false,
ingredients: [{ id: 'i_jidan', qty: 4 }, { id: 'i_mianfen', qty: 1 }, { id: 'i_tang', qty: 1 }] },
// rarity 2 ×4
{ id: 'r_haixianzhou', name: '海鲜砂锅粥', cook_s: 5.0, price: 25, cost: 13, rarity: 2, tags: ['seafood', 'hot'], base: false,
ingredients: [{ id: 'i_youyu', qty: 2 }, { id: 'i_nuomi', qty: 1 }, { id: 'i_shishu', qty: 1 }] },
{ id: 'r_laweiguo', name: '香辣海鲜锅', cook_s: 5.5, price: 27, cost: 14, rarity: 2, tags: ['seafood', 'spicy'], base: false,
ingredients: [{ id: 'i_youyu', qty: 2 }, { id: 'i_laliao', qty: 2 }] },
{ id: 'r_jianbingsu', name: '金黄煎蛋饼', cook_s: 4.5, price: 23, cost: 12, rarity: 2, tags: ['classic', 'hot'], base: false,
ingredients: [{ id: 'i_jidan', qty: 4 }, { id: 'i_mianfen', qty: 2 }, { id: 'i_shishu', qty: 2 }] },
{ id: 'r_tangshanyao', name: '拔丝山药', cook_s: 4.0, price: 21, cost: 11, rarity: 2, tags: ['sweet', 'classic'], base: false,
ingredients: [{ id: 'i_zaliang', qty: 5 }, { id: 'i_tang', qty: 4 }, { id: 'i_zhuqian', qty: 2 }] },
// rarity 3 ×2
{ id: 'r_guihuagao', name: '桂花年糕', cook_s: 4.5, price: 23, cost: 9, rarity: 3, tags: ['sweet', 'rare_fav'], base: false,
ingredients: [{ id: 'i_guihua', qty: 1 }, { id: 'i_nuomi', qty: 2 }, { id: 'i_tang', qty: 1 }] },
{ id: 'r_yexiaopin', name: '招牌夜宵拼盘', cook_s: 6.0, price: 30, cost: 12, rarity: 3, tags: ['rare_fav', 'hot', 'seafood'], base: false,
ingredients: [{ id: 'i_youyu', qty: 1 }, { id: 'i_laliao', qty: 2 }, { id: 'i_zhuqian', qty: 2 }, { id: 'i_shishu', qty: 1 }] },
]);

View File

@ -0,0 +1,84 @@
/**
* src/data/unlock-tree.js 解锁树表(M1 · 40 节点;设计书 §9 M1解锁树 40 节点)
* owner:北极星顶级线①(W-POLARIS)· 全量 140 节点归 U13 灌装批,M1 骨架 40 先立
*
* 节点口径{id, name, kind, target?, value?, deps[], cost_coins, min_prestige, arrivesAt?}
* - kind KINDS:M1 实效节点(解锁即改经济/容量/节奏)+ 'system' 前瞻节点(effect M2 系统,
* honest 标注 arrivesAt:'M2',UI 显示"即将开张"设计书 §3 节点类别系统功能 M1 落位)
* - cost_coins 金币购价;min_prestige 声望门(声望经里程碑获得,不可购买)
* - deps = 前置节点(DAG;静态可达门 scripts/tree-check.mjs 八查)
* 效果解释器core/game-core.js kind 施效;渲染层零效果逻辑
* 草案·首轮仿真回填后锁;成本值目测挂 upgrade_cost 曲线感,U9 定标
*/
'use strict';
/** 合法节点类别(effect 解释器与 tree-check 共用单源)。 */
export const NODE_KINDS = Object.freeze([
'unlock_recipe', // target=菜谱 id:入菜单(顾客可点单)
'stove_slot', // 灶台 +1(2→5;layout STOVES 坐标上限)
'seat', // 座位 +1(6→9;layout SEATS 坐标上限)
'patience_bonus', // 顾客耐心 +value 秒
'tip_bonus', // 小费系数 +value
'spawn_rate', // 生成间隔 ×value(<1 = 客流加快)
'freshness_bonus', // 保鲜 +value 天
'market_discount', // 进货折扣:单价 ×value(向上取整)
'combo_window', // 连击窗口 +value 秒
'combo_stack', // 连击层数上限 +value
'rep_shield', // 翻台口碑损失减免 +value(miss_delta 抬升)
'offline_enable', // 离线收益功能启用(未解锁不计离线;判据5 压力的 M1 缓冲)
'system', // M2 系统前瞻(arrivesAt 必填;M1 无实效,UI 预告)
]);
export const UNLOCK_TREE = Object.freeze([
// ── 甜品线(unlock_recipe ×6)────────────────────────────────────────────
{ id: 'n_r_zhimaqiu', name: '芝麻球', kind: 'unlock_recipe', target: 'r_zhimaqiu', deps: [], cost_coins: 30, min_prestige: 0 },
{ id: 'n_r_kaohongshu', name: '烤红薯', kind: 'unlock_recipe', target: 'r_kaohongshu', deps: ['n_r_zhimaqiu'], cost_coins: 45, min_prestige: 0 },
{ id: 'n_r_nuomici', name: '红糖糍粑', kind: 'unlock_recipe', target: 'r_nuomici', deps: ['n_r_kaohongshu'], cost_coins: 80, min_prestige: 1 },
{ id: 'n_r_danhuasu', name: '蛋花酥', kind: 'unlock_recipe', target: 'r_danhuasu', deps: ['n_r_nuomici'], cost_coins: 110, min_prestige: 1 },
{ id: 'n_r_tangshanyao', name: '拔丝山药', kind: 'unlock_recipe', target: 'r_tangshanyao', deps: ['n_r_danhuasu'], cost_coins: 200, min_prestige: 2 },
{ id: 'n_r_guihuagao', name: '桂花年糕', kind: 'unlock_recipe', target: 'r_guihuagao', deps: ['n_r_tangshanyao'], cost_coins: 320, min_prestige: 4 },
// ── 主食线(unlock_recipe ×5)────────────────────────────────────────────
{ id: 'n_r_congyoubing', name: '葱油饼', kind: 'unlock_recipe', target: 'r_congyoubing', deps: [], cost_coins: 30, min_prestige: 0 },
{ id: 'n_r_jiroujuan', name: '鸡蛋灌饼', kind: 'unlock_recipe', target: 'r_jiroujuan', deps: ['n_r_congyoubing'], cost_coins: 50, min_prestige: 0 },
{ id: 'n_r_chaomian', name: '铁板炒面', kind: 'unlock_recipe', target: 'r_chaomian', deps: ['n_r_jiroujuan'], cost_coins: 90, min_prestige: 1 },
{ id: 'n_r_jianjiao', name: '脆底煎饺', kind: 'unlock_recipe', target: 'r_jianjiao', deps: ['n_r_chaomian'], cost_coins: 120, min_prestige: 1 },
{ id: 'n_r_jianbingsu', name: '金黄煎蛋饼', kind: 'unlock_recipe', target: 'r_jianbingsu', deps: ['n_r_jianjiao'], cost_coins: 220, min_prestige: 3 },
// ── 香辣线(unlock_recipe ×2)────────────────────────────────────────────
{ id: 'n_r_liangpi', name: '麻酱凉皮', kind: 'unlock_recipe', target: 'r_liangpi', deps: [], cost_coins: 40, min_prestige: 0 },
{ id: 'n_r_shaokao', name: '烤蔬菜串', kind: 'unlock_recipe', target: 'r_shaokao', deps: ['n_r_liangpi'], cost_coins: 70, min_prestige: 1 },
// ── 海鲜线(unlock_recipe ×3;吃灶台扩容)─────────────────────────────────
{ id: 'n_r_haixianzhou', name: '海鲜砂锅粥', kind: 'unlock_recipe', target: 'r_haixianzhou', deps: ['n_zao3'], cost_coins: 180, min_prestige: 2 },
{ id: 'n_r_laweiguo', name: '香辣海鲜锅', kind: 'unlock_recipe', target: 'r_laweiguo', deps: ['n_r_haixianzhou'], cost_coins: 260, min_prestige: 3 },
{ id: 'n_r_yexiaopin', name: '招牌夜宵拼盘', kind: 'unlock_recipe', target: 'r_yexiaopin', deps: ['n_r_laweiguo', 'n_r_guihuagao'], cost_coins: 420, min_prestige: 6 },
// ── 灶台扩容(stove_slot ×3:2→5)────────────────────────────────────────
{ id: 'n_zao3', name: '第三口灶', kind: 'stove_slot', value: 1, deps: [], cost_coins: 120, min_prestige: 1 },
{ id: 'n_zao4', name: '第四口灶', kind: 'stove_slot', value: 1, deps: ['n_zao3'], cost_coins: 260, min_prestige: 3 },
{ id: 'n_zao5', name: '第五口灶', kind: 'stove_slot', value: 1, deps: ['n_zao4'], cost_coins: 480, min_prestige: 6 },
// ── 座位扩容(seat ×3:6→9)──────────────────────────────────────────────
{ id: 'n_zuo7', name: '加一张马扎', kind: 'seat', value: 1, deps: [], cost_coins: 60, min_prestige: 0 },
{ id: 'n_zuo8', name: '拼一张折叠桌', kind: 'seat', value: 1, deps: ['n_zuo7'], cost_coins: 140, min_prestige: 2 },
{ id: 'n_zuo9', name: '再撑一把伞', kind: 'seat', value: 1, deps: ['n_zuo8'], cost_coins: 300, min_prestige: 4 },
// ── 服务与节奏 ─────────────────────────────────────────────────────────────
{ id: 'n_dengzi', name: '舒适马扎', kind: 'patience_bonus', value: 0.4, deps: [], cost_coins: 90, min_prestige: 1 },
{ id: 'n_yinliao', name: '免费凉茶', kind: 'patience_bonus', value: 0.4, deps: ['n_dengzi'], cost_coins: 200, min_prestige: 3 },
{ id: 'n_zhaopai', name: '手写招牌', kind: 'tip_bonus', value: 0.02, deps: [], cost_coins: 70, min_prestige: 0 },
{ id: 'n_dengchuan', name: '氛围灯串', kind: 'tip_bonus', value: 0.02, deps: ['n_zhaopai'], cost_coins: 160, min_prestige: 2 },
{ id: 'n_yaohe', name: '穿街吆喝', kind: 'spawn_rate', value: 0.92, deps: [], cost_coins: 100, min_prestige: 1 },
{ id: 'n_koubei', name: '口碑传开', kind: 'spawn_rate', value: 0.92, deps: ['n_yaohe'], cost_coins: 240, min_prestige: 3 },
{ id: 'n_shoutao', name: '利落手套', kind: 'combo_window', value: 2, deps: [], cost_coins: 110, min_prestige: 1 },
{ id: 'n_shuangdao', name: '双刀快手', kind: 'combo_stack', value: 2, deps: ['n_shoutao'], cost_coins: 180, min_prestige: 2 },
{ id: 'n_xiaopai', name: '致歉小牌', kind: 'rep_shield', value: 1, deps: [], cost_coins: 220, min_prestige: 3 },
// ── 库存与进货 ─────────────────────────────────────────────────────────────
{ id: 'n_bingxiang', name: '二手小冰柜', kind: 'freshness_bonus', value: 1, deps: [], cost_coins: 150, min_prestige: 2 },
{ id: 'n_baoxianhe', name: '成套保鲜盒', kind: 'freshness_bonus', value: 1, deps: ['n_bingxiang'], cost_coins: 280, min_prestige: 4 },
{ id: 'n_caigou', name: '相熟菜贩', kind: 'market_discount', value: 0.95, deps: [], cost_coins: 130, min_prestige: 2 },
{ id: 'n_pifa', name: '批发渠道', kind: 'market_discount', value: 0.90, deps: ['n_caigou'], cost_coins: 300, min_prestige: 5 },
// ── 离线收益启用(根节点,早期低价可达)────────────────────────────────────
{ id: 'n_yedeng', name: '值夜摊灯', kind: 'offline_enable', deps: [], cost_coins: 50, min_prestige: 0 },
// ── M2 系统前瞻(system ×4,honest arrivesAt)───────────────────────────────
{ id: 'n_sys_expand', name: '第二摊位(筹)', kind: 'system', deps: ['n_zao3'], cost_coins: 500, min_prestige: 6, arrivesAt: 'M2' },
{ id: 'n_sys_rnd', name: '研发工作台(筹)', kind: 'system', deps: ['n_r_danhuasu'], cost_coins: 400, min_prestige: 5, arrivesAt: 'M2' },
{ id: 'n_sys_staff', name: '招工启事(筹)', kind: 'system', deps: ['n_zuo8'], cost_coins: 450, min_prestige: 5, arrivesAt: 'M2' },
{ id: 'n_sys_event', name: '夜市百态(筹)', kind: 'system', deps: ['n_koubei'], cost_coins: 400, min_prestige: 6, arrivesAt: 'M2' },
]);

View File

@ -0,0 +1,80 @@
/**
* src/host-shadow-store.js 影子存档存储(IndexedDB 直写;开发期存档面,设计书 §8)
* owner:北极星顶级线①(W-POLARIS)· M1 存档落盘件
*
* 定位如实这是**开发期影子口径**(设计书 §8:正式 SDK Storage `save:` 槽归 M5 切换),
* 存的值 = SaveEnvelope 信封原样(与正式通道同形状,届时零迁移)
* 影子期一切存档验收只按影子口径记录,不宣称正式存档验收通过
* 环境兜底 IndexedDB(node/受限 WebView)回落进程内 Map(不持久,可玩性不受阻
* 程序化兜底红线同族:缺存储不崩游戏);打开/读写失败一律 console.warn 留痕后走兜底
*/
'use strict';
const DB_NAME = 'night-market-shadow';
const STORE = 'saves';
const KEY = 'shadow:night-market:v1'; // 影子键(非 save: 白名单族;M5 切正式槽时换键零迁移)
/** 内存兜底(无 IDB 环境;同会话内往返可用)。 */
const memFallback = new Map();
function openDb() {
return new Promise((resolve) => {
if (typeof indexedDB === 'undefined') return resolve(null);
try {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => { req.result.createObjectStore(STORE); };
req.onsuccess = () => resolve(req.result);
req.onerror = () => { console.warn('[shadow-store] IndexedDB 打开失败,回落内存', req.error); resolve(null); };
} catch (e) {
console.warn('[shadow-store] IndexedDB 异常,回落内存', e);
resolve(null);
}
});
}
/** 创建影子存储(get/put 均异步;失败静默降级不抛——存档失败不阻塞可玩)。 */
export function createShadowStore() {
const dbP = openDb();
return {
/** 读存档信封;无/失败返 null。 */
async get() {
const db = await dbP;
if (!db) return memFallback.get(KEY) || null;
return new Promise((resolve) => {
try {
const tx = db.transaction(STORE, 'readonly');
const rq = tx.objectStore(STORE).get(KEY);
rq.onsuccess = () => resolve(rq.result || null);
rq.onerror = () => { console.warn('[shadow-store] 读失败', rq.error); resolve(null); };
} catch (e) { console.warn('[shadow-store] 读异常', e); resolve(null); }
});
},
/** 写存档信封(SaveEnvelope 原样);失败留痕不抛。 */
async put(envelope) {
const db = await dbP;
if (!db) { memFallback.set(KEY, envelope); return true; }
return new Promise((resolve) => {
try {
const tx = db.transaction(STORE, 'readwrite');
const rq = tx.objectStore(STORE).put(envelope, KEY);
rq.onsuccess = () => resolve(true);
rq.onerror = () => { console.warn('[shadow-store] 写失败', rq.error); resolve(false); };
} catch (e) { console.warn('[shadow-store] 写异常', e); resolve(false); }
});
},
/** 清档(调试用;index.html ?reset=1)。 */
async clear() {
const db = await dbP;
if (!db) { memFallback.delete(KEY); return true; }
return new Promise((resolve) => {
try {
const tx = db.transaction(STORE, 'readwrite');
const rq = tx.objectStore(STORE).delete(KEY);
rq.onsuccess = () => resolve(true);
rq.onerror = () => resolve(false);
} catch (_) { resolve(false); }
});
},
};
}

View File

@ -1,10 +1,18 @@
/**
* src/main.js 装配入口(Phaser 工厂;对齐 tier2 host 形态:默认导出工厂readState/_forensicsView 透出)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §8
* owner:北极星顶级线①(W-POLARIS)· 设计书 §8 · M1(存档恢复/命令取证/门参数化)
*
* 构建:tier2/engine/build-phaser.mjs(esbuild iife;'phaser' alias 垫片解析到宿主预载的
* window.Phaser,引擎不进 bundle两本账账二的引擎冷载项即宿主预载这份)
* globalName __NightMarketBundle(index.html 消费; tier2 host 时按 buildProfile 再对齐)
*
* M1 装配职责纯逻辑内核之外的宿主粘合全在这层:
* - resume:开局前导入影子存档(信封经 opts.resume 注入;离线收益在核内结算,fx 透出渲染)
* - persist:跨日推进后回写存档(opts.persist 注入;写失败不阻塞影子口径,失败留痕)
* - callTrace:命令面取证(opts.callTrace 注入;九门 F_wiring window.__engineCalls 供给端
* 只包命令方法,读路径零噪声)
* - onReady:Phaser 'ready' 后回调(index.html __tier2Booted,九门 A_boot 信号)
* - dayLengthS/seed/deviceTier:门与测试的参数化注入口(URL opts;数值缺省仍 economy_curve 单源)
*/
'use strict';
@ -15,20 +23,72 @@ import { createSeededRandom } from './core/seeded-random.js';
import { VIEW_W, VIEW_H } from './core/layout.js';
import { MainScene } from './scenes/main-scene.js';
// 影子存储随 bundle 透出(index.html 经 __NightMarketBundle.createShadowStore 消费,页面零散装依赖)
export { createShadowStore } from './host-shadow-store.js';
/** 命令面取证包装:mutator 调用前推 call-ID(F 门真接线量;读方法不包,取证零噪声)。 */
function instrument(core, trace) {
if (typeof trace !== 'function') return core;
const wrap = (name) => {
const fn = core[name].bind(core);
return (...args) => { trace(`core.${name}`); return fn(...args); };
};
return {
...core,
start: wrap('start'),
tapCustomer: wrap('tapCustomer'),
tapAt: wrap('tapAt'),
buyIngredient: wrap('buyIngredient'),
autoRestock: wrap('autoRestock'),
unlockNode: wrap('unlockNode'),
settleAndAdvanceDay: wrap('settleAndAdvanceDay'),
importSave: wrap('importSave'),
};
}
/**
* 创建夜市一条街实例(宿主唯一入口)
* @param {Object} [opts]
* @param {string|HTMLElement} [opts.parent] Phaser 挂载点(缺省 body)
* @param {number} [opts.seed=20260706] 受控随机种子(真玩给固定默认;仿真/门按局注入)
* @param {string} [opts.deviceTier='mid'] 设备档(screen_cap 钳制)
* @returns {{ game: Phaser.Game, readState:()=>Object, _forensicsView:()=>Object, destroy:()=>void }}
* @param {number} [opts.dayLengthS] 营业日时长覆盖(;九门 CDP 窗口用短日,缺省 600 单源)
* @param {{envelope:Object, nowMs:number}} [opts.resume] 开局恢复(影子存档信封;离线收益核内结算)
* @param {(envelope:Object)=>void} [opts.persist] 存档回写(跨日推进后调;异步失败不阻塞)
* @param {()=>number} [opts.now] 墙钟供给(缺省 Date.now;核内零取时,一律注入)
* @param {(callId:string)=>void} [opts.callTrace] 命令取证(九门 F_wiring)
* @param {()=>void} [opts.onReady] Phaser 就绪回调(九门 A_boot 信号端)
* @returns {{ game: Phaser.Game, readState:()=>Object, _forensicsView:()=>Object, exportSave:(nowMs?:number)=>Object, destroy:()=>void }}
*/
export default function createNightMarket(opts = {}) {
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
// 纯逻辑内核先建(渲染层只是它的观察者)
const core = createGameCore({
const rawCore = createGameCore({
random: createSeededRandom(opts.seed || 20260706),
deviceTier: opts.deviceTier || 'mid',
dayLengthS: opts.dayLengthS,
});
const core = instrument(rawCore, opts.callTrace);
// 开局恢复(影子存档;拒读=newer_schema/bad_envelope 时按新局开,原档由存储层保留不毁)
let resumeInfo = null;
if (opts.resume && opts.resume.envelope) {
const r = core.importSave(opts.resume.envelope, { nowMs: opts.resume.nowMs != null ? opts.resume.nowMs : now() });
resumeInfo = r;
if (!r.ok) console.warn('[night-market] 存档拒读(不毁档,按新局开):', r.reason);
}
// 宿主粘合(场景消费:跨日推进 + 回写存档,一处收口)
const glue = {
/** 跨日推进 + 存档回写(day_end 面板「开市」按钮的唯一实现)。 */
advanceDay() {
const rep = core.settleAndAdvanceDay({ nowMs: now() });
if (rep.ok && typeof opts.persist === 'function') {
try { opts.persist(core.exportSave(now())); } catch (e) { console.warn('[night-market] 存档回写失败(不阻塞):', e); }
}
return rep;
},
};
const game = new Phaser.Game({
type: Phaser.AUTO,
@ -37,8 +97,9 @@ export default function createNightMarket(opts = {}) {
height: VIEW_H,
backgroundColor: '#0b0f22',
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
scene: new MainScene(core),
scene: new MainScene(core, glue),
});
if (typeof opts.onReady === 'function') game.events.once('ready', opts.onReady);
return {
game,
@ -46,6 +107,10 @@ export default function createNightMarket(opts = {}) {
readState: () => core.readState(),
/** 取证视图透出(A6 惯例:挂 window 由宿主页面做,工厂只透出)。 */
_forensicsView: () => core._forensicsView(),
/** 存档导出透出(页面 beforeunload 兜底回写用)。 */
exportSave: (nowMs) => core.exportSave(nowMs != null ? nowMs : now()),
/** 恢复结果(页面可读离线结算摘要;fx 事件另有渲染路)。 */
resumeInfo,
destroy() { game.destroy(true); },
};
}

View File

@ -1,9 +1,10 @@
/**
* src/scenes/main-scene.js 主场景(街景渲染 + 触控输入 + juice;M0 程序化占位美术)
* src/scenes/main-scene.js 主场景(街景渲染 + 触控输入 + juice + 日结面板;M0/M1 程序化占位美术)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §7 主场景零层级
*
* 渲染只读铁律本场景只读 core.readState()/drainFx(),写操作只有 core.tapAt(单击可达)
* 占位美术全部 Phaser Graphics/文本程序化绘制,零二进制资产(账一 assets=0 M0 实测口径);
* 渲染只读铁律本场景只读 core.readState()/drainFx(),写操作只有 core.tapAt(单击可达)
* 日结面板三按钮(补货 core.autoRestock / 解锁 core.unlockNode / 开市 glue.advanceDay)
* 占位美术全部 Phaser Graphics/文本程序化绘制,零二进制资产(账一 assets=0 的实测口径);
* 风格换装归 M4(风格版拍定后 U10/U11)夜色基调按候选 A暖夜霓虹预演,不锁风格
*/
@ -14,10 +15,14 @@ import { VIEW_W, VIEW_H, SEATS, STOVES, STALL, TAP_RADIUS } from '../core/layout
import { Hud } from '../ui/hud.js';
export class MainScene extends Phaser.Scene {
/** @param {Object} core 纯逻辑内核(boot 装配注入) */
constructor(core) {
/**
* @param {Object} core 纯逻辑内核(boot 装配注入;可能已被 main.js 命令取证包装)
* @param {{advanceDay:()=>Object}} [glue] 宿主粘合(跨日推进+存档回写;缺省裸推进,headless 兼容)
*/
constructor(core, glue) {
super({ key: 'main' });
this.core = core;
this.glue = glue || { advanceDay: () => core.settleAndAdvanceDay({ nowMs: 0 }) };
/** @type {Map<number, Phaser.GameObjects.Container>} 顾客 id → 渲染容器 */
this.customerNodes = new Map();
}
@ -36,7 +41,7 @@ export class MainScene extends Phaser.Scene {
for (let i = 0; i < 9; i++) {
this.add.circle(24 + i * 43, 96, 4, 0xffe08a, 0.9);
}
// 灶台位
// 灶台位(坐标上限 5;实际启用数随解锁动态,update 里按 readState().stoves 显隐)
this.stoveNodes = STOVES.map((p, i) => {
const g = this.add.container(p.x, p.y);
g.add(this.add.circle(0, 0, 26, 0x60381e).setStrokeStyle(3, 0xff9950));
@ -47,28 +52,33 @@ export class MainScene extends Phaser.Scene {
g.add(flame); g.add(bar);
g._flame = flame; g._bar = bar;
g.add(this.add.text(0, -40, `${i + 1}`, { fontFamily: 'sans-serif', fontSize: '12px', color: '#c8d2f0' }).setOrigin(0.5));
g.setVisible(false); // 启用数在 update 同步
return g;
});
// 座位标记
for (const s of SEATS) this.add.circle(s.x, s.y, 8, 0x2e3550).setStrokeStyle(2, 0x4a5378);
// 座位标记(同灶台:按启用数显隐)
this.seatNodes = SEATS.map((s) => this.add.circle(s.x, s.y, 8, 0x2e3550).setStrokeStyle(2, 0x4a5378).setVisible(false));
// HUD
this.hud = new Hud(this, VIEW_W);
// 触控:单击可达(经 core.tapAt 命中,渲染与命中同一坐标源)
this.input.on('pointerdown', (pointer) => {
if (this._panel) return; // 日结面板期间主场景不吃点(按钮自带交互)
const r = c.tapAt(pointer.x, pointer.y);
if (!r.ok && r.reason === 'stoves_busy') {
// 灶台全忙的轻提示(不弹窗不阻断;错误路径可感知)
this.hud.floatText(pointer.x, pointer.y, '灶台忙!', '#ff9950');
} else if (!r.ok && r.reason === 'no_stock') {
this.hud.floatText(pointer.x, pointer.y, '缺食材!日结补货', '#ff8a8a'); // M1 轻提示;日中进货 UI 归 M2 dock
}
});
// 开局(M0 直接进营业;标题页归 M6 打磨)
// 开局(恢复局:第 N 日继续;标题页归 M6 打磨)
c.start();
// 首反馈锚:开场提示(<2s 内首客即到,economy_curve 首客 0.5s)
this.hud.floatText(VIEW_W / 2, VIEW_H * 0.42, '开市!点顾客上菜', '#ffe9b8');
const st0 = c.readState();
this.hud.floatText(VIEW_W / 2, VIEW_H * 0.42,
st0.day > 1 ? `${st0.day} 天,继续经营!` : '开市!点顾客上菜', '#ffe9b8');
this._panel = null;
this._dayEndShown = false;
}
@ -83,7 +93,6 @@ export class MainScene extends Phaser.Scene {
seen.add(cu.id);
let node = this.customerNodes.get(cu.id);
if (!node) node = this._spawnCustomerNode(cu);
// 耐心条与烹饪态着色
const ratio = cu.patienceMax > 0 ? cu.patienceLeft / cu.patienceMax : 0;
node._bar.width = 44 * Math.max(0, ratio);
node._bar.fillColor = ratio > 0.5 ? 0x9ef0a0 : (ratio > 0.25 ? 0xffd166 : 0xff6b6b);
@ -94,13 +103,18 @@ export class MainScene extends Phaser.Scene {
if (!seen.has(id)) { node.destroy(); this.customerNodes.delete(id); }
}
// ── 灶台态 ──
for (let i = 0; i < st.stoves.length; i++) {
const sv = st.stoves[i]; const g = this.stoveNodes[i];
// ── 灶台/座位启用与工作态(容量随解锁动态)──
for (let i = 0; i < this.stoveNodes.length; i++) {
const g = this.stoveNodes[i];
const sv = st.stoves[i];
g.setVisible(!!sv);
if (!sv) continue;
g._flame.setVisible(!!sv.busy);
g._bar.setVisible(!!sv.busy);
if (sv.busy && sv.total_s > 0) g._bar.width = 48 * (1 - sv.remain_s / sv.total_s);
}
const activeSeats = this.core._forensicsView().econEcho().seatCount;
for (let i = 0; i < this.seatNodes.length; i++) this.seatNodes[i].setVisible(i < activeSeats);
// ── juice 事件(飘字;音效归 U12)──
for (const ev of c.drainFx()) {
@ -110,9 +124,19 @@ export class MainScene extends Phaser.Scene {
} else if (ev.type === 'miss') {
const seat = SEATS[ev.seat];
this.hud.floatText(seat.x, seat.y - 30, `${ev.typeName}走了…`, '#ff8a8a');
} else if (ev.type === 'unlock') {
this.hud.floatText(VIEW_W / 2, VIEW_H * 0.36, `解锁「${ev.name}」!`, '#9ef0ff');
} else if (ev.type === 'milestone') {
this.hud.floatText(VIEW_W / 2, VIEW_H * 0.32, `成就「${ev.name}」声望+${ev.prestige}`, '#c98bff');
} else if (ev.type === 'offline') {
this.hud.floatText(VIEW_W / 2, VIEW_H * 0.40, `离线收益 +${ev.credited}(${ev.elapsedMin}分钟)`, '#ffe08a');
} else if (ev.type === 'restock') {
this.hud.floatText(VIEW_W / 2, VIEW_H * 0.44, `补货 ${ev.kinds}${ev.spent}`, '#c8d2f0');
} else if (ev.type === 'day_end' && !this._dayEndShown) {
this._dayEndShown = true;
this._showDayEnd(ev);
} else if (ev.type === 'day_start') {
this._dayEndShown = false;
}
}
@ -133,26 +157,73 @@ export class MainScene extends Phaser.Scene {
const bar = this.add.rectangle(0, 36, 44, 6, 0x9ef0a0).setOrigin(0.5);
node.add(body); node.add(label); node.add(order); node.add(bar);
node._body = body; node._bar = bar;
// 命中区可视化调试留口(TAP_RADIUS 热区;正式版不画)
void TAP_RADIUS;
this.customerNodes.set(cu.id, node);
// 进店缓动(占位 juice)
node.setScale(0.2);
this.tweens.add({ targets: node, scale: 1, duration: 220, ease: 'Back.easeOut' });
return node;
}
/** 营业日结算面板(M0 最简;完整日结算归 M1 经济骨架)。 */
/** 简易按钮(程序化占位;M4 换皮)。 */
_button(container, x, y, w, label, color, onTap) {
const bg = this.add.rectangle(x, y, w, 44, color, 0.92).setStrokeStyle(2, 0xffffff, 0.35)
.setDepth(82).setInteractive({ useHandCursor: true });
const txt = this.add.text(x, y, label, {
fontFamily: 'sans-serif', fontSize: '16px', color: '#101426',
}).setOrigin(0.5).setDepth(83);
bg.on('pointerdown', onTap);
container.push(bg, txt);
return { bg, txt };
}
/**
* 营业日结算面板(M1:结算行 + 补货/解锁/开市三按钮 = 经济骨架的经营决策面;
* 全屏树视图/进货页归 M2 dock,本面板是它们的最小可玩前身)
*/
_showDayEnd(ev) {
this.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W - 60, 260, 0x101426, 0.95)
.setStrokeStyle(3, 0xffd166).setDepth(80);
this.add.text(VIEW_W / 2, VIEW_H / 2 - 84, '打烊喽', {
fontFamily: 'sans-serif', fontSize: '28px', color: '#ffd166',
const parts = [];
this._panel = parts;
const cx = VIEW_W / 2, cy = VIEW_H / 2;
parts.push(this.add.rectangle(cx, cy, VIEW_W - 48, 380, 0x101426, 0.96)
.setStrokeStyle(3, 0xffd166).setDepth(80));
parts.push(this.add.text(cx, cy - 156, `${ev.day} 天 · 打烊喽`, {
fontFamily: 'sans-serif', fontSize: '26px', color: '#ffd166',
}).setOrigin(0.5).setDepth(81));
parts.push(this.add.text(cx, cy - 92,
`今日净入 ${ev.dayNet} 金币(总 ${ev.coins})\n出餐 ${ev.served} 单 · 翻台 ${ev.missed}`,
{ fontFamily: 'sans-serif', fontSize: '17px', color: '#e8ecff', align: 'center', lineSpacing: 8 },
).setOrigin(0.5).setDepth(81));
// 状态行(补货/解锁按钮的反馈落这里,不弹层)
const statusText = this.add.text(cx, cy - 34, '打烊盘点:先补货,再看看能解锁点什么', {
fontFamily: 'sans-serif', fontSize: '13px', color: '#c8d2f0', align: 'center', wordWrap: { width: VIEW_W - 90 },
}).setOrigin(0.5).setDepth(81);
this.add.text(
VIEW_W / 2, VIEW_H / 2 + 6,
`今日净入 ${ev.coins} 金币\n出餐 ${ev.served} 单 · 翻台 ${ev.missed}`,
{ fontFamily: 'sans-serif', fontSize: '18px', color: '#e8ecff', align: 'center', lineSpacing: 10 },
).setOrigin(0.5).setDepth(81);
parts.push(statusText);
// ① 补货(economy_curve 缺省策略:补到 stock_target)
this._button(parts, cx, cy + 22, VIEW_W - 96, '进货补齐(自动)', 0x9ef0a0, () => {
const r = this.core.autoRestock();
statusText.setText(r.spent > 0 ? `补货 ${r.bought.length} 种,花费 ${r.spent} 金币` : '库存充足,无需补货');
});
// ② 解锁(够得着最便宜的一个;完整树视图归 M2)
this._button(parts, cx, cy + 78, VIEW_W - 96, '解锁下一项', 0x9ec7ff, () => {
const aff = this.core.affordableUnlocks ? this.core.affordableUnlocks() : [];
if (!aff.length) { statusText.setText('暂无够得着的解锁项(攒钱/攒声望)'); return; }
const r = this.core.unlockNode(aff[0].id);
statusText.setText(r.ok
? `解锁「${r.node.name}」!${r.node.arrivesAt ? '(' + r.node.arrivesAt + ' 开张)' : ''}`
: `解锁失败:${r.reason}`);
});
// ③ 开市下一日(跨日推进 + 存档回写,经宿主粘合一处收口)
this._button(parts, cx, cy + 134, VIEW_W - 96, '开市,下一天!', 0xffd166, () => {
const rep = this.glue.advanceDay();
if (!rep.ok) { statusText.setText(`推进失败:${rep.reason}`); return; }
for (const p of parts) p.destroy();
this._panel = null;
const msLine = rep.milestones.length ? `成就 ${rep.milestones.map((m) => m.name).join('、')}!` : '';
const wasteLine = rep.expired.length ? `过期损耗 ${rep.expired.map((e) => `${e.name}×${e.units}`).join(' ')}` : '';
if (msLine) this.hud.floatText(cx, VIEW_H * 0.32, msLine, '#c98bff');
if (wasteLine) this.hud.floatText(cx, VIEW_H * 0.46, wasteLine, '#ff9950');
this.hud.floatText(cx, VIEW_H * 0.40, `${rep.nextDay} 天开市!`, '#ffe9b8');
});
}
}

View File

@ -1,6 +1,6 @@
/**
* src/systems/service-loop.js 系统①服务循环(顾客进店点单烹饪上菜结账口碑;纯逻辑)
* owner:北极星顶级线①(W-POLARIS)· 底稿 §1.1 · 设计书 §9 M0
* owner:北极星顶级线①(W-POLARIS)· 底稿 §1.1 · 设计书 §9 M0/M1
*
*
* 职责边界(设计书 §8 代码纪律第 5 :系统单向依赖, core 状态与事件总线通信)
@ -8,11 +8,14 @@
* 金币·口碑·连击的**结算归 core**(经济结算是 core 的职责):本系统产出结算事件
* (serve/miss),core 消费事件记账本文件不 import core不碰渲染
*
* M1 动态化菜单/座位数/灶台数/耐心与节拍修饰/库存判定,全部经 deps **函数注入**动态读取
* (解锁树节点即时生效,本系统零解锁逻辑);库存扣减在 cook_start 时经 deps.consumeFor 执行
* (食材在下锅一刻消耗三资源之食材环的接点),缺料经 deps.canServe 前置拒绝(reason='no_stock')
*
* 单击可达红线(底稿 §1.1.3 C·skill §9 driver 可达)
* 点等待中的顾客 = 按其点单自动占灶出餐,一步到位;不做先点顾客再点货架两步匹配
*
* 耐心口径(M0)等待中衰减;开始烹饪即锁定(玩家已响应,承诺必达)
* 先服务谁的优先级判断来自各顾客剩余耐心的可见差异(底稿:耐心条是节拍器)
*
* 纯逻辑铁律 DOM/canvas/引擎;时间经 update(dt) 注入;随机经受控源注入
*
@ -24,36 +27,48 @@
* 创建服务循环系统
* @param {Object} deps
* @param {{next:()=>number,range:(a:number,b:number)=>number,pick:(a:any[])=>any}} deps.random 受控随机源
* @param {Object} deps.econ economy_curve (spawn_interval/patience/stove/combo 消费方)
* @param {Array} deps.recipes 菜谱表
* @param {Object} deps.econ economy_curve (spawn_interval/patience/combo 消费方)
* @param {()=>Array} deps.menu 当前可点菜单(core 按解锁树过滤;动态)
* @param {Array} deps.customerTypes 顾客类型表
* @param {Array} deps.seats 座位逻辑坐标(layout 单源)
* @param {Array} deps.seats 座位逻辑坐标全量(layout 单源)
* @param {()=>number} deps.seatCount 当前启用座位数(解锁树 seat 节点动态)
* @param {()=>number} deps.stoveSlots 当前灶台数(解锁树 stove_slot 节点动态)
* @param {()=>number} deps.patienceBonusS 耐心加成秒(解锁树 patience_bonus 动态)
* @param {()=>number} deps.spawnMult 生成间隔乘子(解锁树 spawn_rate 动态,<1=更快)
* @param {(recipeId:string)=>boolean} deps.canServe 库存足以做这道菜(core 持库存)
* @param {(recipeId:string)=>void} deps.consumeFor 扣减库存(cook_start ;core 持库存)
* @param {string} [deps.deviceTier='mid'] 设备档(screen_cap 钳制)
*/
export function createServiceLoop(deps) {
const { random: rng, econ, recipes, customerTypes, seats } = deps;
const { random: rng, econ, customerTypes, seats } = deps;
const deviceTier = deps.deviceTier || 'mid';
// ── 内部状态 ──────────────────────────────────────────────────────────────
let nextCustomerId = 1;
/** @type {Array<Object>} 在场顾客(waiting/cooking;served/left 即离场移除,离场信息走事件)。 */
const customers = [];
/** 灶台槽:null=空闲,否则 { customerId, recipeId, remain_s }。 */
const stoves = new Array(econ.stove.slots).fill(null);
let customers = [];
/** 灶台槽:null=空闲,否则 { customerId, recipeId, remain_s }。长度随 stoveSlots() 动态增长。 */
let stoves = new Array(deps.stoveSlots()).fill(null);
/** 距下一次生成的剩余秒(首客 0.5s 内到,保证「首反馈 <2s」有客可点)。 */
let spawnCooldown = 0.5;
/** 结算事件队列(core 每帧取走记账;渲染层经 core 转发做 juice)。 */
const events = [];
/** 取证计数(_forensicsView 消费)。 */
const counts = { spawned: 0, tapped: 0, served: 0, missed: 0 };
/** 取证计数(_forensicsView 消费;跨日累计)。 */
const counts = { spawned: 0, tapped: 0, served: 0, missed: 0, noStock: 0 };
/** 灶台数动态同步(只增不缩:解锁树只加灶;缩会丢在烹任务,M1 无缩容场景)。 */
function syncStoves() {
const want = deps.stoveSlots();
while (stoves.length < want) stoves.push(null);
}
/** 高峰判定:营业日进度 ≥ peak_start_ratio(跨日拉伸待校准,§4)。 */
function isPeak(dayProgress) { return dayProgress >= econ.spawn_interval.peak_start_ratio; }
/** 同屏上限:清闲/高峰节拍上限,再受设备档 screen_cap 钳制(底稿曲线 6 两重身份)。 */
/** 同屏上限:清闲/高峰节拍上限,再受设备档 screen_cap 与启用座位数钳制。 */
function onscreenCap(dayProgress) {
const beat = isPeak(dayProgress) ? econ.spawn_interval.peak_onscreen_cap : econ.spawn_interval.idle_onscreen_cap;
return Math.min(beat, econ.screen_cap[deviceTier]);
return Math.min(beat, econ.screen_cap[deviceTier], deps.seatCount());
}
/** 按出现权重抽一个顾客类型(受控随机)。 */
@ -64,21 +79,26 @@ export function createServiceLoop(deps) {
return customerTypes[customerTypes.length - 1];
}
/** 按偏好 tag 点单:偏好命中集里抽,无命中则全菜单均匀(保证永远点得出)。 */
/** 按偏好 tag 点单:当前菜单的偏好命中集里抽,无命中则全菜单均匀(保证永远点得出)。 */
function pickOrder(type) {
const liked = recipes.filter((r) => r.tags.some((tg) => type.prefer_tags.includes(tg)));
return rng.pick(liked.length ? liked : recipes);
const menu = deps.menu();
const liked = menu.filter((r) => r.tags.some((tg) => type.prefer_tags.includes(tg)));
return rng.pick(liked.length ? liked : menu);
}
/** 生成一位顾客(有空座才调;耐心 = 基线 × 稀有 × 高峰修饰,economy_curve 单源)。 */
/** 生成一位顾客(有空座才调;耐心 = (基线+加成) × 稀有 × 高峰修饰,economy_curve 单源)。 */
function spawnCustomer(dayProgress) {
const seatIdx = seats.findIndex((_, i) => !customers.some((c) => c.seat === i));
const activeSeats = deps.seatCount();
let seatIdx = -1;
for (let i = 0; i < activeSeats; i++) {
if (!customers.some((c) => c.seat === i)) { seatIdx = i; break; }
}
if (seatIdx < 0) return; // 无空座(同屏上限已卡,此处兜底)
const type = pickType();
const recipe = pickOrder(type);
const peakMult = isPeak(dayProgress) ? econ.patience.peak_mult : 1;
const rareMult = type.rarity === 'rare' ? econ.patience.rare_mult : 1;
const patienceMax = type.patience_base_s * rareMult * peakMult;
const patienceMax = (type.patience_base_s + deps.patienceBonusS()) * rareMult * peakMult;
const c = {
id: nextCustomerId++,
typeId: type.id, typeName: type.name, rarity: type.rarity, tipCoef: type.tip_coef,
@ -93,11 +113,11 @@ export function createServiceLoop(deps) {
}
/** 找空闲灶台下标;无则 -1。 */
function freeStove() { return stoves.findIndex((s) => s === null); }
function freeStove() { syncStoves(); return stoves.findIndex((s) => s === null); }
return {
/**
* 玩家动作:点顾客即成单(单击可达红线)等待中 + 有空灶 开始烹饪
* 玩家动作:点顾客即成单(单击可达红线)等待中 + 有空灶 + 有料 扣料开始烹饪
* @returns {{ok:boolean, reason?:string}} 失败原因可追溯(渲染层据此给轻提示)
*/
tapCustomer(customerId) {
@ -107,7 +127,10 @@ export function createServiceLoop(deps) {
if (c.state !== 'waiting') return { ok: false, reason: 'already_cooking' };
const slot = freeStove();
if (slot < 0) return { ok: false, reason: 'stoves_busy' }; // 灶台全忙:玩家的调度压力所在
const recipe = recipes.find((r) => r.id === c.orderRecipeId);
const recipe = deps.menu().find((r) => r.id === c.orderRecipeId);
if (!recipe) return { ok: false, reason: 'not_on_menu' }; // 防御:点单菜谱已不在菜单(不应发生)
if (!deps.canServe(recipe.id)) { counts.noStock++; return { ok: false, reason: 'no_stock' } } // 缺料:进货压力的接点
deps.consumeFor(recipe.id); // 食材在下锅一刻消耗(承诺必达,烹饪不再失败)
c.state = 'cooking';
stoves[slot] = { customerId: c.id, recipeId: recipe.id, remain_s: recipe.cook_s, total_s: recipe.cook_s };
events.push({ type: 'cook_start', customerId: c.id, stove: slot, recipeId: recipe.id });
@ -120,14 +143,15 @@ export function createServiceLoop(deps) {
* @param {number} dayProgress 营业日进度 [0,1](core 时钟注入,本系统不持有时钟)
*/
update(dt, dayProgress) {
// ① 生成节拍(同屏上限内按清闲/高峰间隔出客)
// ① 生成节拍(同屏上限内按清闲/高峰间隔出客;间隔乘 spawnMult 解锁修饰)
spawnCooldown -= dt;
if (spawnCooldown <= 0 && customers.length < onscreenCap(dayProgress)) {
spawnCustomer(dayProgress);
const si = econ.spawn_interval;
spawnCooldown = isPeak(dayProgress)
const base = isPeak(dayProgress)
? rng.range(si.peak_s_min, si.peak_s_max)
: rng.range(si.idle_s_min, si.idle_s_max);
spawnCooldown = base * deps.spawnMult();
}
// ② 耐心衰减(仅 waiting;耗尽 = 翻台走人,产 miss 事件交 core 扣口碑断连击)
@ -143,6 +167,7 @@ export function createServiceLoop(deps) {
}
// ③ 灶台计时:到点自动出餐+结账(产 serve 事件带价目,core 记账)
syncStoves();
for (let s = 0; s < stoves.length; s++) {
const job = stoves[s];
if (!job) continue;
@ -154,7 +179,7 @@ export function createServiceLoop(deps) {
if (!c) continue; // 防御:顾客态先没了只清灶(不应发生,留痕即可)
customers.splice(idx, 1);
counts.served++;
const recipe = recipes.find((r) => r.id === job.recipeId);
const recipe = deps.menu().find((r) => r.id === job.recipeId) || { id: job.recipeId, name: job.recipeId, price: 0, cost: 0 };
events.push({
type: 'serve', customerId: c.id, typeName: c.typeName, seat: c.seat, stove: s,
recipeId: recipe.id, recipeName: recipe.name,
@ -164,11 +189,20 @@ export function createServiceLoop(deps) {
}
},
/** 日切复位:清场(顾客/灶台/生成节拍),取证计数与 id 序列保留(生涯连续性)。 */
resetDay() {
customers = [];
stoves = new Array(deps.stoveSlots()).fill(null);
spawnCooldown = 0.5;
events.length = 0;
},
/** 取走本帧事件(core 消费后清空;单消费方保证顺序)。 */
drainEvents() { return events.splice(0, events.length); },
/** 系统视图(core.readState 组装消费;含逻辑坐标供 driver 出招)。 */
view() {
syncStoves();
return {
customers: customers.map((c) => ({
id: c.id, typeId: c.typeId, typeName: c.typeName, rarity: c.rarity,
@ -183,6 +217,6 @@ export function createServiceLoop(deps) {
},
/** 取证计数(_forensicsView)。 */
counts() { return { ...counts }; },
counts() { return { ...counts } },
};
}

View File

@ -1,8 +1,9 @@
/**
* src/ui/hud.js 顶部状态条 + 飘字反馈(M0 程序化占位美术;Phaser 只读渲染件)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §7(主场景零层级:金币/口碑 + 营业日进度 + 反馈三件套之飘字)
* src/ui/hud.js 顶部状态条 + 飘字反馈(M0/M1 程序化占位美术;Phaser 只读渲染件)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §7(主场景零层级:金币/声望/口碑 + 营业日进度 + 反馈三件套之飘字)
*
* 只读 core.readState(),不写游戏状态;风格皮肤归 M4 美术整装,本文件只保证信息可读与反馈即时
* M1 增列:声望(解锁硬轴)/ 本日净入 / 库存低警示(缺料前的可感知信号,进货决策的提示端)
*/
'use strict';
@ -14,27 +15,34 @@ export class Hud {
*/
constructor(scene, viewW) {
this.scene = scene;
// 顶部状态条底(半透明深色,占位)
scene.add.rectangle(viewW / 2, 34, viewW, 68, 0x101426, 0.85).setDepth(50);
// 顶部状态条底(半透明深色,占位;M1 两行信息加高)
scene.add.rectangle(viewW / 2, 44, viewW, 88, 0x101426, 0.85).setDepth(50);
const style = { fontFamily: 'sans-serif', fontSize: '18px', color: '#ffe9b8' };
this.coinsText = scene.add.text(14, 12, '金币 0', style).setDepth(51);
this.repText = scene.add.text(14, 38, '口碑 50', { ...style, color: '#ffc9c9' }).setDepth(51);
this.comboText = scene.add.text(150, 12, '', { ...style, color: '#9ef0ff' }).setDepth(51);
this.coinsText = scene.add.text(14, 10, '金币 0', style).setDepth(51);
this.prestigeText = scene.add.text(14, 36, '声望 0', { ...style, fontSize: '15px', color: '#c98bff' }).setDepth(51);
this.repText = scene.add.text(14, 60, '口碑 50', { ...style, fontSize: '15px', color: '#ffc9c9' }).setDepth(51);
this.comboText = scene.add.text(150, 10, '', { ...style, color: '#9ef0ff' }).setDepth(51);
this.servedText = scene.add.text(150, 38, '', { ...style, fontSize: '14px', color: '#c8d2f0' }).setDepth(51);
this.stockWarnText = scene.add.text(150, 60, '', { ...style, fontSize: '13px', color: '#ff9950' }).setDepth(51);
// 营业日进度条(右上;进度环的占位实现——M4 换环形)
this.dayBarBg = scene.add.rectangle(viewW - 78, 24, 120, 10, 0x2a3050).setDepth(51);
this.dayBar = scene.add.rectangle(viewW - 138, 24, 0, 10, 0xffd166).setOrigin(0, 0.5).setDepth(52);
this.dayText = scene.add.text(viewW - 138, 34, '第 1 天', { ...style, fontSize: '13px', color: '#c8d2f0' }).setDepth(51);
this.dayBarBg = scene.add.rectangle(viewW - 78, 22, 120, 10, 0x2a3050).setDepth(51);
this.dayBar = scene.add.rectangle(viewW - 138, 22, 0, 10, 0xffd166).setOrigin(0, 0.5).setDepth(52);
this.dayText = scene.add.text(viewW - 138, 32, '第 1 天', { ...style, fontSize: '13px', color: '#c8d2f0' }).setDepth(51);
this.netText = scene.add.text(viewW - 138, 52, '', { ...style, fontSize: '13px', color: '#9ef0a0' }).setDepth(51);
}
/** 每帧刷新(只读 state)。 */
update(st) {
this.coinsText.setText(`金币 ${st.coins}`);
this.prestigeText.setText(`声望 ${st.prestige} · 解锁 ${st.unlockedCount}/40`);
this.repText.setText(`口碑 ${st.reputation}`);
this.comboText.setText(st.combo >= 2 ? `连击 ×${st.combo}` : '');
this.servedText.setText(`出餐 ${st.served} · 翻台 ${st.missed}`);
const lows = st.stockSummary.filter((s) => s.low).map((s) => s.name);
this.stockWarnText.setText(lows.length ? `料紧:${lows.slice(0, 4).join(' ')}${lows.length > 4 ? '…' : ''}` : '');
this.dayBar.width = 120 * Math.min(1, st.timeInDay / st.dayLength);
this.dayText.setText(`${st.day}`);
this.netText.setText(st.dayNet !== 0 ? `本日 ${st.dayNet > 0 ? '+' : ''}${st.dayNet}` : '');
}
/** 飘字(反馈三件套之一;+金额/翻台提示,缓动上浮渐隐)。 */

View File

@ -0,0 +1,112 @@
/**
* test/driver-wiring.test.mjs 九门经营 driver 接线单测(本机 headless;CDP 真跑归 mini-desktop 窗口)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §9 M1九门回归的本机可验部分
*
* 验什么 driver 出招决策纯函数(pickTarget:可做优先/紧迫优先/无灶不点)
* run() 主循环(stub cdp/deps:真点坐标terminalPhases 空转不点)
* runDriver type='business-serve' 真分发到本 driver(harness 接线本体)
* play-spec 形状(urlQuery/latch/断言表齐;spec 是门的输入契约)
* 用法:node --test test/
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const requireCjs = createRequire(import.meta.url);
// driver 与 harness 均为 CJS(play 线惯例),经 createRequire 载入
const driver = requireCjs(join(HERE, '../../../harness/business-serve.driver.js'));
const harness = requireCjs(join(HERE, '../../../harness/play-phaser.cdp.cjs'));
/** stub deps:tap 记招、readGameState 按帧回放脚本、其余空实现。 */
function makeDeps(states) {
const taps = [];
let i = 0;
return {
taps,
deps: {
tap: async (_cdp, x, y) => { taps.push([x, y]); },
key: async () => {}, drag: async () => {},
readGameState: async () => states[Math.min(i++, states.length - 1)],
sampleHash: async () => 'h' + i,
getPath: (o, p) => p.split('.').reduce((a, k) => (a == null ? undefined : a[k]), o),
delay: async () => {},
},
};
}
const WAITING = (id, x, y, patience, servable) => ({ customerId: id, x, y, patienceLeft: patience, servable });
test('pickTarget:可做优先、紧迫优先、无灶/无客不点', () => {
// 可做优先:耐心最紧的不可做(缺料),点次紧的可做单
const s1 = {
tapTargets: [WAITING(1, 75, 470, 1.2, false), WAITING(2, 195, 590, 3.0, true)],
stoves: [{ idx: 0, busy: false }],
};
assert.equal(driver.pickTarget(s1).customerId, 2);
// 全不可做:兜底点最紧迫的(耐心耗尽也是信息,点了 core 报 no_stock 无副作用)
const s2 = { tapTargets: [WAITING(1, 75, 470, 1.2, false), WAITING(2, 195, 590, 3.0, false)], stoves: [{ idx: 0, busy: false }] };
assert.equal(driver.pickTarget(s2).customerId, 1);
// 无空灶不点
const s3 = { tapTargets: [WAITING(1, 75, 470, 1.2, true)], stoves: [{ idx: 0, busy: true }, { idx: 1, busy: true }] };
assert.equal(driver.pickTarget(s3), null);
// 无客不点
assert.equal(driver.pickTarget({ tapTargets: [], stoves: [{ idx: 0, busy: false }] }), null);
assert.equal(driver.pickTarget(null), null);
});
test('run():真点坐标、day_end 即停、坐标钳制在取证视口', async () => {
const states = [
{ phase: 'play', tapTargets: [WAITING(1, 75, 470, 2.0, true)], stoves: [{ idx: 0, busy: false }] },
{ phase: 'play', tapTargets: [WAITING(2, 400, 900, 1.0, true)], stoves: [{ idx: 0, busy: false }] }, // 越界坐标 → 钳制
{ phase: 'play', tapTargets: [], stoves: [{ idx: 0, busy: true }] }, // 无可点 → 空转
{ phase: 'day_end', tapTargets: [], stoves: [] }, // 收钟 → 停
{ phase: 'day_end', tapTargets: [], stoves: [] },
];
const { taps, deps } = makeDeps(states);
const hashes = [];
const r = await driver.run({}, { type: 'business-serve', steps: 10, stepMs: 1, terminalPhases: ['day_end'] }, hashes, deps);
assert.equal(r.drove, 2, '两次真点(第三帧无可点空转,第四帧终态停)');
assert.deepEqual(taps[0], [75, 470]);
assert.deepEqual(taps[1], [382, 836]); // clamp 上限 = hi4:x 3864=382,y 8404=836(视口内钳制)
assert.ok(hashes.length >= 3, 'E 门帧哈希采样真发生');
});
test('runDriver:type=business-serve 真分发到本 driver(harness 接线本体)', async () => {
const states = [
{ phase: 'play', tapTargets: [WAITING(7, 195, 470, 2.0, true)], stoves: [{ idx: 0, busy: false }] },
{ phase: 'day_end', tapTargets: [], stoves: [] },
];
const { taps, deps } = makeDeps(states);
const r = await harness.runDriver({}, { type: 'business-serve', steps: 5, stepMs: 1, terminalPhases: ['day_end'] }, [], deps);
assert.equal(r.drove, 1);
assert.equal(taps.length, 1);
assert.equal(r.note, undefined, '不落「未注册 type」空转路');
});
test('runDriver:未注册 type 仍走响亮空转(回归:不静默假装驱动)', async () => {
const { taps, deps } = makeDeps([{ phase: 'play' }]);
const r = await harness.runDriver({}, { type: 'bushi', steps: 2, stepMs: 1 }, [], deps);
assert.equal(r.drove, 0);
assert.match(r.note, /未注册 driver.type/);
assert.equal(taps.length, 0);
});
test('play-spec:门输入契约齐(urlQuery 钉 seed+短日/latch=day_end/断言表/driven 前提)', () => {
const spec = JSON.parse(readFileSync(join(HERE, '../play-spec.json'), 'utf8'));
assert.equal(spec.driver.type, 'business-serve');
assert.ok(spec.urlQuery.includes('seed=') && spec.urlQuery.includes('day=') && spec.urlQuery.includes('reset=1'));
assert.equal(spec.expectLatch, true);
assert.deepEqual(spec.latchTerminals, ['day_end']);
assert.ok(Array.isArray(spec.assertAfterPlay) && spec.assertAfterPlay.length >= 3);
assert.ok(spec.expectedEngineCallPrefixes.includes('core.tapAt'));
// 短日必须给足 latch 可达余量:steps×stepMs 覆盖 day 秒数
const day = Number(/(?:^|&)day=(\d+)/.exec(spec.urlQuery)[1]);
assert.ok(spec.driver.steps * spec.driver.stepMs / 1000 > day, '驱动窗口 > 营业日时长(day_end 在窗口内可达)');
});

View File

@ -0,0 +1,162 @@
/**
* test/m1-core.test.mjs M1 经济骨架单测(node:test;冒烟之外的边界与负例)
* owner:北极星顶级线①(W-POLARIS)· 设计书 §9 M1
* 用法:node --test test/
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createGameCore } from '../src/core/game-core.js';
import { createSeededRandom } from '../src/core/seeded-random.js';
import { encodeSave, decodeSave, SCHEMA_VERSION, defaultData } from '../src/core/save-codec.js';
import { INGREDIENTS } from '../src/data/ingredients.js';
import { UNLOCK_TREE } from '../src/data/unlock-tree.js';
import { ECONOMY_CURVE } from '../src/data/economy-curve.js';
function freshCore(seed = 7) { return createGameCore({ random: createSeededRandom(seed) }) }
/** 直取库存件数(readState.stockSummary 白盒)。 */
function units(core, id) { return core.readState().stockSummary.find((s) => s.id === id).units }
test('进货:入账扣款、折扣向上取整、批次带保鲜', () => {
const c = freshCore();
const coins0 = c.readState().coins; // starting_coins=40
const row = INGREDIENTS.find((r) => r.id === 'i_youyu'); // 单价 5
const before = units(c, 'i_youyu');
const r = c.buyIngredient('i_youyu', 3);
assert.equal(r.ok, true);
assert.equal(r.spent, 15); // 无折扣:3×5
assert.equal(c.readState().coins, coins0 - 15);
assert.equal(units(c, 'i_youyu'), before + 3);
// 非法入参与超钱拒绝
assert.equal(c.buyIngredient('i_youyu', 0).ok, false);
assert.equal(c.buyIngredient('i_bushi', 1).ok, false);
assert.equal(c.buyIngredient('i_youyu', 9999).reason, 'insufficient_coins');
assert.ok(row.freshness_days >= 1);
});
test('解锁:四类拒绝(不存在/重复/依赖/声望/金币)与效果生效', () => {
const c = freshCore();
assert.equal(c.unlockNode('n_bushi').reason, 'not_found');
// 依赖未满足:n_r_kaohongshu 依赖 n_r_zhimaqiu
assert.equal(c.unlockNode('n_r_kaohongshu').reason, 'deps_unmet');
// 声望门:n_zao3 要 prestige 1,初始 0
assert.equal(c.unlockNode('n_zao3').reason, 'prestige_low');
// 金币不够:n_zuo7 60 > 40
assert.equal(c.unlockNode('n_zuo7').reason, 'insufficient_coins');
// 成功解锁 + 菜单扩容 + 重复拒绝
const m0 = c.readState().menuSize;
assert.equal(c.unlockNode('n_r_zhimaqiu').ok, true); // 30 币
assert.equal(c.readState().menuSize, m0 + 1);
assert.equal(c.unlockNode('n_r_zhimaqiu').reason, 'already');
});
test('座位/灶台解锁效果:容量按 layout 坐标上限钳制', () => {
const c = freshCore();
// 白盒注资(经 0 版迁移导入生涯态,绕过长跑;声望经信封注入以测门)
const env = { schemaVersion: 1, data: { ...defaultData(), coins: 5000, prestige: 8 } };
assert.equal(c.importSave(env, {}).ok, true);
c.start();
const fv = c._forensicsView().econEcho();
assert.equal(fv.stoveSlots, ECONOMY_CURVE.stove.slots); // 基线 2
for (const id of ['n_zao3', 'n_zao4', 'n_zao5']) assert.equal(c.unlockNode(id).ok, true, id);
assert.equal(c._forensicsView().econEcho().stoveSlots, 5); // 2+3,恰at layout 上限
for (const id of ['n_zuo7', 'n_zuo8', 'n_zuo9']) assert.equal(c.unlockNode(id).ok, true, id);
assert.equal(c._forensicsView().econEcho().seatCount, 9);
assert.equal(c.readState().stoves.length, 5); // view 反映动态灶台
});
test('市场折扣:取最优不叠乘,总价向上取整', () => {
const c = freshCore();
const env = { schemaVersion: 1, data: { ...defaultData(), coins: 5000, prestige: 8 } };
c.importSave(env, {});
assert.equal(c.unlockNode('n_caigou').ok, true); // 0.95
assert.equal(c.unlockNode('n_pifa').ok, true); // 0.90(最优=0.90,非 0.855)
const r = c.buyIngredient('i_mianfen', 3); // 3×1×0.90=2.7 → ceil 3
assert.equal(r.spent, 3);
const r2 = c.buyIngredient('i_youyu', 1); // 1×5×0.90=4.5 → ceil 5
assert.equal(r2.spent, 5);
});
test('保鲜衰减:批次 FIFO 消耗、日结过期废弃', () => {
const c = freshCore();
const env = { schemaVersion: 1, data: { ...defaultData(), coins: 1000, stock: { i_shishu: [{ qty: 5, freshLeft: 1 }, { qty: 7, freshLeft: 9 }] } } };
c.importSave(env, {});
c.start();
// 快进整日到 day_end(不出招,时蔬不消耗)
for (let i = 0; i < 6001 && c.readState().phase === 'play'; i++) c.update(0.1);
assert.equal(c.readState().phase, 'day_end');
const rep = c.settleAndAdvanceDay({ nowMs: 1 });
assert.equal(rep.ok, true);
const waste = rep.expired.find((e) => e.id === 'i_shishu');
assert.ok(waste && waste.units === 5, `过期批次废弃 5 件,实际 ${JSON.stringify(rep.expired)}`); // freshLeft 1→0 废弃
assert.equal(units(c, 'i_shishu'), 7); // 新批剩 7(freshLeft 9→8)
});
test('防刷币:日中导出 = 日初检查点(日中进账不进存档)', () => {
const c = freshCore(20260706);
c.start();
const dayStartCoins = c.readState().coins;
// 打半天(有出餐进账)
for (let i = 0; i < 3000; i++) {
const st = c.readState();
if (st.tapTargets.length && st.stoves.some((s) => !s.busy)) {
const t = st.tapTargets.find((x) => x.servable);
if (t) c.tapCustomer(t.customerId);
}
c.update(0.1);
}
const midCoins = c.readState().coins;
assert.ok(midCoins > dayStartCoins, '日中确有进账');
const env = c.exportSave(123456);
assert.equal(env.data.coins, dayStartCoins, '导出=日初快照(重打不重赚)');
assert.equal(env.schemaVersion, SCHEMA_VERSION);
assert.equal(env.data.lastSeenAtMs, 123456);
});
test('存档编解码负例:缺版本/字符串版本/data 缺省补齐', () => {
assert.equal(decodeSave(null).ok, false);
assert.equal(decodeSave({ data: {} }).ok, false); // 缺 schemaVersion
assert.equal(decodeSave({ schemaVersion: '1', data: {} }).ok, false); // 非整数
assert.equal(decodeSave({ schemaVersion: -1, data: {} }).ok, false);
const d = decodeSave({ schemaVersion: 1, data: { coins: 9 } });
assert.equal(d.ok, true);
assert.equal(d.data.coins, 9);
assert.equal(d.data.day, 1); // 缺字段按缺省兜底(additive 纪律)
assert.throws(() => encodeSave([1]), /纯对象/);
});
test('口碑减免与连击修饰:rep_shield 后翻台仍不涨口碑', () => {
const c = freshCore();
const env = { schemaVersion: 1, data: { ...defaultData(), coins: 5000, prestige: 8 } };
c.importSave(env, {});
assert.equal(c.unlockNode('n_xiaopai').ok, true); // rep_shield +1 → miss_delta 3+1=2
assert.equal(c.unlockNode('n_shoutao').ok, true); // combo_window +2
const echo = c._forensicsView().econEcho();
assert.equal(echo.comboWindow, ECONOMY_CURVE.combo.window_s + 2);
// 放任跑一段攒 miss,验证口碑按 2 走(50 2×misses,不落 0 以下)
c.start();
for (let i = 0; i < 2000; i++) c.update(0.1);
const st = c.readState();
assert.equal(st.reputation, Math.max(0, 50 - 2 * st.missed), `rep=${st.reputation} missed=${st.missed}`);
});
test('自动补货:确定性 + 资金不足按表序补到耗尽', () => {
const c = freshCore();
const env = { schemaVersion: 1, data: { ...defaultData(), coins: 50, stock: {} } }; // 空库存,只有 50 币
c.importSave(env, {});
const r = c.autoRestock();
assert.ok(r.spent <= 50 && r.spent > 0);
assert.ok(r.bought.length >= 1);
assert.equal(r.bought[0].id, INGREDIENTS[0].id); // 表序确定性:第一位是面粉
assert.equal(c.readState().coins, 50 - r.spent);
});
test('解锁树数据面:40 节点、16 菜谱节点、4 系统前瞻带 arrivesAt', () => {
assert.equal(UNLOCK_TREE.length, 40);
assert.equal(UNLOCK_TREE.filter((n) => n.kind === 'unlock_recipe').length, 16);
const sys = UNLOCK_TREE.filter((n) => n.kind === 'system');
assert.equal(sys.length, 4);
for (const n of sys) assert.equal(n.arrivesAt, 'M2');
});