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:
parent
6dadc70173
commit
3b64fa3834
@ -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 墙钟锚第二批数据点。
|
||||
|
||||
@ -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>
|
||||
|
||||
26
tier2/games/night-market/play-spec.json
Normal file
26
tier2/games/night-market/play-spec.json
Normal 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 先例)"
|
||||
}
|
||||
@ -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 生涯账本口径下逐条重述,不变量不放水:
|
||||
* M0「coins 从 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。`);
|
||||
|
||||
141
tier2/games/night-market/scripts/tree-check.mjs
Normal file
141
tier2/games/night-market/scripts/tree-check.mjs
Normal 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×(1−margin)| ≤ 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×(1−margin)=${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);
|
||||
}
|
||||
@ -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;
|
||||
// ① 保鲜衰减:批次 freshLeft−1,到 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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@ -12,15 +12,19 @@
|
||||
export const VIEW_W = 390;
|
||||
export const VIEW_H = 844;
|
||||
|
||||
/** 顾客座位(街边摊前两排,6 席位 = M0 高峰同屏上限 4 + 余量;从左到右、前排优先)。 */
|
||||
/** 顾客座位(街边摊前三排,9 坐标上限;开局启用前 BASE_SEATS 席,7–9 席经解锁树 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,第 3–5 灶经解锁树 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 },
|
||||
]);
|
||||
|
||||
/** 摊位主体(渲染参考:操作台矩形)。 */
|
||||
|
||||
85
tier2/games/night-market/src/core/save-codec.js
Normal file
85
tier2/games/night-market/src/core/save-codec.js
Normal 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 优先(缺省兜底、不升版);读侧逐级迁移(0→1→…),缺迁移器按缺省兜底;
|
||||
* 未来版本(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 };
|
||||
}
|
||||
@ -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; },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
27
tier2/games/night-market/src/data/ingredients.js
Normal file
27
tier2/games/night-market/src/data/ingredients.js
Normal 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 },
|
||||
]);
|
||||
41
tier2/games/night-market/src/data/milestones.js
Normal file
41
tier2/games/night-market/src/data/milestones.js
Normal 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-肥鹅 20→100 币),本作单营业日流水 ≈ 数千,
|
||||
* 按「首日达成第一组、三五日第二组、旬日第三组」的节奏取营业日尺度放大。〔草案·仿真回填后锁〕 */
|
||||
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);
|
||||
@ -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 / 名称 / 烹饪时长秒 / 售价 / 成本 / 稀有度(0–3) / 偏好客群 tag[]。
|
||||
* 定价纪律:利润率按 economy_curve.recipe_profit 四档(25%/35%/48%/60%),cost = round(price×(1−margin))。
|
||||
* 食材组合/解锁条件字段随 U1(12 表批)与 U2(研发树)接入,M0 不带——服务循环不消费它们。
|
||||
* 内容全原创(clean-room:不引用任何对标作文案)。
|
||||
* 字段口径(底稿 recipe 表 + M1 增量):id / 名称 / 烹饪时长秒 / 售价 / 成本 / 稀有度(0–3) /
|
||||
* 偏好客群 tag[] / base(true=开局可用,false=经解锁树 unlock_recipe 节点解锁) /
|
||||
* ingredients[{id,qty}](M1 三资源:出餐消耗库存;cost 必须 = Σ单价×qty,tree-check ⑧ 机器对账)。
|
||||
* 定价纪律:利润率按 economy_curve.recipe_profit 四档(25%/35%/48%/60%),|cost − price×(1−margin)| ≤ 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 }] },
|
||||
]);
|
||||
|
||||
84
tier2/games/night-market/src/data/unlock-tree.js
Normal file
84
tier2/games/night-market/src/data/unlock-tree.js
Normal 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' },
|
||||
]);
|
||||
80
tier2/games/night-market/src/host-shadow-store.js
Normal file
80
tier2/games/night-market/src/host-shadow-store.js
Normal 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); }
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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); },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 } },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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}` : '');
|
||||
}
|
||||
|
||||
/** 飘字(反馈三件套之一;+金额/翻台提示,缓动上浮渐隐)。 */
|
||||
|
||||
112
tier2/games/night-market/test/driver-wiring.test.mjs
Normal file
112
tier2/games/night-market/test/driver-wiring.test.mjs
Normal 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 上限 = hi−4:x 386−4=382,y 840−4=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 在窗口内可达)');
|
||||
});
|
||||
162
tier2/games/night-market/test/m1-core.test.mjs
Normal file
162
tier2/games/night-market/test/m1-core.test.mjs
Normal 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');
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user