feat(wanglanmei-ref): 参考件轮1交付——经营深化玩法(双权衡两头能输+失败态+救单宽限)49测全绿,mmx资产16件(图14+乐2)风格统一,五要素自评≈4.1

- 逻辑/宿主两层:balance/shop-core/lines/loop-proof 纯逻辑零DOM受控时间随机;render/hostfx/audio-director/palette/assets/main 宿主层
- 手感链:同帧涟漪+弹簧按压/quadOut弧线飞行/连击升调/大额成交hit-stop+屏震+闪白/冷却期InputBuffer补打
- 美术:16件同风格词(主会话目检4张:水彩绘本暖色族高度统一)+P5派生压力色板+vignette联动
- 收编亲验:49/49+全仓155/155绿/隔离零外溢/IP gate源码资产零命中/逻辑层禁API零真调/manifest抽查5/5哈希一致
- mmx对账:quota面零增量(image/music不走Token Plan),权威用量=台账16次,扣费留月度并账
- 计数笔误15→16已纠;状态③收编提交完成,构建+e2e四件套接续

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-12 13:53:40 +00:00
parent 7ca9a03796
commit c2fb26cb40
42 changed files with 5340 additions and 2 deletions

View File

@ -0,0 +1,57 @@
# wanglanmei-ref · 构建与本地 serve集成段 mini-desktop 用)
> 本机lane零工具链纪律本目录只跑 `node --test`esbuild 构建与浏览器证据一律在集成段。
## 0. 单测(本机/任意机,零依赖)
```bash
cd game-runtime && node --test "games/wanglanmei-ref/test/*.test.mjs"
# 注意node24 对「裸目录」参数不做测试扫描(与 scripts/test.sh 显式列文件同因),须用上面引号 glob 形式。
# 原始输出已存 games/wanglanmei-ref/test/RESULT.txt49/49
```
## 1. 构建esbuild 锁参,沿 scripts/build.mjs 同参数bundle+minify+iife+es2019+sourcemap=false
`game-runtime/` 目录下(需 `npm i -D esbuild`mini-desktop 已具备):
```bash
node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef
```
产物:`games/wanglanmei-ref/dist/ref-bundle.js`iife 全局 `WanglanmeiRef`,含 core 协议 + 所用插件 + 游戏全部代码;资产不打包,运行时相对路径取 `./assets/`)。
## 2. 本地静态 serve沿 test/harness/static-serve.cjs轮 1 约定端口 4320
```bash
# 注意 serve 根 = 本游戏目录index.html 以相对路径取 dist/ 与 assets/
systemd-run --unit=t1ba-ref-r1 node test/harness/static-serve.cjs games/wanglanmei-ref 4320
# 探活
curl -sf http://localhost:4320/ | head -3
```
入口 URL
| 用途 | URL |
|---|---|
| 真人试玩RAF 交互) | `http://localhost:4320/?seed=305441741` |
| 取证模式(确定性驱动,无 RAF | `http://localhost:4320/?mode=evidence&seed=305441741` |
- `?seed=` 注入主随机种子(缺省 0x1234abcd=305441741不传也确定
- `?mode=evidence`:画面完全由 `window.__refHost.stepFrames(n)` / `window.__refHost.tap(x,y)` 驱动,同参数重跑一致;
- 取证导出:`window.__refForensics` / `window.__refChecklist`(字段含义见 FORENSICS.md
- 探活标记:`window.__refBooted === true`false + `window.__refBootError` = 引导失败)。
## 3. 构建产物 IP gate 复核(轮终必跑)
```bash
# 受保护字样以码位构造U+738B U+84DD U+8393防本文件自身触发字面量扫描期望输出 banned: false
node -e "const s=require('fs').readFileSync('games/wanglanmei-ref/dist/ref-bundle.js','utf8');console.log('banned:',s.includes(String.fromCharCode(0x738b,0x84dd,0x8393)))"
# 对外页面标题/manifest/资产 prompt 同口径复核:
node -e "const b=String.fromCharCode(0x738b,0x84dd,0x8393);for(const f of ['games/wanglanmei-ref/index.html','games/wanglanmei-ref/assets/manifest.json'])console.log(f,require('fs').readFileSync(f,'utf8').includes(b)?'VIOLATION':'clean')"
```
## 4. 轮终收尾
```bash
systemctl stop t1ba-ref-r1 # 不留孤儿 unit/端口;日志归档 evidence/round-1/
```

View File

@ -0,0 +1,58 @@
# wanglanmei-ref · 取证导出说明(给主会话 e2e 读)
> 运行时挂两个全局:`window.__refForensics`(取证清单)与 `window.__refChecklist`(五要素自动子项)。
> 另有 `window.__refHost`(确定性驱动钩子)与 `window.__refBooted`(探活)。全部在 boot 完成后可用。
## 1. `window.__refForensics`
| 字段 | 类型 | 含义 |
|---|---|---|
| `protocol` | string | core 协议版本('core-protocol-v0' |
| `seed` / `mode` | number / string | 本次运行的主种子与模式('play'/'evidence' |
| `geometry()` | () => Zone[] | **可交互几何**:当前帧全部命中区,`{id, kind, x, y, hw, hh, z}`(中心+半宽高collision 插件 broadphase 同口径与视觉绘制同源——computeZones 与绘制共用同一布局函数) |
| `probe()` | () => object | **锚点接线实记**`{jsonl, verify, anchors, uncaught, initOk, registeredPlugins}``jsonl`=P10 六锚链逐行 JSONL`verify.ok`=FNV-1a 哈希链+偏序校验;`anchors` 期望前 6 项=`t_boot→t_plugins_ready→t_game_init→t_first_paint→t_input_bound→t_game_start``uncaught` 期望 `[]` |
| `loopProof` | object | **闭环可达断言数据**boot 期对纯逻辑跑出):`{ok, opCount(≤64), ops[], day, upgradeId, served, cashJiao, reputation}`——开局→进货定价→营业接待→首日结算→首次升级的完整 op 流水 |
| `failureProof` | object | **失败态可达**:摆烂策略到停业的证明 `{ok, reason('reputation'/'bankrupt'), days, finedJiao, angry, events[]}`(含 fined 与 gameover 事件留痕) |
| `priceProof` | object | **定价两头都能输**`{loProfit(<0), midProfit(>0), hiProfit(<0), twoSidedLoss}`(单位角,解析模型) |
| `state()` | () => object | 游戏状态快照JSON 安全phase/day/cash/reputation/priceMul/stock/unlocked/upgrades/customers 数/combo/today/lifetime/gameoverReason |
| `recentEvents()` | () => object[] | 最近 ≤240 条逻辑事件(含 tMs 仿真时戳),断言行为序列用 |
| `measures()` | () => object | 实时度量frames/taps/tapFeedbackMaxFrames/comboUiShown/hitStopCount/rushPaletteSeen/bgmCrossfadeSeen/linesShown/bufferedServeReplays + `particle`(三层粒子数)+ `audio`sfxKinds/bgm 增益)+ `assetsMissing` |
## 2. `window.__refChecklist`LOOP-LOG 五要素表自动子项,逐帧刷新)
| 组 | 子项 | 判读 |
|---|---|---|
| `shougan`(手感) | `tapFeedbackMaxFrames` | 点击→可见反馈的最大帧延迟,目标 ≤3涟漪+按压弹簧在 tap 同帧注册,下一渲染帧可见,构造上 ≤1 |
| | `tapFeedbackWithin3` / `everyTapScalePulse` | 布尔后者构造保证onTap 必加涟漪+弹簧脉冲) |
| | `comboUiShown` | 连击 UI 出现过live 计数或当日 maxCombo≥2 |
| | `flightEasingUsed` / `hitStopTriggered` / `bufferedServeReplays` | 商品飞行走非线性缓动(构造保证)/ hit-stop 实际触发过 / 输入缓冲补打次数 |
| `meishu`(美术统一性) | `allDrawsViaPalette` | 构造保证render.js 一切取色经 palette 解析器P5 lerp/apply/shiftHsl 驱动) |
| | `vignetteEnabled` / `rushPaletteSeen` | 暗角开启(强度随高峰 0.22→0.46/ 高峰压力色板实际出现过 |
| | `assetsLoaded` | 'n/13' 图像装载数(缺失走程序化回退,亦无原色矩形) |
| `yinyue`(音乐) | `audioUnlocked` / `bgmReady` / `bgmCrossfadeSeen` | 手势解锁 / 双 BGM 解码就绪 / 双轨增益同时 >0.02 出现过crossfade 实证) |
| | `sfxKindCount` / `sfxKinds` | 已触发音效种类(目标 ≥5全集=arrive/shelve/upgrade/coin/bad/rescue/bigsale/tick/empty |
| `shendu`(结构深度) | `loopReachable` / `loopOps` / `firstUpgradeDay` | 闭环可达 / 操作数 / 首次升级落在第几天(应 =2首段落内 |
| | `failureReachable` / `failureReason` / `priceTwoSidedLoss` / `upgradeTreeSize` | 失败态可达 / 原因 / 定价两头能输 / 升级树 8 节点 |
| `juese`(角色壳) | `keeperSpriteResident` | 店主立绘每帧常驻(构造保证) |
| | `lineGroups` / `lineTotal` / `linesShownLive` | 台词场景组数(≥6) / 总条数(≥20) / 本次运行实际展示条数 |
> 「构造保证」类布尔=代码结构使然(如取色必经调色器),运行时恒 true
> 「live」类=真实发生才置位(如 rushPaletteSeen/bgmCrossfadeSeen/hitStopTriggerede2e 需驱动到对应场景再读。
## 3. `window.__refHost`(评估驱动钩子,样板=host-dev 的 `__hostdev`
| 方法 | 含义 |
|---|---|
| `stepFrames(n)` | 确定性推进 n 帧(固定 dt=1/60 + 步进时钟evidence 模式唯一推进方式) |
| `tap(x, y)` | 注入一次点击pointerdown+up经受控输入桥与真 CDP 输入同路) |
| `do(action)` | 直接执行按钮动作('newGame'/'continue'/'openShop'/'confirmSettle'/'confirmSummary'/'restart'e2e 兜底用 |
| `state()` / `frameCount()` / `startRaf()` / `stopRaf()` | 快照 / 帧数 / RAF 开关 |
## 4. e2e 建议断言流(输入轨迹/截图/probe/checklist 四件套)
1. 开 `?mode=evidence&seed=305441741` → 等 `__refBooted===true`
2. `__refForensics.probe().verify.ok === true``uncaught.length === 0`
3. `geometry()``newGame` 中心 → CDP 真输入点击 → `state().phase === 'prep'`
4. 依 `geometry()``inc-*` 备货、`openShop` 开门 → stepFrames 推进 → 对 `serve-*` 真输入连点 → `state().today.served > 0``recentEvents()` 出现 `serve_ok`
5. 推到 `phase==='settle'` → 截图(结算面板)→ 点 `confirmSettle` → 第 2 天点 `up-speed1``state().upgrades.speed1===true`(闭环真 UI 复证);
6. 全程读 `__refChecklist` 落 JSONcanvas 哈希在固定 seed + stepFrames 定帧下取。

View File

@ -46,13 +46,71 @@ serve=`systemd-run --unit=t1ba-ref-r1` 端口 4320,轮终 stop+日志归档 `evi
### 状态
- [ ] ②写码(Fable agent,待集成门 3/3 过后开火)
- [x] ②写码(Fable agent,2026-06-12 交付:玩法逻辑+宿主层+15 资产+49 测试全绿+取证导出;详见下方「轮 1 交付」三节)
- [ ] ③收编提交+构建
- [ ] ④评估四件套(输入轨迹/截图+canvas 哈希/probe JSONL/checklist JSON)
- [ ] ⑤判词
---
## 轮 1 交付 · 设计说明(关键裁量+理由)
> 写码 agent 留痕(2026-06-12)。交付物清单:`src/`×10(逻辑 4+宿主 6)+`entry.js`+`index.html`+`test/`×8(49 测试)+`assets/`×15+manifest+`BUILD.md`+`FORENSICS.md`
1. **逻辑/宿主两层切分**:`balance.js`(数值单一事实源)/`shop-core.js`(营业日状态机)/`shopkeeper-lines.js`(台词库)/`loop-proof.js`(闭环+失败态脚本化证明)为**纯逻辑层**——零 DOM、时间=逐帧 dt 累计、随机=注入受控源、金额全程「角」整数防浮点漂移;渲染(`render.js`)/表现导演(`hostfx.js`)/音频(`audio-director.js`)/资产(`assets.js`)/引导(`main.js`)为宿主层。逻辑层 49 项 node 直测,不开浏览器即可钉死玩法正确性。
2. **二阶决策做成「数值上两头都能输」**:定价→客流曲线(0.8→×1.3,1.5→×0.25)+毛利曲线+日租金叠加,解析模型实测 0.8 亏 ¥6.7/1.5 亏 ¥7.8/最优 ×1.15 赚 ¥25.7(内部最优,非送分单调);库存=资金占用 vs 缺货流失(-2.5 声誉/人)。两者都有测试与运行时 priceProof 双保险。
3. **失败态真实可达**:耐心耗尽→0.55s 救单宽限(coyote 语义,救回有额外声誉奖励——把惩罚窗变成高光操作窗)→愤怒离店 -4 声誉;日结声誉<30 罚没(基数 ¥24+每差评 ¥2);声誉0 或现金<0停业终态`proveFailure` 脚本化证明 1 天即可摆烂致死,罚没与 gameover 事件双留痕
4. **连击=ComboWindow(P4 公开 API)喂受控仿真时钟**:2.6s 窗,小费 +4%/级封顶 +32%,收银音效每级 +2 半音(音调递升),≥2 出徽章(弹簧弹跳)。
5. **接待手感链**:tap 同帧出涟漪+按压弹簧(反馈帧延迟构造上 ≤1)→商品 quadOut+抛物弧飞行 0.28s→到达爆金币喷泉(粒子)+浮字+叮;大额成交(≥¥9)hit-stop(0.09s)+屏震+闪白三件套;冷却期点击经 gamefeel InputBuffer(260ms)缓冲补打不吞输入。
6. **P3 多实例分层供色**:粒子插件不内置颜色(集成段 REPORT §4.3 教训)且 v1 环境曲线全局共享——故建 3 个独立实例(金币金/奶油亮/暖棕烟,各派生独立随机流+独立环境),宿主逐层设 fillStyle;juice 套件只走注册进 registry 的权威实例(全局唯一 timeScale)。
7. **美术统一性双保险**:①15 件 mmx 资产共用同一组风格词(手绘水彩绘本+暖色族+奶油底,商品/头像全部居中构图);②程序化绘制一切取色必经 `palette.js` 解析器——基础色名表唯一,高峰压力色板由 P5 shiftHsl 程序化派生(色相-10°/饱和×1.18/明度×0.9)+lerpPaletteMap 过渡+vignette 0.22→0.46 收紧,情绪随玩法起伏。资产无 alpha 通道的限制用「圆形头像/圆角卡片裱框」构图语言化解(全部 prompt 按此构图生成,统一且回避抠图)。
8. **音频双路裁量**:BGM=mmx 两首(92/132bpm 同族配器)宿主双 source 增益逐帧 crossfade(≈1.2s 收敛);音效 P6 预设(来客 blip/上架 thud/升级 chime)+宿主合成 6 种(收银连击升调/差评降鸣/救单琶音/大额低鸣/点按嗒/缺货噗)——P6 预设面无音高参数,合成补位为刻意裁量(记 v2 边界)。
9. **probe 六锚编排沿 host-dev 样板**:宿主自管 probe,t_boot→t_plugins_ready→(资产装载+游戏构建)t_game_init→首帧 t_first_paint→probe.init 绑输入 t_input_bound→t_game_start,verifyChain 全绿(headless 测试断言)。
10. **取证清单随码导出**:`__refForensics`(几何与视觉同源 computeZones/probe 链/闭环+失败+定价三证明/状态/事件环/度量)+`__refChecklist`(五要素表自动子项,「构造保证」与「live 实测」分类标注)——字段语义全录 FORENSICS.md。
11. **本机最强验证=headless 全链路**:Proxy 伪 2D 上下文真跑 bootRef,真 tap 路由走完 开店→进货→营业接待→日结→次日升级 整圈+同 seed 确定性复跑一致(不开浏览器,红线 4 合规);像素/发声证据留集成段四件套。
## 轮 1 交付 · 生成清单(mmx,逐次当场记录;另存 assets/gen-ledger.md 原始件)
- **调用合计:16 次(图 14+乐 2)≤ 预算 20;全部 exit0,零配额/鉴权失败;补绘 1 次(#16 店主招手像服装与待机像对齐,替换 #2 产物)。**
- 图统一风格词 STYLE=「手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁」;全部 768²(1:1)默认,storefront 9:16,ui-panel 4:3;**prompt 全程零受保护字样**(占位店主=通用市井老板娘)。
- 完整 sha256 见 `assets/manifest.json`(file/bytes/sha256/source 四字段全录)。
| # | 类型 | 文件 | 字节 | sha256(前16) | prompt(+STYLE) |
|---|---|---|---:|---|---|
| 1 | image | shopkeeper-idle.jpg | 250074 | d880ac0010bc545c | 一位亲切的市井小卖部老板娘半身像,圆脸盘发系米色围裙,温和微笑,双手轻搭在木柜台上,人物居中,纯浅奶油色背景 |
| 2 | image | (弃用,被 #16 替换) | 239519 | bea80c16a0de273d | 同一位市井小卖部老板娘半身像,…,开心地挥手打招呼,热情笑容,人物居中,纯浅奶油色背景 |
| 3 | image | customer-kid.jpg | 272573 | a7923ef9d6ae4e10 | 小学生男孩圆形头像构图,胸像,馋嘴期待的表情,背红色书包,居中,纯浅奶油色背景 |
| 4 | image | customer-lady.jpg | 144021 | 0d3123c7b65bd186 | 年轻上班族女性圆形头像构图,胸像,温和微笑,低马尾辫白衬衫,居中,纯浅奶油色背景 |
| 5 | image | customer-grandpa.jpg | 327951 | 193dbdda36be380f | 慈祥老爷爷圆形头像构图,胸像,眯眼笑,戴灰色布帽白胡子,居中,纯浅奶油色背景 |
| 6 | image | customer-auntie.jpg | 259982 | 233d14e41ffd41f7 | 时髦中年阿姨圆形头像构图,胸像,爽朗大笑,棕色卷发戴耳环,居中,纯浅奶油色背景 |
| 7 | image | storefront.jpg | 509293 | ab453b8b35b32ac8 | 温暖的小卖部店内背景,木质货架陈列杂货,暖黄吊灯,玻璃柜台,窗外是傍晚市井街道,无人物,画面中下部干净留白(9:16) |
| 8 | image | item-soda.jpg | 113934 | 1fee829fe5d39d0f | 一罐橙子味汽水易拉罐,简洁商品图标,居中特写,纯浅奶油色背景 |
| 9 | image | item-snack.jpg | 176052 | 485629ceee8bc69b | 一包红色包装的辣条零食,简洁商品图标,居中特写,纯浅奶油色背景 |
| 10 | image | item-bread.jpg | 120166 | 063a0aea058515fb | 一只金黄色菠萝包面包,简洁商品图标,居中特写,纯浅奶油色背景 |
| 11 | image | ui-panel.jpg | 162064 | 3d4ddf5f9508ec7e | 米色水彩纸纹理面板,四边轻微水彩晕染描边,中心干净留白,无文字无图案(4:3) |
| 12 | image | item-ice.jpg | 73755 | 2143ba67d643edc5 | 一支双球奶油香草冰淇淋甜筒,简洁商品图标,居中特写,纯浅奶油色背景 |
| 13 | image | item-toy.jpg | 99391 | 497143628f79e8ad | 一辆红色小木头玩具汽车,简洁商品图标,居中特写,纯浅奶油色背景 |
| 14 | music | bgm-idle.mp3 | 4419803 | 37a7e4faeabd9eca | 温暖悠闲的市井小店白天背景音乐,木吉他指弹与口琴主奏,轻刷子鼓点与软贝斯,大调,治愈放松,适合经营游戏闲时循环播放,旋律简单上口(--instrumental --bpm 92) |
| 15 | music | bgm-rush.mp3 | 4610393 | 690aa103725c2358 | 忙碌欢快的市井小店高峰时段背景音乐,木吉他快速扫弦与口琴主奏,手拍与沙锤节奏,大调,与同族闲时曲同一配器风格,紧凑明快但不焦躁,适合经营游戏高峰循环播放(--instrumental --bpm 132) |
| 16 | image | shopkeeper-wave.jpg(替换#2) | 205213 | a1ff8036ab7a729a | 同一位亲切的市井小卖部老板娘半身像,圆脸深棕色盘发配红色发圈,粉色衬衫外系米黄色围裙,开心地挥手打招呼,热情笑容露齿笑,人物居中,纯浅奶油色背景——补绘原因:服装色与待机像对齐(待机/招手在场内同位切换,撞色会闪) |
(quota 对账「前/后」读数由主会话按轮前基线补;本 agent 按纪律未跑 quota show,**调用计数=16**(表 #1~#16,原文"15"系笔误,主会话收编时按表行数纠正)。)
## 轮 1 交付 · v2 边界(刻意没做的)
1. **P6 情绪分层/ZzFXM 曲未用**:BGM 走 mmx 双轨 crossfade(计划内首选);P6 的 loadSong/intensity 留给「程序化配乐」场景。**P6 预设面无音高/音量参数**→连击升调收银音由宿主合成补位;建议 v2 给 P6 扩 `playSfx(name, {pitch, volume})`
2. **拖拽交互未做**:接待=点按(移动端最脆路径);拖拽上架/拖拽递货留 v2(P1 raycast 面already 可支撑)。滑条已用 pointermove 拖拽。
3. **多发射器异构环境规避而非解决**:P3 v1 环境曲线全局共享→以 3 实例分层绕开;若 v2 插件支持粒子级环境可并回单实例。
4. **存档只存 prep 间持久态**:当日过程态(顾客队列/日内时刻)不存——中途关页=当日重开(口径已写注释);云存档/多档位不做。
5. **经济纵深 3 段落后未扩**:房租每段落 +¥12 的单一递增压力;新品类/事件卡/天气等内容扩展留品类层。
6. **图像无 alpha**:image-01 产 JPEG,以裱框构图语言化解;若后续需要真抠图 sprite,v2 评估 +抠图管线(或带 alpha 的生成器)。
7. **save 跨 iframe 契约不声称**:P9 走 localStorage adapter(浏览器)/内存(降级),SDK storage 通道按执行版口径留 β。
8. **店面背景右下角自带生成水印样纹理**:9:16 大图角部有细小杂笔触,被底部面板遮挡,不影响成片;如亲玩轮在意可用余量 5 次补绘一张。
---
## mmx 对账与生成清单(轮 1)
(开火前补「前」基线,生成后补清单与「后」读数)
生成清单见上「轮 1 交付 · 生成清单」(**16 次**调用全记录+sha256;原始当场记录件=assets/gen-ledger.md)。
**对账结论(主会话 2026-06-12 收编时核)**:`quota show` 前后读数零增量(`evidence/round-1/quota-{before,after}.json`:general 0→0,video 21→21 为本轮之前的他用)——**image-01/music-2.6-free 调用不走 Token Plan 配额面**(music-2.6-free 对 API key 不限量;image 计费面待月度账单核对)。权威用量=生成清单 16 次+manifest sha256 抽查 5/5 一致;真实扣费入成本台账留月度对账(per [[newapi-billing-plane-integration]] 外平面计量并账纪律)。

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@ -0,0 +1,17 @@
| 1 | image | shopkeeper-idle.jpg | 250074 | d880ac0010bc545c | 一位亲切的市井小卖部老板娘半身像,圆脸盘发系米色围裙,温和微笑,双手轻搭在木柜台上,人物居中,纯浅奶油色背景,STYLE | exit0 |
| 2 | image | shopkeeper-wave.jpg | 239519 | bea80c16a0de273d | 同一位市井小卖部老板娘半身像,圆脸盘发系米色围裙,开心地挥手打招呼,热情笑容,人物居中,纯浅奶油色背景,STYLE | exit0 |
| 3 | image | customer-kid.jpg | 272573 | a7923ef9d6ae4e10 | 小学生男孩圆形头像构图,胸像,馋嘴期待的表情,背红色书包,居中,纯浅奶油色背景,STYLE | exit0 |
| 4 | image | customer-lady.jpg | 144021 | 0d3123c7b65bd186 | 年轻上班族女性圆形头像构图,胸像,温和微笑,低马尾辫白衬衫,居中,纯浅奶油色背景,STYLE | exit0 |
| 5 | image | customer-grandpa.jpg | 327951 | 193dbdda36be380f | 慈祥老爷爷圆形头像构图,胸像,眯眼笑,戴灰色布帽白胡子,居中,纯浅奶油色背景,STYLE | exit0 |
| 6 | image | customer-auntie.jpg | 259982 | 233d14e41ffd41f7 | 时髦中年阿姨圆形头像构图,胸像,爽朗大笑,棕色卷发戴耳环,居中,纯浅奶油色背景,STYLE | exit0 |
| 7 | image | storefront.jpg | 509293 | ab453b8b35b32ac8 | 温暖的小卖部店内背景,木质货架陈列杂货,暖黄吊灯,玻璃柜台,窗外是傍晚市井街道,无人物,画面中下部干净留白,STYLE | exit0 |
| 8 | image | item-soda.jpg | 113934 | 1fee829fe5d39d0f | 一罐橙子味汽水易拉罐,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
| 9 | image | item-snack.jpg | 176052 | 485629ceee8bc69b | 一包红色包装的辣条零食,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
| 10 | image | item-bread.jpg | 120166 | 063a0aea058515fb | 一只金黄色菠萝包面包,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
| 11 | image | ui-panel.jpg | 162064 | 3d4ddf5f9508ec7e | 米色水彩纸纹理面板,四边轻微水彩晕染描边,中心干净留白,无文字无图案,STYLE | exit0 |
| 12 | image | item-ice.jpg | 73755 | 2143ba67d643edc5 | 一支双球奶油香草冰淇淋甜筒,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
| 13 | image | item-toy.jpg | 99391 | 497143628f79e8ad | 一辆红色小木头玩具汽车,简洁商品图标,居中特写,纯浅奶油色背景,STYLE | exit0 |
| 14 | music | bgm-idle.mp3 | 4419803 | 37a7e4faeabd9eca | 温暖悠闲的市井小店白天背景音乐,木吉他指弹与口琴主奏,轻刷子鼓点与软贝斯,大调,治愈放松,适合经营游戏闲时循环播放,旋律简单上口 (--instrumental --bpm 92) | exit0 |
| 15 | music | bgm-rush.mp3 | 4610393 | 690aa103725c2358 | 忙碌欢快的市井小店高峰时段背景音乐,木吉他快速扫弦与口琴主奏,手拍与沙锤节奏,大调,与同族闲时曲同一配器风格,紧凑明快但不焦躁,适合经营游戏高峰循环播放 (--instrumental --bpm 132) | exit0 |
| 16 | image | shopkeeper-wave.jpg(替换#2) | 205213 | a1ff8036ab7a729a | 同一位亲切的市井小卖部老板娘半身像,圆脸深棕色盘发配红色发圈,粉色衬衫外系米黄色围裙,开心地挥手打招呼,热情笑容露齿笑,人物居中,纯浅奶油色背景,STYLE | exit0 服装与待机像对齐,采用;#2 产物弃用 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@ -0,0 +1,176 @@
{
"game": "wanglanmei-ref",
"round": 1,
"note": "全部图片共用同一组风格词(美术统一性之根);占位店主=通用市井店主,IP 签署后换正式形象;程序化绘制(粒子/UI描边/图形)不在此表,manifest 只录外采资产。",
"styleWords": "手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁",
"assets": [
{
"file": "shopkeeper-idle.jpg",
"role": "店主立绘·待机",
"bytes": 250074,
"sha256": "d880ac0010bc545c06f1f6c5424021e9a55b0831c653befefd11a666abb1cda2",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "一位亲切的市井小卖部老板娘半身像,圆脸盘发系米色围裙,温和微笑,双手轻搭在木柜台上,人物居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "shopkeeper-wave.jpg",
"role": "店主立绘·招手",
"bytes": 205213,
"sha256": "a1ff8036ab7a729a1dc640da852d7abd3e21c73469e2cf5e9bb897f5f9e5011a",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "同一位亲切的市井小卖部老板娘半身像,圆脸深棕色盘发配红色发圈,粉色衬衫外系米黄色围裙,开心地挥手打招呼,热情笑容露齿笑,人物居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
},
"note": "第16次调用产物(服装与待机像对齐);第2次调用产物弃用"
},
{
"file": "customer-kid.jpg",
"role": "顾客头像·小学生",
"bytes": 272573,
"sha256": "a7923ef9d6ae4e10d54e6794c8f409c6a3409e9f2572f97cbcd387f0878ae267",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "小学生男孩圆形头像构图,胸像,馋嘴期待的表情,背红色书包,居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "customer-lady.jpg",
"role": "顾客头像·上班族",
"bytes": 144021,
"sha256": "0d3123c7b65bd186922752ca151a2ad95846f95da53bbce614f5403dc923f36e",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "年轻上班族女性圆形头像构图,胸像,温和微笑,低马尾辫白衬衫,居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "customer-grandpa.jpg",
"role": "顾客头像·老爷爷",
"bytes": 327951,
"sha256": "193dbdda36be380ff091e4856a465567d59809d35928336a5e384d929f6aa91d",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "慈祥老爷爷圆形头像构图,胸像,眯眼笑,戴灰色布帽白胡子,居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "customer-auntie.jpg",
"role": "顾客头像·阿姨",
"bytes": 259982,
"sha256": "233d14e41ffd41f7c54771c3ce3a8560f5ebde5c947a2bef9c2ba210d8716cc8",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "时髦中年阿姨圆形头像构图,胸像,爽朗大笑,棕色卷发戴耳环,居中,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "storefront.jpg",
"role": "店面背景(9:16)",
"bytes": 509293,
"sha256": "ab453b8b35b32ac8d02e646d6dc913e4b0c375bf44fe24b55cdebfbccb61c5f2",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "温暖的小卖部店内背景,木质货架陈列杂货,暖黄吊灯,玻璃柜台,窗外是傍晚市井街道,无人物,画面中下部干净留白,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "item-soda.jpg",
"role": "商品图·汽水",
"bytes": 113934,
"sha256": "1fee829fe5d39d0fe24fc58bf577004cf5b0a2f206ac0466d89df982a95bed48",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "一罐橙子味汽水易拉罐,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "item-snack.jpg",
"role": "商品图·辣条",
"bytes": 176052,
"sha256": "485629ceee8bc69bf9dbaa39b59cf72bac143230653352a2f7f74ba76293d995",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "一包红色包装的辣条零食,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "item-bread.jpg",
"role": "商品图·面包",
"bytes": 120166,
"sha256": "063a0aea058515fb0594c88c6a15c751d90ad8c8d0459ff9c50aeaf253e06545",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "一只金黄色菠萝包面包,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "item-ice.jpg",
"role": "商品图·冰淇淋(解锁)",
"bytes": 73755,
"sha256": "2143ba67d643edc5a012e101fdb5158680a8e5d80ddcda38fcaa057e0ecaebee",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "一支双球奶油香草冰淇淋甜筒,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "item-toy.jpg",
"role": "商品图·小玩具(解锁)",
"bytes": 99391,
"sha256": "497143628f79e8adaad4f7344719004a64dfadbd0744e23701164e12ec2d34e2",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "一辆红色小木头玩具汽车,简洁商品图标,居中特写,纯浅奶油色背景,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "ui-panel.jpg",
"role": "UI 面板底(4:3)",
"bytes": 162064,
"sha256": "3d4ddf5f9508ec7eb02ef88c3f033249553c3924824a2ad082fd3ee4e5721230",
"source": {
"tool": "mmx image generate",
"model": "image-01",
"prompt": "米色水彩纸纹理面板,四边轻微水彩晕染描边,中心干净留白,无文字无图案,手绘水彩绘本插画风,温暖市井烟火气,柔和暖色调:奶油黄、杏色、赭石棕、砖橙,笔触柔和边缘干净,儿童绘本质感,构图简洁"
}
},
{
"file": "bgm-idle.mp3",
"role": "BGM·闲时",
"bytes": 4419803,
"sha256": "37a7e4faeabd9ecab156a35bb57a309cc1bd841b9d5e2c07470f9cfd229ac1f4",
"source": {
"tool": "mmx music generate",
"model": "music-2.6-free",
"flags": "--instrumental --bpm 92",
"prompt": "温暖悠闲的市井小店白天背景音乐,木吉他指弹与口琴主奏,轻刷子鼓点与软贝斯,大调,治愈放松,适合经营游戏闲时循环播放,旋律简单上口"
}
},
{
"file": "bgm-rush.mp3",
"role": "BGM·高峰",
"bytes": 4610393,
"sha256": "690aa103725c2358d6f9fe15c8e16d2fc36e507e20fb397a0198da0682ac08c2",
"source": {
"tool": "mmx music generate",
"model": "music-2.6-free",
"flags": "--instrumental --bpm 132",
"prompt": "忙碌欢快的市井小店高峰时段背景音乐,木吉他快速扫弦与口琴主奏,手拍与沙锤节奏,大调,与同族闲时曲同一配器风格,紧凑明快但不焦躁,适合经营游戏高峰循环播放"
}
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@ -0,0 +1,17 @@
/**
* entry.js wanglanmei-ref esbuild 打包入口集成段锁参打成 iife index.html 加载
* ownerwanglanmei-ref 1
*
* 构建沿 scripts/build.mjs 锁参bundle+minify+iife+es2019+sourcemap=false BUILD.md
* node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef
*
* 定位仿 host-dev/entry.jscore 协议 + 所用插件 + 游戏逻辑/渲染/宿主汇成一个
* iife 全局WanglanmeiRefindex.html bootRef 启动α 不接 littlejsengine裁定同 host-dev
* 本文件 import-safenode import 不执行任何浏览器调用boot 由页面显式触发
*/
'use strict';
import { bootRef } from './src/main.js';
export { bootRef };

View File

@ -0,0 +1,42 @@
{
"model_remains": [
{
"start_time": 1781265600000,
"end_time": 1781280000000,
"remains_time": 7678241,
"current_interval_total_count": 0,
"current_interval_usage_count": 0,
"model_name": "general",
"current_weekly_total_count": 0,
"current_weekly_usage_count": 0,
"weekly_start_time": 1780848000000,
"weekly_end_time": 1781452800000,
"weekly_remains_time": 180478241,
"current_interval_status": 1,
"current_interval_remaining_percent": 96,
"current_weekly_status": 3,
"current_weekly_remaining_percent": 100
},
{
"start_time": 1781193600000,
"end_time": 1781280000000,
"remains_time": 7678241,
"current_interval_total_count": 3,
"current_interval_usage_count": 3,
"model_name": "video",
"current_weekly_total_count": 21,
"current_weekly_usage_count": 21,
"weekly_start_time": 1780848000000,
"weekly_end_time": 1781452800000,
"weekly_remains_time": 180478241,
"current_interval_status": 1,
"current_interval_remaining_percent": 100,
"current_weekly_status": 1,
"current_weekly_remaining_percent": 100
}
],
"base_resp": {
"status_code": 0,
"status_msg": "success"
}
}

View File

@ -0,0 +1,92 @@
<!DOCTYPE html>
<!--
index.html — 暖阳小卖部wanglanmei-ref 轮 1宿主页
用法:
· 集成段mini-desktop锁参构建见 BUILD.md产物 dist/ref-bundle.jsiife 全局 WanglanmeiRef
· serve 本目录test/harness/static-serve.cjs浏览器打开
· ?seed=12345 注入主随机种子(缺省固定种子,确定可复现);
· ?mode=evidence 取证模式:不起 RAF由 window.__refHost.stepFrames/tap 确定性驱动;
· 取证导出window.__refForensics / window.__refChecklist字段说明见 FORENSICS.md
注意:页面对外文本走 IP gate零受保护字样本页标题即对外名「暖阳小卖部」。
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- 固定取证视口390×844 移动竖屏基准;禁缩放防像素哈希扰动。 -->
<meta name="viewport" content="width=390, height=844, initial-scale=1, user-scalable=no" />
<title>暖阳小卖部</title>
<style>
/* 暖纸底居中画布:游戏全部绘制在 canvas 内,页面壳只负责承载。 */
html, body {
margin: 0;
padding: 0;
background: #2e231b;
color: #d8c6a8;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
overflow: hidden;
}
#wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px 0;
}
#game {
width: 390px;
height: 844px;
background: #f6e7c9;
display: block;
border-radius: 8px;
touch-action: none; /* 触摸全交 JSpointer 桥接),禁浏览器默认手势 */
}
#status {
font-size: 12px;
line-height: 1.4;
min-height: 15px;
max-width: 390px;
text-align: center;
word-break: break-all;
opacity: 0.85;
}
</style>
</head>
<body>
<div id="wrap">
<div id="status">booting…</div>
<canvas id="game"></canvas>
</div>
<!-- 构建产物集成段生成iife 全局 WanglanmeiRef含 bootRef-->
<script src="./dist/ref-bundle.js"></script>
<script>
// 引导:异步 boot含资产装载失败把错误打到 status + 重抛(评估门要看 console 零未捕获错误)。
(function () {
'use strict';
var statusEl = document.getElementById('status');
var canvas = document.getElementById('game');
try {
var G = window.WanglanmeiRef;
if (!G || typeof G.bootRef !== 'function') {
throw new Error('WanglanmeiRef.bootRef 未找到ref-bundle.js 未正确加载或 globalName 不符)');
}
G.bootRef({ canvas: canvas, statusEl: statusEl })
.then(function () {
window.__refBooted = true; // CDP 探活标记区分「脚本没加载」与「boot 抛错」)
})
.catch(function (e) {
window.__refBooted = false;
window.__refBootError = String((e && e.message) || e);
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__refBootError;
throw e;
});
} catch (e) {
window.__refBooted = false;
window.__refBootError = String((e && e.message) || e);
if (statusEl) statusEl.textContent = 'BOOT ERROR: ' + window.__refBootError;
throw e;
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,84 @@
/**
* assets.js 资产装载器宿主层允许浏览器原生 Image/fetch逻辑层不得 import 本文件
* ownerwanglanmei-ref 1 消费方main.jsboot await/ render.js取图
*
* 容错铁律
* 任一资产缺失/加载失败 该键置 null 并记入 missing游戏照常启动渲染层有程序化回退
* 绝不因资产把游戏带崩评估环境可能只 serve 代码不带资产
* 音频文件不在此装载需用户手势后解码 audio-director 懒加载
*/
'use strict';
/** 图像资产键 → 文件名(与 assets/manifest.json 对应)。 */
export const IMAGE_FILES = {
shopkeeperIdle: 'shopkeeper-idle.jpg',
shopkeeperWave: 'shopkeeper-wave.jpg',
customer0: 'customer-kid.jpg',
customer1: 'customer-lady.jpg',
customer2: 'customer-grandpa.jpg',
customer3: 'customer-auntie.jpg',
storefront: 'storefront.jpg',
itemSoda: 'item-soda.jpg',
itemSnack: 'item-snack.jpg',
itemBread: 'item-bread.jpg',
itemIce: 'item-ice.jpg',
itemToy: 'item-toy.jpg',
uiPanel: 'ui-panel.jpg',
};
/** 商品 id → 图像键(渲染层查表)。 */
export const PRODUCT_IMAGE_KEY = {
soda: 'itemSoda',
snack: 'itemSnack',
bread: 'itemBread',
ice: 'itemIce',
toy: 'itemToy',
};
/**
* 装载全部图像资产并行单个失败不连坐
* @param {string} [baseUrl] 资产根缺省 './assets/'
* @returns {Promise<{ images: Record<string, HTMLImageElement|null>, missing: string[], loaded: number, total: number }>}
*/
export function loadAssets(baseUrl) {
const root = baseUrl == null ? './assets/' : baseUrl;
const keys = Object.keys(IMAGE_FILES);
/** @type {Record<string, HTMLImageElement|null>} */
const images = {};
/** @type {string[]} */
const missing = [];
/** 装载单图(失败 resolve null不 reject——容错降级。@param {string} key */
function loadOne(key) {
return new Promise((resolve) => {
// 浏览器环境守卫node 侧 import 本模块不应触发 Imageimport-safe
if (typeof Image === 'undefined') {
images[key] = null;
missing.push(key);
resolve(null);
return;
}
const img = new Image();
img.onload = () => {
images[key] = img;
resolve(img);
};
img.onerror = () => {
// 资产缺失:记录并降级(渲染层走程序化回退),错误路径留痕。
console.warn('[ref-assets] 资产加载失败(已降级程序化回退):' + IMAGE_FILES[key]);
images[key] = null;
missing.push(key);
resolve(null);
};
img.src = root + IMAGE_FILES[key];
});
}
return Promise.all(keys.map(loadOne)).then(() => ({
images,
missing,
loaded: keys.length - missing.length,
total: keys.length,
}));
}

View File

@ -0,0 +1,204 @@
/**
* audio-director.js 音频导演宿主层 BGM crossfade + 合成音效 + P6 预设接线
* ownerwanglanmei-ref 1 消费方main.js事件声音 / 每帧推 crossfade
*
* 职责边界
* - 宿主层是受控面的提供方AudioContext 实例由 main audioFactory 创建用户手势解锁后
* 本模块经 getAudio() 取同一实例P6 插件经受控面 getAudioContext() 拿到的也是它同源不双开
* - BGMmmx 两首闲时 92bpm / 高峰 132bpm 同族配器fetch+decode 后双 source 同启循环
* 增益按 rushFactor 每帧插值 crossfade评审基线/忙双 BGM crossfade的正面实现
* - 音效分两路设计裁量详见 LOOP-LOG 设计说明
* P6 playSfx 预设来客=blip / 落货上架=thud / 升级=chime插件公开 API 直用
* 宿主合成osc+包络受控面提供方特权收银叮连击逐级升调P6 预设面无音高参数
* 此为补位已记 v2 边界/ 差评降调蜂鸣 / 救单上行琶音 / 大额成交低鸣+亮叮 / 点按轻嗒
* - 一切发声在未解锁/ AudioContext时静默 no-op容错降级 P6 同则
*/
'use strict';
/**
* 创建音频导演
* @param {Object} deps
* @param {() => (AudioContext|null)} deps.getAudio 取宿主 AudioContext未解锁返回 null
* @param {any} deps.p6 已注册的 audio-music 插件实例playSfx
* @param {string} [deps.baseUrl] 资产根缺省 './assets/'
* @returns {AudioDirector}
*/
export function createAudioDirector(deps) {
const getAudio = deps.getAudio;
const p6 = deps.p6;
const baseUrl = deps.baseUrl == null ? './assets/' : deps.baseUrl;
/** @type {Set<string>} 已触发过的音效种类checklist≥5 种)。 */
const sfxKinds = new Set();
/** BGM 状态。 */
const bgm = {
loading: false,
ready: false,
failed: false,
/** @type {GainNode|null} */ gainIdle: null,
/** @type {GainNode|null} */ gainRush: null,
idleLevel: 0, // 当前增益(取证可读)
rushLevel: 0,
targetRush: 0, // 目标 crossfade 比例 0..1
};
/** @type {GainNode|null} 合成音效总线(统一限幅)。 */
let sfxBus = null;
/** 取(并懒建)音效总线。@param {AudioContext} ac */
function bus(ac) {
if (!sfxBus) {
sfxBus = ac.createGain();
sfxBus.gain.value = 0.5;
sfxBus.connect(ac.destination);
}
return sfxBus;
}
/**
* 解锁后启动 BGM 装载fetch+decode 两首 双循环 source 同启幂等
* 失败资产缺位/解码失败 标记 failed 静默继续音效仍可用
*/
function startBgm() {
const ac = getAudio();
if (!ac || bgm.loading || bgm.ready || bgm.failed) return;
bgm.loading = true;
/** 装一首并接好循环 source。@param {string} file @returns {Promise<GainNode>} */
function loadTrack(file) {
return fetch(baseUrl + file)
.then((r) => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
})
.then((buf) => ac.decodeAudioData(buf))
.then((audioBuf) => {
const src = ac.createBufferSource();
src.buffer = audioBuf;
src.loop = true;
const gain = ac.createGain();
gain.gain.value = 0;
src.connect(gain);
gain.connect(ac.destination);
src.start();
return gain;
});
}
Promise.all([loadTrack('bgm-idle.mp3'), loadTrack('bgm-rush.mp3')])
.then(([gi, gr]) => {
bgm.gainIdle = gi;
bgm.gainRush = gr;
bgm.ready = true;
bgm.loading = false;
})
.catch((e) => {
// BGM 失败不连坐游戏留痕降级checklist bgm=false亲玩轮会暴露
console.warn('[ref-audio] BGM 装载失败(已降级无 BGM' + (e && e.message ? e.message : e));
bgm.failed = true;
bgm.loading = false;
});
}
/**
* 每帧推进BGM 增益向目标 crossfade 比例平滑插值 1.2s 收敛
* @param {number} dt @param {number} rushT 高峰系数 0..1 @param {boolean} musicOn 当前阶段是否要 BGM
*/
function step(dt, rushT, musicOn) {
bgm.targetRush = rushT;
if (!bgm.ready) return;
const overall = musicOn ? 1 : 0.35; // 面板/结算阶段整体收小声,不戛然而止
const targetIdle = 0.5 * (1 - rushT) * overall;
const targetRush = 0.55 * rushT * overall;
const k = Math.min(dt / 1.2, 1); // 线性追踪:约 1.2s 收敛
bgm.idleLevel += (targetIdle - bgm.idleLevel) * k;
bgm.rushLevel += (targetRush - bgm.rushLevel) * k;
bgm.gainIdle.gain.value = bgm.idleLevel;
bgm.gainRush.gain.value = bgm.rushLevel;
}
/* ── 合成音效原语(受控面提供方特权;全部短促、暖色系音色)── */
/**
* 一只包络化振荡器
* @param {AudioContext} ac @param {OscillatorType} type @param {number} f0 起始频率
* @param {number} f1 结束频率 @param {number} dur 时长 @param {number} vol 音量 @param {number} [delay]
*/
function blip(ac, type, f0, f1, dur, vol, delay) {
const t0 = ac.currentTime + (delay || 0);
const osc = ac.createOscillator();
const g = ac.createGain();
osc.type = type;
osc.frequency.setValueAtTime(f0, t0);
osc.frequency.exponentialRampToValueAtTime(Math.max(f1, 1), t0 + dur);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g);
g.connect(bus(ac));
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
/** 触发一种音效(按 kind 分路P6 预设 or 宿主合成)。@param {string} kind @param {Object} [opt] */
function sfx(kind, opt) {
const ac = getAudio();
if (!ac) return; // 未解锁:静默(与 P6 降级同则)
const o = opt || {};
switch (kind) {
case 'arrive': // 来客门铃 —— P6 预设 blip
p6.playSfx('blip');
break;
case 'shelve': // 落货上架闷响 —— P6 预设 thud
p6.playSfx('thud');
break;
case 'upgrade': // 升级风铃 —— P6 预设 chime
p6.playSfx('chime');
break;
case 'coin': {
// 收银叮:连击逐级升调(每连击 +2 半音,封顶 +20——「音调递升」手感锚点。
const combo = Math.min(o.combo || 1, 11);
const f = 660 * Math.pow(2, ((combo - 1) * 2) / 12);
blip(ac, 'sine', f, f * 1.5, 0.16, 0.5);
blip(ac, 'sine', f * 2, f * 2.6, 0.1, 0.22, 0.03);
break;
}
case 'bad': // 差评降调蜂鸣(暖色系的「闷」,不刺耳)
blip(ac, 'sawtooth', 220, 90, 0.32, 0.3);
break;
case 'rescue': // 救单上行琶音(惊险→释然)
blip(ac, 'triangle', 520, 540, 0.07, 0.32);
blip(ac, 'triangle', 660, 690, 0.07, 0.32, 0.07);
blip(ac, 'triangle', 880, 920, 0.1, 0.34, 0.14);
break;
case 'bigsale': // 大额成交:低鸣 + 高亮叮(配 hit-stop
blip(ac, 'sine', 130, 80, 0.22, 0.5);
blip(ac, 'sine', 1320, 1760, 0.18, 0.3, 0.05);
break;
case 'tick': // 通用点按轻嗒(每次 tap 的听觉回执)
blip(ac, 'triangle', 900, 700, 0.045, 0.16);
break;
case 'empty': // 空货架/无效点按(提示性短噗)
blip(ac, 'square', 180, 140, 0.08, 0.14);
break;
default:
return;
}
sfxKinds.add(kind);
}
return {
startBgm,
step,
sfx,
/** 取证:音效种类清单 / BGM 状态。 */
probe() {
return {
sfxKinds: Array.from(sfxKinds),
bgm: { ready: bgm.ready, failed: bgm.failed, idleLevel: bgm.idleLevel, rushLevel: bgm.rushLevel },
};
},
};
}
/**
* @typedef {ReturnType<typeof createAudioDirector>} AudioDirector
*/

View File

@ -0,0 +1,296 @@
/**
* balance.js 小卖部经营 · 数值与公式单一事实源纯数据 + 纯函数零依赖
* ownerwanglanmei-ref 1 消费方shop-core.js状态机/ test数值断言/ 渲染层展示
*
* 设计原则
* 1. 金额一律用整数1 = 10 杜绝浮点漂移展示层再 ÷10 格式化
* 2. 二阶决策必须数值上两头都能输定价过低 毛利薄到盖不住房租定价过高
* 客流萎缩到营收盖不住房租estimateDayOutcome 给出解析模型测试钉死该性质
* 3. 所有曲线/公式集中在本文件shop-core 只消费调参不碰状态机
*/
'use strict';
/*
* 商品表wholesale/basePrice 单位=popularity=顾客点单权重
* */
/**
* @typedef {Object} ProductDef
* @property {string} id 商品 id存档/事件键
* @property {string} name 展示名简体中文
* @property {number} wholesale 进货价
* @property {number} basePrice 基准售价priceMul=1.0
* @property {number} popularity 点单权重顾客随机点单按权重抽样
* @property {boolean} initial 开局是否解锁
*/
/** @type {ProductDef[]} 商品全表(前 3 件开局解锁,后 2 件经升级解锁)。 */
export const PRODUCTS = [
{ id: 'soda', name: '汽水', wholesale: 20, basePrice: 40, popularity: 1.0, initial: true },
{ id: 'snack', name: '辣条', wholesale: 10, basePrice: 25, popularity: 1.2, initial: true },
{ id: 'bread', name: '面包', wholesale: 30, basePrice: 60, popularity: 0.8, initial: true },
{ id: 'ice', name: '冰淇淋', wholesale: 40, basePrice: 90, popularity: 0.7, initial: false },
{ id: 'toy', name: '小玩具', wholesale: 80, basePrice: 180, popularity: 0.35, initial: false },
];
/** 按 id 取商品定义(不存在返回 undefined。@param {string} id */
export function productOf(id) {
return PRODUCTS.find((p) => p.id === id);
}
/*
* 营业日节奏单位
* */
export const DAY = {
/** 单个营业日时长(秒)≈ 96s落在 brief 的 90~120s 区间。 */
lengthSec: 96,
/** 高峰期起点(秒)。 */
rushStartSec: 30,
/** 高峰期终点(秒)。 */
rushEndSec: 66,
/** 高峰进出场的平滑斜坡——rushFactor 在边缘线性过渡,供色板/BGM crossfade。 */
rushRampSec: 3,
/** 一个段落包含的营业日数3 日一段落,首段落内必出首次升级)。 */
daysPerSegment: 3,
};
/**
* 计算某时刻的高峰系数 rushFactor [0,1]0=闲时,1=高峰边缘 3s 线性渐变
* @param {number} tSec 营业日内时刻
* @returns {number}
*/
export function rushFactorAt(tSec) {
const { rushStartSec: a, rushEndSec: b, rushRampSec: r } = DAY;
if (tSec <= a - r || tSec >= b + r) return 0;
if (tSec >= a && tSec <= b) return 1;
if (tSec < a) return (tSec - (a - r)) / r; // 进高峰斜坡
return 1 - (tSec - b) / r; // 出高峰斜坡
}
/*
* 客流模型定价 × 声誉 × 装修 到店间隔
* */
export const FLOW = {
/** 闲时基础到店间隔(秒)。 */
idleIntervalSec: 4.6,
/** 高峰基础到店间隔(秒)。 */
rushIntervalSec: 2.3,
/** 间隔随机抖动区间(乘数,[min,max))。 */
jitterMin: 0.85,
jitterMax: 1.2,
/** 排队槽位数(满了新客进不来=流失,不掉声誉)。 */
queueSlots: 4,
};
/**
* 定价客流乘数曲线核心二阶决策之一
* 低价端斜率 1.5薄利多销高价端斜率 1.5贵了没人来下限 0.25
* 锚点0.81.30 1.01.00 1.250.625 1.50.25
* @param {number} priceMul 定价乘数 [0.8, 1.5]
* @returns {number} 客流乘数
*/
export function flowMulOfPrice(priceMul) {
if (priceMul <= 1) return 1 + (1 - priceMul) * 1.5;
return Math.max(0.25, 1 - (priceMul - 1) * 1.5);
}
/**
* 声誉客流乘数口碑带客锚点rep501.0 rep1001.45 rep00.55
* @param {number} reputation 声誉 [0,100]
* @returns {number}
*/
export function flowMulOfReputation(reputation) {
return 0.55 + 0.9 * (clamp(reputation, 0, 100) / 100);
}
/*
* 耐心与声誉
* */
export const PATIENCE = {
/** 闲时耐心总量(秒)。 */
idleSec: 13,
/** 高峰耐心总量(秒,更短=压力态)。 */
rushSec: 8.5,
/** 耐心耗尽后的「救单宽限」宽限内仍可接待coyote 语义,逻辑层内实现)。 */
graceSec: 0.55,
/** 缺货顾客「看了一眼货架」后离店的停留时长(秒)。 */
stockoutLingerSec: 2.5,
/** 耐心低于该比例时触发一次「不耐烦」事件(气泡/音效挂点)。 */
impatientRatio: 0.32,
};
export const REP = {
/** 开局声誉。 */
start: 62,
max: 100,
/** 每次成功接待 +×10 存为整数避免浮点:实际 +0.55,内部用 repTenth。 */
serveGainTenth: 6, // +0.6
/** 救单(宽限期内接住)额外 +1.0。 */
rescueBonusTenth: 10,
/** 耐心耗尽愤怒离店 4.0。 */
angryLossTenth: 40,
/** 缺货失望离店 2.5。 */
stockoutLossTenth: 25,
/** 日结「全天零差评」口碑回暖 +1.5。 */
noAngryBonusTenth: 15,
/** 罚没阈值:日结时声誉 < 30 → 触发罚款。 */
fineThreshold: 30,
};
/*
* 经济单位
* */
export const ECON = {
/** 开局资金(角)= ¥120。 */
startCashJiao: 1200,
/** 基础日租金(角)= ¥40每进一个段落 +¥12递增压力。 */
rentBaseJiao: 400,
rentPerSegmentJiao: 120,
/** 罚款基数(角)= ¥24 + 每个差评 ¥2声誉 < fineThreshold 的日结触发)。 */
fineBaseJiao: 240,
finePerAngryJiao: 20,
/** 大额成交阈值(角):单笔 ≥ ¥9 触发 hit-stop/screen-shake 级反馈。 */
bigSaleJiao: 90,
/** 连击小费:每级连击 +4% 售价,封顶 +32%(连击 9。 */
comboTipStep: 0.04,
comboTipCap: 0.32,
/** 定价乘数边界与步进。 */
priceMin: 0.8,
priceMax: 1.5,
priceStep: 0.05,
};
/**
* 商品当前售价基准价 × 定价乘数取整到角
* @param {string} productId
* @param {number} priceMul
* @returns {number} 售价
*/
export function priceJiaoOf(productId, priceMul) {
const p = productOf(productId);
return p ? Math.round(p.basePrice * priceMul) : 0;
}
/**
* 连击小费乘数连击 n1时售价乘 1 + min((n-1)×step, cap)
* @param {number} combo 当前连击数
* @returns {number}
*/
export function comboTipMul(combo) {
const bonus = Math.min(Math.max(combo - 1, 0) * ECON.comboTipStep, ECON.comboTipCap);
return 1 + bonus;
}
/*
* 升级树货架容量 / 收银速度 / 店面装修 / 新品解锁
* */
/**
* @typedef {Object} UpgradeDef
* @property {string} id 升级 id
* @property {string} name 展示名
* @property {string} desc 一句话效果描述展示用
* @property {number} costJiao 价格
* @property {string|null} requires 前置升级 id=null
* @property {string|null} unlocksProduct 解锁的商品 id非解锁类=null
*/
/** @type {UpgradeDef[]} 升级树(首段落内最低 ¥55 可达首次升级)。 */
export const UPGRADES = [
{ id: 'speed1', name: '收银加速 I', desc: '收银间隔 0.55s→0.42s', costJiao: 550, requires: null, unlocksProduct: null },
{ id: 'shelf1', name: '货架扩容 I', desc: '库存上限 24→34', costJiao: 650, requires: null, unlocksProduct: null },
{ id: 'unlock_ice', name: '新品·冰淇淋', desc: '解锁高毛利冰淇淋', costJiao: 750, requires: null, unlocksProduct: 'ice' },
{ id: 'decor1', name: '店面装修 I', desc: '顾客耐心 +15%,日结声誉 +3', costJiao: 850, requires: null, unlocksProduct: null },
{ id: 'unlock_toy', name: '新品·小玩具', desc: '解锁高单价小玩具', costJiao: 1100, requires: null, unlocksProduct: 'toy' },
{ id: 'speed2', name: '收银加速 II', desc: '收银间隔 0.42s→0.32s', costJiao: 1300, requires: 'speed1', unlocksProduct: null },
{ id: 'shelf2', name: '货架扩容 II', desc: '库存上限 34→48', costJiao: 1500, requires: 'shelf1', unlocksProduct: null },
{ id: 'decor2', name: '店面装修 II', desc: '耐心 +30%,日结声誉 +6客流 +12%', costJiao: 1900, requires: 'decor1', unlocksProduct: null },
];
/** 按 id 取升级定义。@param {string} id */
export function upgradeOf(id) {
return UPGRADES.find((u) => u.id === id);
}
/**
* 已购升级 id 集合推导生效参数货架容量/收银冷却/装修加成
* @param {Record<string, boolean>} owned 已购集合idtrue
* @returns {{ shelfCap:number, serveCooldownSec:number, patienceMul:number, settleRepBonusTenth:number, flowMul:number }}
*/
export function derivedOfUpgrades(owned) {
const has = (id) => !!owned[id];
return {
// 货架容量24 → 34 → 48。
shelfCap: has('shelf2') ? 48 : has('shelf1') ? 34 : 24,
// 收银冷却0.55 → 0.42 → 0.32 秒。
serveCooldownSec: has('speed2') ? 0.32 : has('speed1') ? 0.42 : 0.55,
// 装修:耐心乘数 / 日结声誉回复×10 整数)/ 客流乘数。
patienceMul: has('decor2') ? 1.3 : has('decor1') ? 1.15 : 1,
settleRepBonusTenth: has('decor2') ? 60 : has('decor1') ? 30 : 0,
flowMul: has('decor2') ? 1.12 : 1,
};
}
/*
* 解析日收益模型测试钉死两头都能输+ 内部预判用不进游戏 UI
* */
/**
* 估算一个营业日的期望结果解析近似不含随机
* 模型到客数 = 基础到客 × 定价客流 × 声誉客流成交数 = min(到客, 接待力上限, 备货)
* 毛利 = Σ(售价 进货价) popularity 加权均值 × 成交数净利 = 毛利 房租
* @param {Object} opts
* @param {number} opts.priceMul 定价乘数
* @param {number} [opts.reputation] 声誉缺省 REP.start
* @param {number} [opts.serveCapacity] 全天接待力上限缺省 26对应人手一台收银
* @param {number} [opts.stockUnits] 备货量上限缺省 Infinity仅算定价效应
* @param {number} [opts.segment] 段落决定房租缺省 1
* @param {string[]} [opts.unlocked] 解锁商品缺省开局 3
* @returns {{ arrivals:number, served:number, grossJiao:number, rentJiao:number, profitJiao:number }}
*/
export function estimateDayOutcome(opts) {
const priceMul = opts.priceMul;
const reputation = opts.reputation == null ? REP.start : opts.reputation;
const serveCapacity = opts.serveCapacity == null ? 26 : opts.serveCapacity;
const stockUnits = opts.stockUnits == null ? Infinity : opts.stockUnits;
const segment = opts.segment == null ? 1 : opts.segment;
const unlocked = opts.unlocked || PRODUCTS.filter((p) => p.initial).map((p) => p.id);
// 基础到客数:闲时段 + 高峰段按各自间隔积分。
const idleSec = DAY.lengthSec - (DAY.rushEndSec - DAY.rushStartSec);
const baseArrivals = idleSec / FLOW.idleIntervalSec + (DAY.rushEndSec - DAY.rushStartSec) / FLOW.rushIntervalSec;
const arrivals = baseArrivals * flowMulOfPrice(priceMul) * flowMulOfReputation(reputation);
const served = Math.min(arrivals, serveCapacity, stockUnits);
// popularity 加权平均毛利(角/单)。
const defs = unlocked.map((id) => productOf(id)).filter(Boolean);
const wSum = defs.reduce((s, p) => s + p.popularity, 0);
const avgMarginJiao = defs.reduce(
(s, p) => s + (Math.round(p.basePrice * priceMul) - p.wholesale) * (p.popularity / wSum),
0
);
const grossJiao = Math.round(served * avgMarginJiao);
const rentJiao = ECON.rentBaseJiao + (segment - 1) * ECON.rentPerSegmentJiao;
return { arrivals, served, grossJiao, rentJiao, profitJiao: grossJiao - rentJiao };
}
/*
* 通用小工具
* */
/** 钳制。@param {number} v @param {number} lo @param {number} hi */
export function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}
/** 角 → 元字符串(如 1234 → "123.4";整元省略小数)。@param {number} jiao */
export function fmtYuan(jiao) {
const yuan = jiao / 10;
return Number.isInteger(yuan) ? String(yuan) : yuan.toFixed(1);
}

View File

@ -0,0 +1,309 @@
/**
* hostfx.js 表现层导演宿主层缓动补间 / 弹簧 / 弹道抛掷 / 粒子分层 / juice / 浮字
* ownerwanglanmei-ref 1 消费方main.js事件表现/ render.js读取动画值并绘制
*
* 插件用法全部走公开 API
* - P4 gamefeel.easing一切补间曲线面板滑入/商品飞行/气泡弹出
* - P2 physics-lite.springStep按钮按压脉冲/落货压扁/连击徽章弹跳弹簧阻尼手感
* - P2 physics-lite.Projectile进货抛掷上架弹道解析初速 半隐式欧拉积分真弹道
* - P3 particles-juice多实例分层取色插件不内置颜色集成段 REPORT §4.3 教训
* 宿主为每层提供色族金币金/奶油亮/暖棕烟render 前逐层设 fillStyle
* juice 套件hit-stop/屏震/闪白/脉冲只用注册进 registry 的权威实例全局唯一时间缩放
*
* 双时间轴step(dtReal, dtWorld)世界系动画飞行/弹道/浮字 dtWorld hit-stop 冻结
* 打击骤停时商品也悬停这正是手感UI 系动画按钮弹簧/面板补间 dtReal界面不卡死
*/
'use strict';
import { easing } from '../../../src/plugins/gamefeel/impl.js';
import { springStep, Projectile } from '../../../src/plugins/physics-lite/impl.js';
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
/**
* 创建表现层导演
* @param {Object} deps
* @param {import('../../../src/core/api.d.ts').HostDevContextBundle} deps.bundle 宿主受控面 bundle派生粒子层上下文用
* @param {any} deps.juice 已注册的 particles-juice 权威实例juice 套件 + 闪白
* @returns {HostFx}
*/
export function createHostFx(deps) {
const bundle = deps.bundle;
const juice = deps.juice;
/* ── 粒子分层(多实例 = 多色族 + 各自独立环境曲线,规避 v1「全局最近发射器环境」串扰── */
/**
* 建一层粒子独立插件实例 + 派生受控上下文host 拥有 bundle合法派生autoStep tick 驱动
* @param {string} name 层名参与随机子种子派生
* @returns {any} particles-juice 实例
*/
function makeLayer(name) {
const inst = createParticlesJuicePlugin({ maxParticles: 400 });
inst.init(bundle.deriveContextFor(name, name));
return inst;
}
const layerGold = makeLayer('fx-gold'); // 金币金(收钱)
const layerCream = makeLayer('fx-cream'); // 奶油亮(满意/救单/升级)
const layerSmoke = makeLayer('fx-smoke'); // 暖棕烟(差评/缺货)
/* ── 补间UI 时间轴)── */
/** @type {Array<{t:number,dur:number,ease:(x:number)=>number,onUpdate:(v:number)=>void,onDone:(()=>void)|null}>} */
let tweens = [];
/**
* 加一条补间dur 秒内 onUpdate(eased t)
* @param {number} dur @param {(v:number)=>void} onUpdate
* @param {{ease?:(x:number)=>number, onDone?:()=>void}} [opts]
*/
function addTween(dur, onUpdate, opts) {
tweens.push({
t: 0,
dur: Math.max(dur, 0.001),
ease: (opts && opts.ease) || easing.cubicOut,
onUpdate,
onDone: (opts && opts.onDone) || null,
});
}
/* ── 弹簧表UI 时间轴springStep 半隐式积分)── */
/** @type {Map<string, {state:{x:number,v:number}, target:number, stiffness:number, damping:number}>} */
const springs = new Map();
/**
* 读弹簧当前值不存在则建于 rest
* @param {string} id @param {number} [rest] 静止值缺省 1
*/
function springVal(id, rest) {
let s = springs.get(id);
if (!s) {
s = { state: { x: rest == null ? 1 : rest, v: 0 }, target: rest == null ? 1 : rest, stiffness: 320, damping: 14 };
springs.set(id, s);
}
return s.state.x;
}
/**
* 弹簧脉冲瞬时速度踢一脚按压/弹跳手感目标值回弹
* @param {string} id @param {number} kick 速度冲量 -3=先压扁再弹回
* @param {number} [target] 新目标缺省保持
*/
function springKick(id, kick, target) {
springVal(id); // 确保存在
const s = springs.get(id);
s.state.v += kick;
if (target != null) s.target = target;
}
/* ── 商品飞行世界时间轴easing 弧线)── */
/** @type {Array<{t:number,dur:number,fx:number,fy:number,tx:number,ty:number,arc:number,imageKey:string|null,label:string,onArrive:(()=>void)|null}>} */
let flights = [];
/**
* 货架顾客的商品飞行非线性缓动 + 抛物弧
* @param {{fx:number,fy:number,tx:number,ty:number,dur?:number,arc?:number,imageKey?:string,label?:string,onArrive?:()=>void}} o
*/
function addFlight(o) {
flights.push({
t: 0,
dur: o.dur || 0.3,
fx: o.fx,
fy: o.fy,
tx: o.tx,
ty: o.ty,
arc: o.arc == null ? 46 : o.arc,
imageKey: o.imageKey || null,
label: o.label || '',
onArrive: o.onArrive || null,
});
}
/* ── 进货抛掷世界时间轴P2 Projectile 真弹道)── */
/** @type {Array<{proj:Projectile, age:number, dur:number, tx:number, ty:number, imageKey:string|null, onLand:(()=>void)|null}>} */
let tosses = [];
/** 弹道重力(单位/秒²,画布坐标系 y 向下)。 */
const TOSS_G = 980;
/**
* 抛一件货上架按目标点与飞行时长解析初速vx=dx/T, vy=(dy½gT²)/T逐帧积分
* @param {{fx:number,fy:number,tx:number,ty:number,dur?:number,imageKey?:string,onLand?:()=>void}} o
*/
function addToss(o) {
const T = o.dur || 0.45;
const vx = (o.tx - o.fx) / T;
const vy = (o.ty - o.fy - 0.5 * TOSS_G * T * T) / T;
tosses.push({
proj: new Projectile({ px: o.fx, py: o.fy, vx, vy }, { gravity: { x: 0, y: TOSS_G } }),
age: 0,
dur: T,
tx: o.tx,
ty: o.ty,
imageKey: o.imageKey || null,
onLand: o.onLand || null,
});
}
/* ── 浮字 / 点按涟漪(世界时间轴)── */
/** @type {Array<{text:string,x:number,y:number,t:number,dur:number,colorName:string,size:number}>} */
let floaters = [];
/** 浮字(+¥4.5 / 差评! / 连击×N。 */
function addFloater(text, x, y, colorName, size) {
floaters.push({ text, x, y, t: 0, dur: 0.9, colorName: colorName || 'gold', size: size || 16 });
}
/** @type {Array<{x:number,y:number,t:number}>} 点按涟漪(每次 tap 当帧出现=反馈零延迟)。 */
let tapRings = [];
function addTapRing(x, y) {
tapRings.push({ x, y, t: 0 });
}
/* ── 粒子触发配方(中性预设 + 局部覆盖;语义/颜色=游戏层)── */
/** 收钱金光:向上喷泉(重力回落)。 */
function burstCoin(x, y, big) {
layerGold.spawnEmitter('burst', x, y, {
count: big ? 26 : 13,
angle: -Math.PI / 2,
spread: 0.85,
speedMin: 130,
speedMax: big ? 320 : 240,
gravity: 420,
lifeMin: 0.35,
lifeMax: 0.7,
sizeCurve: { kind: 'decay', from: big ? 5.5 : 4, to: 0, power: 1.3 },
});
}
/** 满意亮尘:轻飘上扬。 */
function burstSparkle(x, y, n) {
layerCream.spawnEmitter('burst', x, y, {
count: n || 10,
angle: -Math.PI / 2,
spread: 1.1,
speedMin: 40,
speedMax: 110,
gravity: -30,
lifeMin: 0.4,
lifeMax: 0.8,
sizeCurve: { kind: 'decay', from: 3, to: 0, power: 1 },
});
}
/** 差评烟尘:闷闷地散开。 */
function burstSmoke(x, y) {
layerSmoke.spawnEmitter('burst', x, y, {
count: 12,
angle: -Math.PI / 2,
spread: Math.PI,
speedMin: 25,
speedMax: 70,
gravity: -18,
drag: 1.2,
lifeMin: 0.5,
lifeMax: 1.0,
sizeCurve: { kind: 'easeInOut', from: 5, to: 1 },
alphaCurve: { kind: 'decay', from: 0.7, to: 0, power: 1.5 },
});
}
/* ── 主步进 ── */
/**
* 推进表现层一帧
* @param {number} dtReal UI 时间步
* @param {number} dtWorld 世界时间步已乘 hit-stop 缩放
*/
function step(dtReal, dtWorld) {
// 补间UI
for (let i = tweens.length - 1; i >= 0; i--) {
const tw = tweens[i];
tw.t += dtReal;
const k = Math.min(tw.t / tw.dur, 1);
tw.onUpdate(tw.ease(k));
if (k >= 1) {
if (tw.onDone) tw.onDone();
tweens.splice(i, 1);
}
}
// 弹簧UIspringStep 内部自适应子步,安全稳定)。
for (const s of springs.values()) {
s.state = springStep(s.state, s.target, { stiffness: s.stiffness, damping: s.damping }, dtReal);
}
// 飞行(世界)。
for (let i = flights.length - 1; i >= 0; i--) {
const f = flights[i];
f.t += dtWorld;
if (f.t >= f.dur) {
if (f.onArrive) f.onArrive();
flights.splice(i, 1);
}
}
// 弹道世界Projectile 固定步长积分)。
for (let i = tosses.length - 1; i >= 0; i--) {
const t = tosses[i];
t.age += dtWorld;
t.proj.step(dtWorld);
if (t.age >= t.dur) {
if (t.onLand) t.onLand();
tosses.splice(i, 1);
}
}
// 浮字 / 涟漪(世界)。
for (let i = floaters.length - 1; i >= 0; i--) {
const f = floaters[i];
f.t += dtWorld;
if (f.t >= f.dur) floaters.splice(i, 1);
}
for (let i = tapRings.length - 1; i >= 0; i--) {
tapRings[i].t += dtReal;
if (tapRings[i].t >= 0.32) tapRings.splice(i, 1);
}
}
/**
* 绘制粒子分层每层宿主供色插件无内置颜色+ 权威实例 render 含闪白叠加
* @param {CanvasRenderingContext2D} g
* @param {{c:(n:string,a?:number)=>string}} pal
*/
function renderParticles(g, pal) {
g.save();
g.fillStyle = pal.c('coin');
layerGold.render(g);
g.fillStyle = pal.c('cream');
layerCream.render(g);
g.fillStyle = pal.c('smoke');
layerSmoke.render(g);
g.restore();
// 权威实例无粒子render 仅绘 hit 闪白叠加层。
juice.render(g);
}
/** @type {HostFx} */
const fx = {
easing,
addTween,
springVal,
springKick,
addFlight,
addToss,
addFloater,
addTapRing,
burstCoin,
burstSparkle,
burstSmoke,
step,
renderParticles,
/** 世界系动画的只读视图render.js 取用绘制)。 */
view: { flights, tosses, floaters, tapRings },
/** juice 套件直通hit-stop/屏震/闪白/脉冲,全局唯一实例)。 */
juice,
/** 粒子层探针(取证:各层粒子数)。 */
particleProbe() {
return {
gold: layerGold.particleCount(),
cream: layerCream.particleCount(),
smoke: layerSmoke.particleCount(),
};
},
/** 释放手动管理的粒子层(页面卸载/重启用;幂等)。 */
dispose() {
layerGold.dispose();
layerCream.dispose();
layerSmoke.dispose();
},
};
return fx;
}
/**
* @typedef {ReturnType<typeof createHostFx>} HostFx
*/

View File

@ -0,0 +1,154 @@
/**
* loop-proof.js 闭环可达 / 失败态可达的脚本化证明纯逻辑 DOM
* ownerwanglanmei-ref 1 消费方test断言+ main.jsboot 时跑一遍结果挂 __refForensics
*
* 定位
* 用固定策略驱动 shop-core产出两份可机读证明
* 1. proveCoreLoop开局 进货定价 营业接待 首日结算 首次升级 闭环可达
* 并记录全部操作op 序列供断言操作数 上限结构深度的客观证据
* 2. proveFailure摆烂策略不接待 差评滚雪球 罚没 失败终态 失败真实可达
* spike 判词只胜不败的反面证据
* 两份证明都确定性注入 SeededRandom同种子重跑逐字段一致
*/
'use strict';
import { SeededRandom } from '../../../src/core/plugin.js';
import { createShopCore } from './shop-core.js';
import { PRODUCTS, UPGRADES } from './balance.js';
/** 仿真步长30Hz 足够覆盖逻辑分支,跑得快。 */
const STEP = 1 / 30;
/**
* 闭环证明开局首日结算首次升级
* 策略均衡备货每件 7/8/5 定价 1.15解析模型内的较优区 营业时有货且有客就接待
* 日结确认 买得起的最便宜升级
* @param {number} [seed] 随机种子缺省 20260612
* @param {number} [maxOps] 操作数上限缺省 64超限即判失败
* @returns {{ ok:boolean, opCount:number, ops:string[], day:number, upgradeId:string|null,
* served:number, cashJiao:number, reputation:number, failReason:string|null }}
*/
export function proveCoreLoop(seed, maxOps) {
const limit = maxOps == null ? 64 : maxOps;
const core = createShopCore({ random: new SeededRandom(seed == null ? 20260612 : seed) });
/** @type {string[]} 操作流水(人读得懂的闭环路径证据)。 */
const ops = [];
/** 记一次玩家操作。@param {string} desc */
const op = (desc) => ops.push(desc);
// ① 开局。
core.startGame(null);
op('startGame');
// ② 进货:按销量预期均衡备货(汽水 7 / 辣条 8 / 面包 5 = 20 件 ≤ 容量 24
const plan = { soda: 7, snack: 8, bread: 5 };
for (const id of Object.keys(plan)) {
const r = core.buyStock(id, plan[id]);
op(`buyStock(${id},${plan[id]})`);
if (!r.ok) return fail(`进货失败:${id}:${r.reason}`);
}
// ③ 定价1.15(利润与客流的平衡点附近)。
core.setPrice(1.15);
op('setPrice(1.15)');
// ④ 开门营业。
if (!core.startDay().ok) return fail('startDay失败');
op('startDay');
// ⑤ 营业:固定策略逐帧推进——出现「有货可接」的顾客且冷却就接待(每次接待计 1 op
let guard = 0;
while (core.state.phase === 'open' && guard++ < 30 * 200) {
core.update(STEP);
if (core.state.serveCooldown > 0) continue;
const c = core.state.customers.find(
(x) => (x.mood === 'queue' || x.mood === 'leaving') && (core.state.stock[x.productId] || 0) > 0
);
if (c) {
const r = core.serve(c.productId);
if (r.ok) op(`serve(${c.productId})`);
}
}
if (core.state.phase !== 'settle') return fail(`未到达日结(phase=${core.state.phase})`);
// ⑥ 日结确认。
const served = core.state.settle.served;
core.confirmSettle();
op('confirmSettle');
if (core.state.phase !== 'prep') return fail(`日结后未回 prep(phase=${core.state.phase})`);
// ⑦ 首次升级:买「买得起的最便宜」(升级树最低 ¥55首段落内必可达
const affordable = UPGRADES.filter((u) => !u.requires && core.state.cash >= u.costJiao).sort(
(a, b) => a.costJiao - b.costJiao
)[0];
if (!affordable) return fail(`第2天开市前无可负担升级(cash=${core.state.cash})`);
const r = core.buyUpgrade(affordable.id);
op(`buyUpgrade(${affordable.id})`);
if (!r.ok) return fail(`升级购买失败:${r.reason}`);
core.drainEvents(); // 清空事件(证明只看状态终点)
return {
ok: ops.length <= limit,
opCount: ops.length,
ops,
day: core.state.day,
upgradeId: affordable.id,
served,
cashJiao: core.state.cash,
reputation: core.reputation(),
failReason: ops.length <= limit ? null : `操作数超限:${ops.length}>${limit}`,
};
/** 失败收口。@param {string} why */
function fail(why) {
return { ok: false, opCount: ops.length, ops, day: core.state.day, upgradeId: null, served: 0, cashJiao: core.state.cash, reputation: core.reputation(), failReason: why };
}
}
/**
* 失败态证明摆烂策略备货极少 + 全天不接待 差评滚雪球 罚没 失败终态
* @param {number} [seed] 随机种子缺省 4422
* @param {number} [maxDays] 最多模拟天数缺省 4超出仍未失败则证明不成立
* @returns {{ ok:boolean, reason:string|null, days:number, finedJiao:number, angry:number, events:string[] }}
*/
export function proveFailure(seed, maxDays) {
const days = maxDays == null ? 4 : maxDays;
const core = createShopCore({ random: new SeededRandom(seed == null ? 4422 : seed) });
core.startGame(null);
let finedJiao = 0;
let angry = 0;
/** @type {string[]} 关键事件流水fined/gameover 证据)。 */
const trail = [];
for (let d = 0; d < days; d++) {
// 故意只买 1 件货(缺货流失双杀)然后开门完全不接待。
core.buyStock('snack', 1);
core.setPrice(1.0);
if (!core.startDay().ok) break;
let guard = 0;
while (core.state.phase === 'open' && guard++ < 30 * 200) {
core.update(STEP);
}
// 收集本日事件证据。
for (const e of core.drainEvents()) {
if (e.type === 'fined') {
finedJiao += e.fineJiao;
trail.push(`day${core.state.day}:fined:${e.fineJiao}`);
} else if (e.type === 'angry_leave') {
angry += 1;
} else if (e.type === 'gameover') {
trail.push(`day${core.state.day}:gameover:${e.reason}`);
}
}
if (core.state.phase === 'gameover') {
return { ok: true, reason: core.state.gameoverReason, days: core.state.day, finedJiao, angry, events: trail };
}
if (core.state.phase === 'settle') core.confirmSettle();
if (core.state.phase === 'summary') core.confirmSummary();
}
return { ok: false, reason: null, days, finedJiao, angry, events: trail };
}
/** 商品/升级表再导出(证明消费方免双 import。 */
export { PRODUCTS, UPGRADES };

View File

@ -0,0 +1,823 @@
/**
* main.js wanglanmei-ref 宿主引导浏览器层受控面提供方
* ownerwanglanmei-ref 1 消费方entry.jsesbuild 入口 index.html
*
* 职责仿 host-dev/host.js 样板
* - Canvas 390×844 DPR2取证固定环境+ 步进时钟?mode=evidence 完全确定性
* - createHostDevContext 受控面 PluginRegistry 注册 7 件业务插件 + probe 宿主自管六锚链
* - DOM 输入 归一化 HostDevInputBridge._emit真实交互灌进受控输入面
* - 游戏逻辑shop-core 纯模块只吃受控 dt/随机渲染/资产/BGM 在宿主层
* - 取证导出window.__refForensics / window.__refChecklist / window.__refHost驱动钩子
*
* 红线遵守
* - 游戏逻辑零裸 Date.now/Math.random/AudioContext 全部经受控面/注入
* - 宿主层是受控面提供方RAF/Image/fetch/AudioContext 工厂只在本层出现
* - import littlejsengineα 裁定发行版 v0 = core+插件引擎桩=RAF+tick
*/
'use strict';
import { PluginRegistry, createHostDevContext, CORE_PROTOCOL_VERSION } from '../../../src/core/plugin.js';
import { createCollisionPlugin, aabbVsAabb } from '../../../src/plugins/collision/impl.js';
import { createPhysicsLitePlugin } from '../../../src/plugins/physics-lite/impl.js';
import { createGamefeelPlugin } from '../../../src/plugins/gamefeel/impl.js';
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
import { createPalettePostPlugin } from '../../../src/plugins/palette-post/impl.js';
import { createAudioMusicPlugin } from '../../../src/plugins/audio-music/impl.js';
import { createSaveProgressPlugin, createMemoryAdapter, createLocalStorageAdapter } from '../../../src/plugins/save-progress/impl.js';
import { createRuntimeProbePlugin } from '../../../src/plugins/runtime-probe/impl.js';
import { createShopCore } from './shop-core.js';
import { createLinePicker, LINES } from './shopkeeper-lines.js';
import { createPalette } from './palette.js';
import { loadAssets, PRODUCT_IMAGE_KEY } from './assets.js';
import { createHostFx } from './hostfx.js';
import { createAudioDirector } from './audio-director.js';
import { drawFrame, computeZones, shelfSlotRect, W as VIEW_W, H as VIEW_H, LAYOUT } from './render.js';
import { proveCoreLoop, proveFailure } from './loop-proof.js';
import { estimateDayOutcome, ECON, fmtYuan, productOf } from './balance.js';
/** 取证固定环境(与 host-dev / browser-evidence FIXED_ENV 对齐)。 */
const DPR = 2;
const FIXED_DT = 1 / 60;
/** 读 URL 参数。 */
function getParam(name, def) {
try {
const v = new URLSearchParams(window.location.search).get(name);
return v == null ? def : v;
} catch {
return def;
}
}
/** 解析种子(缺省固定值,保证「不传也确定」)。 */
function resolveSeed() {
const raw = getParam('seed', null);
if (raw == null) return 0x1234abcd >>> 0;
const n = Number(raw);
return Number.isFinite(n) ? n >>> 0 : 0x1234abcd >>> 0;
}
/** Canvas 设为逻辑 390×844 / 物理 ×DPR并把 2D 上下文缩放到逻辑坐标。 */
function prepareCanvas(canvas) {
canvas.width = VIEW_W * DPR;
canvas.height = VIEW_H * DPR;
canvas.style.width = VIEW_W + 'px';
canvas.style.height = VIEW_H + 'px';
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
return ctx;
}
/**
* 启动参考件
* @param {{canvas: HTMLCanvasElement, statusEl?: HTMLElement}} opts
* @returns {Promise<object>} refHost 句柄同时挂 window.__refHost
*/
export async function bootRef(opts) {
const canvas = opts.canvas;
const statusEl = opts.statusEl || null;
const seed = resolveSeed();
const mode = getParam('mode', 'play'); // 'play' | 'evidence'
const g = prepareCanvas(canvas);
/** 状态行(仅人看)。 */
function setStatus(msg) {
if (statusEl) statusEl.textContent = msg;
}
/* ── 未捕获错误归集评估门console 零未捕获错误)── */
/** @type {Array<{type:string,message:string}>} */
const uncaught = [];
window.addEventListener('error', (e) => uncaught.push({ type: 'error', message: String((e && e.message) || e) }));
window.addEventListener('unhandledrejection', (e) =>
uncaught.push({ type: 'unhandledrejection', message: String((e && e.reason && e.reason.message) || (e && e.reason) || e) })
);
/* ── 受控音频工厂(用户手势解锁后才 new宿主=受控面提供方)── */
let realAudioCtx = null;
let audioUnlocked = false;
function audioFactory() {
if (!audioUnlocked) return null;
if (realAudioCtx) return realAudioCtx;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
realAudioCtx = new AC();
return realAudioCtx;
}
/* ── 受控面 bundle步进时钟 + 主种子 + 真 2D 上下文 ── */
let mockNowMs = 0;
const bundle = createHostDevContext({ context2d: g, seed, audioFactory, clock: () => mockNowMs });
/* ── 注册 7 件业务插件probe 宿主自管保证六锚偏序——host-dev 同款编排)── */
const registry = new PluginRegistry();
const collision = createCollisionPlugin({ cellSize: 96 });
const physics = createPhysicsLitePlugin();
const gamefeel = createGamefeelPlugin({ inputBufferMs: 260, inputTypes: ['pointerdown'] });
const juice = createParticlesJuicePlugin({ seed, maxParticles: 600 });
const palettePost = createPalettePostPlugin({ vignette: { enabled: true, strength: 0.22 } });
const audioMusic = createAudioMusicPlugin({ masterVolume: 0.8 });
// P9 后端:浏览器 localStorage不可用环境回退内存 adapter错误路径留痕
let saveAdapter;
try {
saveAdapter = createLocalStorageAdapter();
} catch (e) {
console.warn('[ref] localStorage 不可用,存档退内存 adapter' + (e && e.message));
saveAdapter = createMemoryAdapter();
}
const save = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter: saveAdapter });
registry
.register(collision)
.register(physics)
.register(gamefeel)
.register(juice)
.register(palettePost)
.register(save)
.register(audioMusic);
registry.useContext(bundle.context);
const initRes = registry.initAll();
/* ── runtime-probe宿主自管六锚 canonical 顺序t_input_bound 恰在 first_paint 后)── */
const pluginSet = registry.list().concat(['runtime-probe']);
const probe = createRuntimeProbePlugin({ pluginSet, inputAnchorType: 'pointerdown', autoBoot: true });
const probeCtx = bundle.deriveContextFor('runtime-probe', 'runtime-probe');
/** 安全打点probe 异常不中断启动链)。 */
function mark(anchor, extra) {
try {
probe.mark(anchor, extra);
} catch (e) {
uncaught.push({ type: 'mark', message: String((e && e.message) || e) });
}
}
mark('t_boot', { by: 'ref-host', stage: 'pre-init' });
mark('t_plugins_ready', { initialized: initRes.initialized.length, ok: initRes.ok });
/* ── 游戏对象(逻辑随机=派生流;台词随机=另一派生流——互不推演)── */
const core = createShopCore({ random: bundle.deriveContextFor('shop-core', 'shop-core').random });
const pickLine = createLinePicker(bundle.deriveContextFor('shop-lines', 'shop-lines').random);
const pal = createPalette();
const fx = createHostFx({ bundle, juice });
const audio = createAudioDirector({ getAudio: () => realAudioCtx, p6: audioMusic });
/* ── 资产装载(容错:缺失走程序化回退,照常启动)── */
setStatus('装载资产…');
const assets = await loadAssets('./assets/');
/* ── boot 期纯逻辑证明(闭环/失败态/定价两头输——挂 forensics 供 e2e 读)── */
const loopProof = proveCoreLoop();
const failureProof = proveFailure();
const priceProof = (() => {
const lo = estimateDayOutcome({ priceMul: ECON.priceMin });
const mid = estimateDayOutcome({ priceMul: 1.0 });
const hi = estimateDayOutcome({ priceMul: ECON.priceMax });
return { loProfit: lo.profitJiao, midProfit: mid.profitJiao, hiProfit: hi.profitJiao, twoSidedLoss: lo.profitJiao < 0 && hi.profitJiao < 0 && mid.profitJiao > 0 };
})();
mark('t_game_init', { assetsLoaded: assets.loaded, assetsTotal: assets.total, loopProofOk: loopProof.ok });
/* ── 宿主 UI 态 ── */
const ui = {
bubbleText: '',
bubbleUntil: 0,
bubbleShownAt: -9999,
keeperWaveUntil: 0,
tickerText: '小本生意,用心经营。',
banner: null, // {text,color,until,dur}
panelSlide: 1, // prep 面板滑入 0→1
settleAnim: 0, // 日结计数 0→1
/** @type {Array<{slot:number, avatar:number, emote:string, until:number}>} 离场幽灵(视觉余韵)。 */
ghosts: [],
};
/** @type {Record<number, number>} 顾客出生时刻(走位动画)。 */
const customerBornMs = {};
/* ── 度量checklist 自动子项的数据源)── */
const measures = {
frames: 0,
taps: 0,
tapFeedbackMaxFrames: 0,
comboUiShown: 0,
hitStopCount: 0,
rushPaletteSeen: false,
bgmCrossfadeSeen: false,
linesShown: 0,
bufferedServeReplays: 0,
};
/** @type {Array<object>} 最近逻辑事件环形缓冲(取证)。 */
const recentEvents = [];
function pushRecent(e) {
recentEvents.push(e);
if (recentEvents.length > 240) recentEvents.shift();
}
/* ── 台词气泡 ── */
/**
* 店主说一句场景取词普通优先级 700ms 内不顶替
* @param {string} sceneKey @param {boolean} [force] 高优先级强插
*/
function say(sceneKey, force) {
if (!force && mockNowMs - ui.bubbleShownAt < 700 && ui.bubbleUntil > mockNowMs) return;
const text = pickLine(sceneKey);
if (!text) return;
ui.bubbleText = text;
ui.bubbleUntil = mockNowMs + 2600;
ui.bubbleShownAt = mockNowMs;
ui.tickerText = text;
measures.linesShown += 1;
fx.springKick('bubble', 2.2);
}
/** 横幅。 */
function banner(text, color, durMs) {
ui.banner = { text, color, dur: durMs || 1500, until: mockNowMs + (durMs || 1500) };
}
/* ── 音频解锁(首个手势)── */
function unlockAudioOnce() {
if (audioUnlocked) return;
audioUnlocked = true;
const ac = audioFactory();
if (ac && ac.state === 'suspended' && typeof ac.resume === 'function') ac.resume().catch(() => {});
audio.startBgm();
}
/* ── 命中区视觉同源几何collision broadphase + 窄相点测)── */
/** @type {Array<any>} 当前帧命中区。 */
let zones = [];
function rebuildZones() {
zones = computeZones(core, { hasSave: hasSave() });
collision.spatial.clear();
for (const z of zones) collision.spatial.insert(z.id, { x: z.x, y: z.y, hw: z.hw, hh: z.hh });
}
/** 点测broadphase 粗筛 → aabbVsAabb 窄相 → 取 z 最高者。 */
function hitZone(x, y) {
const candidates = collision.spatial.queryPoint(x, y);
let best = null;
for (const c of candidates) {
const z = zones.find((zz) => zz.id === c.id);
if (!z) continue;
if (!aabbVsAabb({ x, y, hw: 0, hh: 0 }, { x: z.x, y: z.y, hw: z.hw, hh: z.hh }).hit) continue;
if (!best || z.z > best.z) best = z;
}
return best;
}
function hasSave() {
return !!save.get('profile');
}
/* ── 接待缓冲重放P4 输入缓冲:冷却期点击不丢,窗口内自动补打)── */
/** @type {{productId:string, tMs:number}|null} */
let pendingServe = null;
function maybeReplayBufferedServe() {
if (!pendingServe || core.state.phase !== 'open' || core.state.serveCooldown > 0) return;
const within = mockNowMs - pendingServe.tMs <= 260;
if (within && gamefeel.inputBuffer && gamefeel.inputBuffer.consume('pointerdown', mockNowMs)) {
const pid = pendingServe.productId;
pendingServe = null;
const r = core.serve(pid);
if (r.ok) measures.bufferedServeReplays += 1;
} else if (!within) {
pendingServe = null;
}
}
/* ── 点击路由 ── */
let sliderDrag = null; // {track}
/** 滑条 x → 定价。 */
function priceFromX(x, track) {
const v = Math.max(0, Math.min(1, (x - track.x) / track.w));
core.setPrice(ECON.priceMin + v * (ECON.priceMax - ECON.priceMin));
}
function onTap(e) {
measures.taps += 1;
const tapFrame = bundle.frameCount();
fx.addTapRing(e.x, e.y); // 当帧反馈≤1 渲染帧可见)
const z = hitZone(e.x, e.y);
if (!z) {
// 空点:极轻 tick仍有听觉回执不打扰。
if (audioUnlocked) audio.sfx('tick');
noteFeedback(tapFrame);
return;
}
fx.springKick('z:' + z.id, -2.6); // 按压脉冲render 侧按钮读同名弹簧 'z:<zoneId>'
switch (z.kind) {
case 'serve': {
const pid = z.payload.productId;
fx.springKick('slot-' + pid, -3.2);
const r = core.serve(pid);
if (!r.ok) {
if (r.reason === 'cooldown') {
pendingServe = { productId: pid, tMs: mockNowMs }; // 输入缓冲补打
} else if (r.reason === 'no_stock') {
audio.sfx('empty');
const rect = shelfSlotRect(core.state.unlocked.indexOf(pid));
fx.addFloater('缺货…', rect.x + rect.w / 2, rect.y, 'danger', 13);
} else if (r.reason === 'no_customer') {
audio.sfx('tick');
const rect = shelfSlotRect(core.state.unlocked.indexOf(pid));
fx.addFloater('没人要这个', rect.x + rect.w / 2, rect.y, 'inkSoft', 12);
}
}
break;
}
case 'stockInc': {
const r = core.buyStock(z.payload.productId, 1);
if (r.ok) audio.sfx('tick');
else {
audio.sfx('empty');
fx.addFloater(r.reason === 'no_cash' ? '钱不够了' : '货架满了', z.x, z.y - 22, 'danger', 13);
}
break;
}
case 'stockDec': {
const r = core.refundStock(z.payload.productId, 1);
audio.sfx(r.ok ? 'tick' : 'empty');
break;
}
case 'slider':
sliderDrag = { track: z.payload.track };
priceFromX(e.x, z.payload.track);
audio.sfx('tick');
break;
case 'upgrade': {
const r = core.buyUpgrade(z.payload.upgradeId);
if (!r.ok && r.reason === 'no_cash') {
audio.sfx('empty');
fx.addFloater('钱不够了', z.x, z.y - 24, 'danger', 13);
} else if (!r.ok && r.reason === 'requires') {
audio.sfx('empty');
fx.addFloater('要先买前置', z.x, z.y - 24, 'inkSoft', 12);
} else if (!r.ok && r.reason === 'owned') {
audio.sfx('tick');
}
break;
}
case 'btn':
audio.sfx('tick');
handleAction(z.payload.action);
break;
default:
break;
}
noteFeedback(tapFrame);
}
/** 反馈延迟度量:反馈在 tap 同帧注册,下一渲染帧可见 ⇒ 帧延迟 ≤1。 */
function noteFeedback(tapFrame) {
const delay = Math.max(bundle.frameCount() - tapFrame, 0) + 1; // +1=下一渲染帧可见的保守口径
measures.tapFeedbackMaxFrames = Math.max(measures.tapFeedbackMaxFrames, delay);
}
/** 按钮动作。 */
function handleAction(action) {
switch (action) {
case 'newGame':
core.startGame(null);
break;
case 'continue':
core.startGame(save.get('profile'));
break;
case 'openShop':
core.startDay();
break;
case 'confirmSettle':
core.confirmSettle();
break;
case 'confirmSummary':
core.confirmSummary();
break;
case 'restart':
core.startGame(null);
break;
default:
break;
}
}
/* ── 逻辑事件 → 表现(粒子/音效/台词/横幅/存档)── */
/** 槽位中心坐标。 */
function slotPos(slot) {
const s = LAYOUT.queue[slot] || LAYOUT.queue[0];
return { x: s.x, y: s.y };
}
function applyEvents(events) {
for (const e of events) {
pushRecent(e);
switch (e.type) {
case 'customer_arrive': {
customerBornMs[e.id] = mockNowMs;
audio.sfx('arrive');
break;
}
case 'serve_ok': {
const from = shelfSlotRect(core.state.unlocked.indexOf(e.productId));
const to = slotPos(e.slot);
delete customerBornMs[e.id];
// 满意幽灵(顾客拿到货的余韵)。
ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: e.rescued ? '!' : '♪', until: mockNowMs + 460 });
// 商品飞行:货架 → 顾客(非线性缓动+弧线),到达再收钱爆金光。
fx.addFlight({
fx: from.x + 28,
fy: from.y + 28,
tx: to.x,
ty: to.y,
dur: 0.28,
imageKey: PRODUCT_IMAGE_KEY[e.productId],
label: (productOf(e.productId) || { name: '?' }).name[0],
onArrive: () => {
fx.burstCoin(to.x, to.y, e.big);
fx.addFloater(`${fmtYuan(e.revenueJiao)}`, to.x, to.y - 30, 'gold', e.big ? 19 : 15);
audio.sfx('coin', { combo: e.combo });
fx.springKick('hud-cash', 1.8);
if (e.rescued) {
fx.burstSparkle(to.x, to.y - 10, 14);
audio.sfx('rescue');
}
},
});
if (e.combo >= 2) {
fx.springKick('combo', 2.4);
if (e.combo === 3) measures.comboUiShown += 1;
}
if (e.big) {
// 大额成交hit-stop + 屏震 + 闪白(脆的打击感三件套)。
juice.hitStop(0.09, 0.12);
juice.shakeScreen(0.22, 5, 26);
juice.flashScreen(0.12, 0.18);
measures.hitStopCount += 1;
audio.sfx('bigsale');
ui.keeperWaveUntil = mockNowMs + 1300;
say('big_sale', true);
} else if (e.rescued) {
say('rescue', true);
} else if (e.combo >= 3 && e.combo % 3 === 0) {
say('combo_hot');
} else if (e.combo === 1 && core.state.today.served % 4 === 1) {
say('serve_ok');
}
break;
}
case 'angry_leave': {
const p = slotPos(e.slot);
delete customerBornMs[e.id];
ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: '怒', until: mockNowMs + 520 });
fx.burstSmoke(p.x, p.y);
fx.addFloater('差评!', p.x, p.y - 36, 'danger', 15);
juice.shakeScreen(0.16, 3, 24);
audio.sfx('bad');
say('angry', true);
break;
}
case 'stockout_leave': {
const p = slotPos(e.slot);
delete customerBornMs[e.id];
ui.ghosts.push({ slot: e.slot, avatar: e.avatar, emote: '…', until: mockNowMs + 480 });
fx.burstSmoke(p.x, p.y);
fx.addFloater('白跑一趟…', p.x, p.y - 36, 'danger', 13);
audio.sfx('empty');
if (core.state.today.stockout % 3 === 1) say('stockout');
break;
}
case 'customer_grace':
audio.sfx('tick');
break;
case 'walkaway_full':
fx.addFloater('店里挤不下…', 372, 420, 'inkSoft', 12);
break;
case 'rush_start':
banner('高峰来了!', 'brick', 1600);
say('rush_start', true);
break;
case 'rush_end':
banner('缓过来了~', 'leaf', 1300);
break;
case 'closing':
banner('打烊咯~', 'wood', 1500);
break;
case 'day_start': {
say('day_start', true);
ui.keeperWaveUntil = mockNowMs + 1200;
// 进货上架弹道P2 Projectile开门瞬间把备货抛上架落架压扁+闷响。
let delay = 0;
for (let i = 0; i < core.state.unlocked.length; i++) {
const pid = core.state.unlocked[i];
const n = Math.min(core.state.stock[pid] || 0, 3);
const rect = shelfSlotRect(i);
for (let k = 0; k < n; k++) {
const d = delay;
fx.addTween(Math.max(d, 0.001), () => {}, {
onDone: () =>
fx.addToss({
fx: -24,
fy: 600,
tx: rect.x + 28,
ty: rect.y + 28,
dur: 0.42,
imageKey: PRODUCT_IMAGE_KEY[pid],
onLand: () => {
fx.springKick('slot-' + pid, -3.4);
audio.sfx('shelve');
},
}),
});
delay += 0.07;
}
}
break;
}
case 'day_end': {
ui.settleAnim = 0;
fx.addTween(1.1, (v) => {
ui.settleAnim = v;
});
say(e.settle.netJiao >= 0 ? 'settle_win' : 'settle_lose', true);
break;
}
case 'fined':
audio.sfx('bad');
break;
case 'upgrade_bought': {
fx.burstSparkle(VIEW_W / 2, 430, e.first ? 22 : 14);
fx.addFloater('升级!', VIEW_W / 2, 410, 'gold', 18);
audio.sfx('upgrade');
ui.keeperWaveUntil = mockNowMs + 1300;
say('upgrade', true);
break;
}
case 'price_changed':
break;
case 'phase_changed': {
if (e.to === 'prep') {
ui.panelSlide = 0;
fx.addTween(0.45, (v) => {
ui.panelSlide = v;
});
if (e.from === 'title' || e.from === 'gameover') say('open_shop', true);
}
ui.ghosts.length = 0;
break;
}
case 'gameover':
save.remove('profile'); // 失败清档(重开新局)
audio.sfx('bad');
say('gameover', true);
break;
case 'save_point':
save.set('profile', core.snapshotForSave());
break;
case 'segment_end':
break;
default:
break;
}
}
}
/* ── 输入接线DOM → 归一化 → 受控桥;游戏经受控面订阅 ── */
const inputBridge = bundle.inputBridge;
function toCanvasXY(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
return {
x: ((clientX - rect.left) / rect.width) * VIEW_W,
y: ((clientY - rect.top) / rect.height) * VIEW_H,
};
}
canvas.addEventListener('pointerdown', (ev) => {
unlockAudioOnce();
const p = toCanvasXY(ev.clientX, ev.clientY);
inputBridge._emit('pointerdown', p);
});
canvas.addEventListener('pointermove', (ev) => {
const p = toCanvasXY(ev.clientX, ev.clientY);
inputBridge._emit('pointermove', p);
});
canvas.addEventListener('pointerup', (ev) => {
const p = toCanvasXY(ev.clientX, ev.clientY);
inputBridge._emit('pointerup', p);
});
// 游戏侧:经受控输入面订阅(与插件同一面)。
const input = bundle.context.getInput();
input.on('pointerdown', (e) => onTap(e));
input.on('pointermove', (e) => {
if (sliderDrag) priceFromX(e.x, sliderDrag.track);
});
input.on('pointerup', () => {
sliderDrag = null;
});
/* ── 闲时台词节拍器 ── */
let idleChatTimer = 7;
/* ── 主帧 ── */
function frame(dtReal) {
mockNowMs += dtReal * 1000;
const ts = juice.getTimeScale();
const dtWorld = dtReal * ts; // hit-stop 冻结世界(手感锚点)
core.update(dtWorld);
bundle.tick(dtReal); // 驱动插件 onFrame粒子 autoStep 等,按 PLUGIN.md 约定吃真实 dt
applyEvents(core.drainEvents());
maybeReplayBufferedServe();
fx.step(dtReal, dtWorld);
// 高峰 → 色板/暗角/BGM 三联动。
const rush = core.state.phase === 'open' ? core.state.rushFactor : 0;
pal.setRush(rush);
if (rush > 0.9) measures.rushPaletteSeen = true;
palettePost.setPost({ vignette: { enabled: true, strength: 0.22 + rush * 0.24 } });
audio.step(dtReal, rush, core.state.phase === 'open');
const ap = audio.probe();
if (ap.bgm.ready && ap.bgm.idleLevel > 0.02 && ap.bgm.rushLevel > 0.02) measures.bgmCrossfadeSeen = true;
// 闲时随口台词(营业中、不忙的时候偶尔来一句)。
if (core.state.phase === 'open') {
idleChatTimer -= dtReal;
if (idleChatTimer <= 0) {
idleChatTimer = 8 + (rush > 0.5 ? 4 : 0);
if (ui.bubbleUntil < mockNowMs) say(rush > 0.5 ? 'rush_start' : 'idle_chat');
}
}
// 幽灵过期清理。
for (let i = ui.ghosts.length - 1; i >= 0; i--) if (ui.ghosts[i].until <= mockNowMs) ui.ghosts.splice(i, 1);
rebuildZones();
drawFrame(g, {
core,
pal,
assets,
fx,
ui,
nowMs: mockNowMs,
customerBornMs,
hasSave: hasSave(),
});
palettePost.renderPost(g); // 暗角收口(绘制末尾叠加)
measures.frames += 1;
refreshForensics();
}
/** 确定性推进 n 帧evidence 驱动 / 启动首帧)。 */
function stepFrames(n) {
for (let i = 0; i < n; i++) frame(FIXED_DT);
}
/* ── RAFplay 模式)── */
let rafId = 0;
let rafRunning = false;
let lastTs = 0;
function rafLoop(ts) {
if (!rafRunning) return;
if (lastTs === 0) lastTs = ts;
let dt = (ts - lastTs) / 1000;
lastTs = ts;
if (dt > 0.05) dt = 0.05; // 钳大跳帧
frame(dt);
rafId = window.requestAnimationFrame(rafLoop);
}
function startRaf() {
if (rafRunning) return;
rafRunning = true;
lastTs = 0;
rafId = window.requestAnimationFrame(rafLoop);
}
function stopRaf() {
rafRunning = false;
if (rafId) window.cancelAnimationFrame(rafId);
rafId = 0;
}
/* ── 取证导出 ── */
const forensics = {
protocol: CORE_PROTOCOL_VERSION,
seed,
mode,
/** 可交互几何(当前帧;与视觉同源)。 */
geometry: () => zones.map((z) => ({ id: z.id, kind: z.kind, x: z.x, y: z.y, hw: z.hw, hh: z.hh, z: z.z })),
/** 探针六锚链导出。 */
probe: () => ({
jsonl: probe.toJSONL(),
verify: probe.verify(),
anchors: probe.getRecords().map((r) => r.anchor),
uncaught: uncaught.slice(),
initOk: initRes.ok,
registeredPlugins: pluginSet,
}),
/** 闭环/失败态/定价证明boot 期纯逻辑跑出)。 */
loopProof,
failureProof,
priceProof,
/** 游戏状态快照JSON 安全)。 */
state: () => JSON.parse(JSON.stringify({
phase: core.state.phase,
day: core.state.day,
cash: core.state.cash,
reputation: core.reputation(),
priceMul: core.state.priceMul,
stock: core.state.stock,
unlocked: core.state.unlocked,
upgrades: core.state.upgrades,
customers: core.state.customers.length,
combo: core.state.comboCount,
today: core.state.today,
lifetime: core.state.lifetime,
gameoverReason: core.state.gameoverReason,
})),
/** 最近逻辑事件(环形 240 条)。 */
recentEvents: () => recentEvents.slice(),
/** 实时度量。 */
measures: () => Object.assign({ particle: fx.particleProbe(), audio: audio.probe(), assetsMissing: assets.missing }, measures),
};
/** checklistLOOP-LOG 五要素表自动子项 → 布尔+数值,逐帧刷新)。 */
const checklist = {};
function refreshForensics() {
const ap = audio.probe();
checklist.shougan = {
tapFeedbackMaxFrames: measures.tapFeedbackMaxFrames, // 目标 ≤3
tapFeedbackWithin3: measures.taps === 0 || measures.tapFeedbackMaxFrames <= 3,
everyTapScalePulse: true, // 构造保证onTap 必加涟漪+弹簧(见 onTap
comboUiShown: measures.comboUiShown > 0 || core.state.today.maxCombo >= 2,
flightEasingUsed: true, // serve_ok 飞行走 easing.quadOut+弧线(构造保证)
hitStopTriggered: measures.hitStopCount > 0,
bufferedServeReplays: measures.bufferedServeReplays,
};
checklist.meishu = {
allDrawsViaPalette: true, // 构造保证render.js 一切取色经 pal.c()
vignetteEnabled: palettePost.getPost().vignette.enabled,
rushPaletteSeen: measures.rushPaletteSeen,
assetsLoaded: `${assets.loaded}/${assets.total}`,
noPrimaryPlaceholderRects: true, // 构造保证回退块也走调色amber/sky 族)
};
checklist.yinyue = {
audioUnlocked,
bgmReady: ap.bgm.ready,
bgmCrossfadeSeen: measures.bgmCrossfadeSeen,
sfxKindCount: ap.sfxKinds.length, // 目标 ≥5
sfxKinds: ap.sfxKinds,
};
checklist.shendu = {
loopReachable: loopProof.ok,
loopOps: loopProof.opCount,
failureReachable: failureProof.ok,
failureReason: failureProof.reason,
priceTwoSidedLoss: priceProof.twoSidedLoss,
upgradeTreeSize: 8,
firstUpgradeDay: loopProof.day,
};
checklist.juese = {
keeperSpriteResident: true, // 构造保证drawKeeper 每帧绘制
lineGroups: Object.keys(LINES).length,
lineTotal: Object.values(LINES).reduce((s, a) => s + a.length, 0),
linesShownLive: measures.linesShown,
};
}
refreshForensics();
window.__refForensics = forensics;
window.__refChecklist = checklist;
/* ── 首帧 + 输入锚 + 启动 ── */
stepFrames(1);
mark('t_first_paint', { frame: bundle.frameCount() });
try {
probe.init(probeCtx); // 绑输入 → t_input_bound恰在 first_paint 之后,偏序成立)
} catch (e) {
uncaught.push({ type: 'probe-init', message: String((e && e.message) || e) });
}
mark('t_game_start', { mode });
const refHost = {
seed,
mode,
protocol: CORE_PROTOCOL_VERSION,
frameCount: () => bundle.frameCount(),
stepFrames,
startRaf,
stopRaf,
/** 注入一次点击evidence 驱动;与真输入同走受控桥)。 */
tap(x, y) {
unlockAudioOnce();
inputBridge._emit('pointerdown', { x, y });
inputBridge._emit('pointerup', { x, y });
},
/** 直接命令e2e 辅助;与 UI 同走逻辑公开 API。 */
do(action) {
handleAction(action);
},
state: forensics.state,
forensics,
};
window.__refHost = refHost;
if (mode === 'evidence') {
// evidence不起 RAF画面完全由 __refHost.stepFrames/tap 接管(同参数重跑一致)。
setStatus(`evidence 就绪 seed=${seed} · 用 window.__refHost 驱动`);
} else {
setStatus(`seed=${seed} · 点「开店!」开始(首次点击开声)`);
startRaf();
}
return refHost;
}

View File

@ -0,0 +1,106 @@
/**
* palette.js 全局暖调色板 + P5 调色引擎接线宿主层色板数据=游戏生成域引擎=P5 插件
* ownerwanglanmei-ref 1 消费方render.js一切绘制取色必经本模块/ main.jsvignette 强度
*
* 美术统一性之根
* 1. 全部 mmx 资产共用同一组风格词 assets/manifest.json styleWords
* 2. 全部程序化绘制UI/粒子/文字只准经本模块取色色名表唯一杜绝原色矩形
* 3. 高峰压力态 = 同一色名表经 P5 lerpPaletteMap 平静压力两份目标色板间过渡
* 压力色板由 shiftHsl 从基色程序化派生色相偏暖红饱和升明度降
* vignette 收紧main setPost画面情绪随玩法起伏而非换贴图
*
* P5 红线遵守P5 只供引擎映射/插值/HSL成品色板下面的 BASE是本游戏的数据
*/
'use strict';
import { lerpPaletteMap, applyPaletteMap, shiftHsl } from '../../../src/plugins/palette-post/impl.js';
/**
* 基础色名表唯一事实源一切绘制色都从这里出warm 市井暖调家族
* @type {Record<string, {r:number,g:number,b:number}>}
*/
export const BASE = {
paper: { r: 246, g: 231, b: 201 }, // 米纸底
paperHi: { r: 255, g: 246, b: 227 }, // 高亮纸
cream: { r: 255, g: 243, b: 220 }, // 奶油(粒子亮色)
ink: { r: 91, g: 70, b: 54 }, // 主墨色(文字/描边)
inkSoft: { r: 138, g: 108, b: 84 }, // 次级墨色
wood: { r: 169, g: 118, b: 79 }, // 木质(柜台/货架)
woodDark: { r: 124, g: 84, b: 55 }, // 深木
gold: { r: 232, g: 169, b: 66 }, // 金(钱/强调)
coin: { r: 246, g: 196, b: 83 }, // 金币粒子
amber: { r: 240, g: 196, b: 117 }, // 琥珀(进度条)
brick: { r: 201, g: 111, b: 74 }, // 砖橙(警示暖红)
danger: { r: 178, g: 72, b: 54 }, // 差评红(仍在暖族)
leaf: { r: 133, g: 158, b: 94 }, // 暖叶绿(成功/盈利)
sky: { r: 243, g: 217, b: 168 }, // 暖天光
shadow: { r: 62, g: 49, b: 40 }, // 阴影
scrim: { r: 46, g: 35, b: 27 }, // 蒙层
smoke: { r: 140, g: 106, b: 85 }, // 烟尘(差评粒子,暖棕)
};
/** 色名列表(固定顺序,构造映射表用)。 */
const NAMES = Object.keys(BASE);
/**
* 创建调色器维护平静压力过渡态输出 css 颜色字符串带量化缓存避免逐帧逐色重算
* @returns {{ setRush:(t:number)=>void, rushT:()=>number, c:(name:string, alpha?:number)=>string, rgb:(name:string)=>{r:number,g:number,b:number} }}
*/
export function createPalette() {
const from = NAMES.map((n) => BASE[n]);
// 平静色板恒等映射from→from
const calmMap = { from, to: from.slice() };
// 压力色板:每个基色经 HSL 偏移派生(暖红化 + 微降明度 + 升饱和——P5 引擎做派生,数据归游戏。
const rushMap = {
from,
to: from.map((c) => shiftHsl(c, { hue: -10, sat: 1.18, light: 0.9 })),
};
/** 当前压力系数(量化到 1/25 步,缓存键)。 */
let quantT = 0;
/** @type {Record<string, {r:number,g:number,b:number}>} 当前色名→RGB 解析缓存。 */
let resolved = buildResolved(0);
/** 重建解析表。@param {number} t */
function buildResolved(t) {
const map = lerpPaletteMap(calmMap, rushMap, t);
/** @type {Record<string, {r:number,g:number,b:number}>} */
const out = {};
for (const n of NAMES) out[n] = applyPaletteMap(map, BASE[n]);
return out;
}
return {
/**
* 设定高峰压力系数0=平静 1=高峰量化到 0.04 步进变化才重建缓存
* @param {number} t
*/
setRush(t) {
const q = Math.round(Math.max(0, Math.min(1, t)) * 25) / 25;
if (q !== quantT) {
quantT = q;
resolved = buildResolved(q);
}
},
/** 当前压力系数。 */
rushT() {
return quantT;
},
/**
* 取色css 字符串一切绘制必经此函数美术统一性 checklist 的实现锚点
* @param {string} name 色名BASE
* @param {number} [alpha] 透明度缺省 1
* @returns {string}
*/
c(name, alpha) {
const v = resolved[name] || BASE.ink;
const a = alpha == null ? 1 : alpha;
return a >= 1 ? `rgb(${v.r},${v.g},${v.b})` : `rgba(${v.r},${v.g},${v.b},${a})`;
},
/** 取当前 RGB 对象(粒子层 fillStyle 等需要原始分量时用)。@param {string} name */
rgb(name) {
return resolved[name] || BASE.ink;
},
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,673 @@
/**
* shop-core.js 小卖部经营 · 营业日状态机纯逻辑 DOM时间/随机全走注入受控源
* ownerwanglanmei-ref 1 消费方main.js宿主接线/ loop-proof.js / test
*
* 纯逻辑铁律红线 2
* - 本模块不触 DOM / canvas / AudioContext / Date.now / performance.now / Math.random
* - 时间 = 调用方逐帧喂 update(dtSeconds)内部积累 simClockMs受控仿真时钟
* - 随机 = 注入的 RandomSourcecore-protocol-v0 形状next/range/reseed
* - 连击 = gamefeel 插件公开 API ComboWindow公开 API 喂受控 TimeSource
*
* 状态机
* title prep open settle (prep | summary prep | gameover)
* - prep进货资金占用 vs 缺货流失+ 定价高价利润客流+ 买升级 二阶决策都在这里
* - open顾客到店排队限时耐心 点击货架商品接待 收钱连击小费
* 耐心耗尽愤怒离店掉声誉 0.55s 救单宽限缺货失望离店掉声誉
* - settle房租扣除声誉 < 30 罚没声誉 0 或资金 < 0 失败终态真实可达
*
* 与宿主的接口
* 命令式 APIserve/buyStock/...全部返回 {ok, reason?}+ 事件队列 drainEvents()
* 宿主据事件驱动粒子/音效/台词/存档渲染层只读 state直接引用单线程安全
*/
'use strict';
import { ComboWindow } from '../../../src/plugins/gamefeel/impl.js';
import {
PRODUCTS,
productOf,
DAY,
rushFactorAt,
FLOW,
flowMulOfPrice,
flowMulOfReputation,
PATIENCE,
REP,
ECON,
priceJiaoOf,
comboTipMul,
UPGRADES,
upgradeOf,
derivedOfUpgrades,
clamp,
} from './balance.js';
/** 存档结构版本号restoreFromSave 校验,不认旧版即拒绝并回退新开局)。 */
export const SAVE_VERSION = 1;
/** 连击窗口(毫秒):相邻两次接待 ≤2600ms 算连击。 */
const COMBO_WINDOW_MS = 2600;
/**
* 创建一局小卖部经营状态机
* @param {Object} deps
* @param {{ next:()=>number, range:(a:number,b:number)=>number, reseed:(s:number)=>void }} deps.random
* 受控随机源core-protocol-v0 RandomSource 形状宿主传派生流测试传 SeededRandom
* @returns {ShopCore}
*/
export function createShopCore(deps) {
const rng = deps.random;
if (!rng || typeof rng.next !== 'function') {
throw new Error('[shop-core] 必须注入受控随机源 randomnext/range/reseed');
}
/* ── 受控仿真时钟update 累计ComboWindow 经 TimeSource 视图消费同一时钟 ── */
let simClockMs = 0;
/** @type {{ nowMs:()=>number, elapsedMs:()=>number }} 受控 TimeSource 视图(喂给 gamefeel。 */
const timeSource = {
nowMs: () => simClockMs,
elapsedMs: () => simClockMs,
};
// 连击计时窗gamefeel 公开 API封顶 99。
const combo = new ComboWindow(timeSource, { windowMs: COMBO_WINDOW_MS, maxCount: 99 });
/* ── 事件队列(宿主逐帧 drain── */
/** @type {Array<{type:string}&Record<string,any>>} */
let events = [];
/** 入队一条事件(附仿真时刻,便于取证)。@param {string} type @param {Object} [payload] */
function emit(type, payload) {
events.push(Object.assign({ type, tMs: Math.round(simClockMs) }, payload || {}));
}
/* ── 顶层状态(渲染层只读引用)── */
const state = {
phase: 'title', // 'title'|'prep'|'open'|'settle'|'summary'|'gameover'
day: 1, // 当前营业日1 起)
cash: ECON.startCashJiao, // 资金(角)
repTenth: REP.start * 10, // 声誉 ×10 整数(避免浮点漂移;展示 ÷10
priceMul: 1.0, // 定价乘数
/** @type {Record<string, number>} 各商品库存(件)。 */
stock: {},
/** @type {string[]} 已解锁商品 id顺序即货架顺序。 */
unlocked: PRODUCTS.filter((p) => p.initial).map((p) => p.id),
/** @type {Record<string, boolean>} 已购升级集合。 */
upgrades: {},
/** @type {Customer[]} 在场顾客。 */
customers: [],
dayTimeSec: 0, // 营业日内时刻(秒)
rushFactor: 0, // 高峰系数 0..1(色板/BGM 挂点)
serveCooldown: 0, // 收银冷却(秒)
comboCount: 0, // 当前连击(渲染缓存,每帧从 ComboWindow 读)
/** @type {Record<string, number>} 本次 prep 已购件数(可退)。 */
prepBought: {},
/** 当日统计settle 报表与罚款依据)。 */
today: freshToday(),
/** 生涯统计。 */
lifetime: { days: 0, served: 0, angry: 0, netJiao: 0, upgradesBought: 0 },
/** @type {object|null} 日结报表phase=settle 时有效)。 */
settle: null,
/** @type {object|null} 段落小结phase=summary 时有效)。 */
summary: null,
/** @type {string|null} 失败原因:'reputation'|'bankrupt'。 */
gameoverReason: null,
};
/** @returns {object} 全新当日统计。 */
function freshToday() {
return { revenueJiao: 0, soldCostJiao: 0, served: 0, angry: 0, stockout: 0, walkaway: 0, rescued: 0, bigSales: 0, maxCombo: 0 };
}
/*
* @typedef {Object} Customer
* @property {number} id 自增 id
* @property {string} productId 想要的商品
* @property {number} slot 队列槽位0..queueSlots-1
* @property {'queue'|'leaving'|'stockout'} mood
* queue=正常排队leaving=耐心耗尽进入救单宽限stockout=发现缺货停留片刻后离店
* @property {number} patienceSec 剩余耐心
* @property {number} patienceMaxSec 总耐心
* @property {number} timerSec mood 内倒计时leaving 宽限 / stockout 停留
* @property {number} avatar 头像编号0..3渲染用
* @property {boolean} impatientFired 是否已发过不耐烦事件
*/
let customerIdSeq = 1;
/** 下一位顾客的到店倒计时(秒)。 */
let spawnTimer = 0;
/** 上一帧的高峰布尔rush_start/rush_end 边沿触发用)。 */
let wasRush = false;
/** 升级派生参数缓存(升级变化时重算)。 */
let derived = derivedOfUpgrades(state.upgrades);
function refreshDerived() {
derived = derivedOfUpgrades(state.upgrades);
}
/** 当前库存总件数。 */
function totalStock() {
let n = 0;
for (const id of Object.keys(state.stock)) n += state.stock[id];
return n;
}
/** 声誉读数0..1001 位小数语义)。 */
function reputation() {
return state.repTenth / 10;
}
/** 调声誉×10 整数,钳制 [0, max])。@param {number} deltaTenth */
function bumpRep(deltaTenth) {
state.repTenth = clamp(state.repTenth + deltaTenth, 0, REP.max * 10);
}
/*
* 阶段切换
* */
/** 切阶段并广播事件。@param {string} to */
function setPhase(to) {
const from = state.phase;
state.phase = to;
emit('phase_changed', { from, to });
}
/** 进入 prep开市准备。@param {number} day */
function enterPrep(day) {
state.day = day;
state.prepBought = {};
state.today = freshToday();
state.customers = [];
state.dayTimeSec = 0;
state.rushFactor = 0;
state.settle = null;
state.summary = null;
setPhase('prep');
// 存档点:每个 prep 开头(进度=「第 day 天开市前」)。
emit('save_point', {});
}
/*
* open 阶段到店 / 耐心 / 接待
* */
/** 重置到店倒计时(按当前定价/声誉/装修/时段计算 + 抖动)。 */
function rollSpawnTimer() {
const base = state.rushFactor > 0.5 ? FLOW.rushIntervalSec : FLOW.idleIntervalSec;
const mul = flowMulOfPrice(state.priceMul) * flowMulOfReputation(reputation()) * derived.flowMul;
spawnTimer = (base / Math.max(mul, 0.05)) * rng.range(FLOW.jitterMin, FLOW.jitterMax);
}
/** 顾客点单:按 popularity 权重在已解锁商品中抽样(不看库存——顾客进店前不知道)。 */
function pickProduct() {
const defs = state.unlocked.map((id) => productOf(id));
const wSum = defs.reduce((s, p) => s + p.popularity, 0);
let roll = rng.next() * wSum;
for (const p of defs) {
roll -= p.popularity;
if (roll <= 0) return p.id;
}
return defs[defs.length - 1].id;
}
/** 找一个空队列槽0..n-1 的最小空位;无空位返回 -1。 */
function freeSlot() {
for (let s = 0; s < FLOW.queueSlots; s++) {
if (!state.customers.some((c) => c.slot === s)) return s;
}
return -1;
}
/** 生成一位顾客(队满→流失事件)。 */
function spawnCustomer() {
const productId = pickProduct();
const slot = freeSlot();
if (slot < 0) {
// 队满进不来:只记流失,不掉声誉(高峰压力的温和面)。
state.today.walkaway += 1;
emit('walkaway_full', { productId });
return;
}
const baseSec = state.rushFactor > 0.5 ? PATIENCE.rushSec : PATIENCE.idleSec;
const patienceMaxSec = baseSec * derived.patienceMul * rng.range(0.9, 1.12);
/** @type {Customer} */
const c = {
id: customerIdSeq++,
productId,
slot,
mood: 'queue',
patienceSec: patienceMaxSec,
patienceMaxSec,
timerSec: 0,
avatar: Math.floor(rng.next() * 4) % 4,
impatientFired: false,
};
// 进店即发现缺货 → 看一眼就失望(缺货流失的「快反馈」路径)。
if ((state.stock[productId] || 0) <= 0) {
c.mood = 'stockout';
c.timerSec = PATIENCE.stockoutLingerSec;
}
state.customers.push(c);
emit('customer_arrive', { id: c.id, slot, productId, mood: c.mood });
}
/** 移除顾客。@param {Customer} c */
function removeCustomer(c) {
const i = state.customers.indexOf(c);
if (i >= 0) state.customers.splice(i, 1);
}
/** 推进 open 阶段一帧。@param {number} dt 秒 */
function updateOpen(dt) {
state.dayTimeSec += dt;
state.rushFactor = rushFactorAt(state.dayTimeSec);
// 高峰进出边沿事件BGM crossfade / 压力色板挂点)。
const isRush = state.rushFactor > 0.5;
if (isRush !== wasRush) {
emit(isRush ? 'rush_start' : 'rush_end', {});
wasRush = isRush;
}
// 收银冷却。
if (state.serveCooldown > 0) state.serveCooldown = Math.max(0, state.serveCooldown - dt);
// 打烊:到时不再进客,场内顾客温和散场(不掉声誉),转日结。
if (state.dayTimeSec >= DAY.lengthSec) {
emit('closing', {});
state.customers = [];
doSettle();
return;
}
// 到店节拍。
spawnTimer -= dt;
if (spawnTimer <= 0) {
spawnCustomer();
rollSpawnTimer();
}
// 顾客耐心 / 状态推进(倒序遍历,便于移除)。
for (let i = state.customers.length - 1; i >= 0; i--) {
const c = state.customers[i];
if (c.mood === 'queue') {
// 排队中商品被买空 → 转缺货停留(看了一眼货架)。
if ((state.stock[c.productId] || 0) <= 0) {
c.mood = 'stockout';
c.timerSec = PATIENCE.stockoutLingerSec;
continue;
}
c.patienceSec -= dt;
// 「不耐烦」一次性事件(气泡/滴答音效挂点)。
if (!c.impatientFired && c.patienceSec / c.patienceMaxSec <= PATIENCE.impatientRatio) {
c.impatientFired = true;
emit('customer_impatient', { id: c.id, slot: c.slot });
}
// 耐心耗尽 → 救单宽限coyote 语义:失效后仍留一小窗可接住)。
if (c.patienceSec <= 0) {
c.mood = 'leaving';
c.timerSec = PATIENCE.graceSec;
emit('customer_grace', { id: c.id, slot: c.slot });
}
} else if (c.mood === 'leaving') {
c.timerSec -= dt;
if (c.timerSec <= 0) {
// 愤怒离店:掉声誉(失败态的日常来源)。
removeCustomer(c);
state.today.angry += 1;
bumpRep(-REP.angryLossTenth);
emit('angry_leave', { id: c.id, slot: c.slot, avatar: c.avatar, productId: c.productId, repLossTenth: REP.angryLossTenth });
}
} else if (c.mood === 'stockout') {
c.timerSec -= dt;
if (c.timerSec <= 0) {
// 缺货失望离店:掉声誉(少于愤怒,但备货不足会持续放血)。
removeCustomer(c);
state.today.stockout += 1;
bumpRep(-REP.stockoutLossTenth);
emit('stockout_leave', { id: c.id, slot: c.slot, avatar: c.avatar, productId: c.productId, repLossTenth: REP.stockoutLossTenth });
}
}
}
// 连击展示缓存(断连归 0 由 ComboWindow 判定)。
state.comboCount = combo.getCount(simClockMs);
}
/*
* 日结 / 段落 / 失败
* */
/** 执行日结:算账(房租/罚款)→ 声誉回暖 → 失败判定 → settle 报表。 */
function doSettle() {
const t = state.today;
const segment = Math.ceil(state.day / DAY.daysPerSegment);
const rentJiao = ECON.rentBaseJiao + (segment - 1) * ECON.rentPerSegmentJiao;
// 罚没:声誉 < 阈值 → 罚款 = 基数 + 每差评加罚(差评赔偿)。
let fineJiao = 0;
if (reputation() < REP.fineThreshold) {
fineJiao = ECON.fineBaseJiao + t.angry * ECON.finePerAngryJiao;
emit('fined', { fineJiao });
}
// 声誉日结回暖:全天零差评 +1.5;装修加成 +3/+6。
let repBackTenth = derived.settleRepBonusTenth;
if (t.angry === 0) repBackTenth += REP.noAngryBonusTenth;
bumpRep(repBackTenth);
state.cash -= rentJiao + fineJiao;
const netJiao = t.revenueJiao - t.soldCostJiao - rentJiao - fineJiao;
state.lifetime.days += 1;
state.lifetime.netJiao += netJiao;
state.settle = {
day: state.day,
revenueJiao: t.revenueJiao,
soldCostJiao: t.soldCostJiao,
rentJiao,
fineJiao,
netJiao,
served: t.served,
angry: t.angry,
stockout: t.stockout,
walkaway: t.walkaway,
rescued: t.rescued,
maxCombo: t.maxCombo,
repBackTenth,
reputation: reputation(),
};
emit('day_end', { settle: state.settle });
// 失败终态判定(真实可达,非只胜不败)。
if (reputation() <= 0) {
state.gameoverReason = 'reputation';
setPhase('gameover');
emit('gameover', { reason: 'reputation' });
return;
}
if (state.cash < 0) {
state.gameoverReason = 'bankrupt';
setPhase('gameover');
emit('gameover', { reason: 'bankrupt' });
return;
}
setPhase('settle');
}
/*
* 公开命令 API全部 {ok, reason?}
* */
const core = {
/** 渲染层只读状态(直接引用;单线程逐帧消费,不做防御拷贝换性能)。 */
state,
/** 受控 TimeSource 视图(宿主可复用,如喂浮动文字时间轴)。 */
timeSource,
/**
* 推进仿真一帧宿主喂已应用 hit-stop 缩放的世界 dt
* @param {number} dtSeconds 帧时间步0
*/
update(dtSeconds) {
const dt = Math.max(0, Math.min(dtSeconds, 0.1)); // 钳制大跳帧,防卡顿后逻辑雪崩
simClockMs += dt * 1000;
if (state.phase === 'open') updateOpen(dt);
},
/** 取走积压事件(宿主逐帧调;返回后内部清空)。 */
drainEvents() {
const out = events;
events = [];
return out;
},
/**
* 开始新游戏title/gameover prep 1
* @param {object|null} [save] 传入合法存档则续档恢复到存档日的 prep
*/
startGame(save) {
if (save && core.restoreFromSave(save)) return { ok: true, continued: true };
// 全新开局:恢复默认。
state.cash = ECON.startCashJiao;
state.repTenth = REP.start * 10;
state.priceMul = 1.0;
state.stock = {};
state.unlocked = PRODUCTS.filter((p) => p.initial).map((p) => p.id);
state.upgrades = {};
state.lifetime = { days: 0, served: 0, angry: 0, netJiao: 0, upgradesBought: 0 };
state.gameoverReason = null;
refreshDerived();
combo.reset();
emit('game_start', { continued: false });
enterPrep(1);
return { ok: true, continued: false };
},
/**
* 进货prep 限定资金占用是真实代价买多压资金买少缺货流失
* @param {string} productId @param {number} [n] 件数缺省 1
*/
buyStock(productId, n) {
const count = Math.max(1, Math.floor(n || 1));
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
const p = productOf(productId);
if (!p || !state.unlocked.includes(productId)) return { ok: false, reason: 'unknown_product' };
if (totalStock() + count > derived.shelfCap) return { ok: false, reason: 'shelf_full' };
const costJiao = p.wholesale * count;
if (state.cash < costJiao) return { ok: false, reason: 'no_cash' };
state.cash -= costJiao;
state.stock[productId] = (state.stock[productId] || 0) + count;
state.prepBought[productId] = (state.prepBought[productId] || 0) + count;
emit('stock_bought', { productId, n: count, costJiao });
return { ok: true, costJiao };
},
/**
* 退货仅限本次 prep 买的原价退允许悔棋让备货决策可探索
* @param {string} productId @param {number} [n]
*/
refundStock(productId, n) {
const count = Math.max(1, Math.floor(n || 1));
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
const bought = state.prepBought[productId] || 0;
if (bought < count || (state.stock[productId] || 0) < count) return { ok: false, reason: 'not_bought_now' };
const p = productOf(productId);
state.cash += p.wholesale * count;
state.stock[productId] -= count;
state.prepBought[productId] = bought - count;
emit('stock_refunded', { productId, n: count });
return { ok: true };
},
/**
* 定价prep 限定钳制 [0.8,1.5] 并吸附 0.05 步进
* @param {number} mul
*/
setPrice(mul) {
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
const snapped = clamp(Math.round(mul / ECON.priceStep) * ECON.priceStep, ECON.priceMin, ECON.priceMax);
state.priceMul = Math.round(snapped * 100) / 100;
emit('price_changed', { priceMul: state.priceMul });
return { ok: true, priceMul: state.priceMul };
},
/**
* 购买升级prep 限定校验前置与资金
* @param {string} upgradeId
*/
buyUpgrade(upgradeId) {
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
const u = upgradeOf(upgradeId);
if (!u) return { ok: false, reason: 'unknown_upgrade' };
if (state.upgrades[upgradeId]) return { ok: false, reason: 'owned' };
if (u.requires && !state.upgrades[u.requires]) return { ok: false, reason: 'requires' };
if (state.cash < u.costJiao) return { ok: false, reason: 'no_cash' };
state.cash -= u.costJiao;
state.upgrades[upgradeId] = true;
state.lifetime.upgradesBought += 1;
if (u.unlocksProduct && !state.unlocked.includes(u.unlocksProduct)) {
state.unlocked.push(u.unlocksProduct);
}
refreshDerived();
emit('upgrade_bought', { id: upgradeId, costJiao: u.costJiao, first: state.lifetime.upgradesBought === 1 });
emit('save_point', {});
return { ok: true };
},
/** 开门营业prep → open。 */
startDay() {
if (state.phase !== 'prep') return { ok: false, reason: 'phase' };
state.dayTimeSec = 0;
state.rushFactor = 0;
wasRush = false;
state.serveCooldown = 0;
combo.reset();
state.comboCount = 0;
setPhase('open');
emit('day_start', { day: state.day });
rollSpawnTimer();
return { ok: true };
},
/**
* 接待把货架上的 productId 递给最早到的想要它的顾客
* 命中救单宽限mood='leaving'也算成功并奖励声誉惊险救单
* @param {string} productId
* @returns {{ok:boolean, reason?:string, revenueJiao?:number, combo?:number, big?:boolean, rescued?:boolean, customerId?:number}}
*/
serve(productId) {
if (state.phase !== 'open') return { ok: false, reason: 'phase' };
if (state.serveCooldown > 0) return { ok: false, reason: 'cooldown' };
if (!state.unlocked.includes(productId)) return { ok: false, reason: 'unknown_product' };
if ((state.stock[productId] || 0) <= 0) {
emit('tap_empty', { productId });
return { ok: false, reason: 'no_stock' };
}
// 取最早到店id 最小)的、想要该商品的、可被接待的顾客。
const target = state.customers
.filter((c) => c.productId === productId && (c.mood === 'queue' || c.mood === 'leaving'))
.sort((a, b) => a.id - b.id)[0];
if (!target) {
emit('tap_nocustomer', { productId });
return { ok: false, reason: 'no_customer' };
}
const rescued = target.mood === 'leaving';
// 出货 + 收钱(连击小费)。
state.stock[productId] -= 1;
const n = combo.hit(simClockMs);
state.comboCount = n;
state.today.maxCombo = Math.max(state.today.maxCombo, n);
const revenueJiao = Math.round(priceJiaoOf(productId, state.priceMul) * comboTipMul(n));
state.cash += revenueJiao;
state.today.revenueJiao += revenueJiao;
state.today.soldCostJiao += productOf(productId).wholesale;
state.today.served += 1;
state.lifetime.served += 1;
if (rescued) state.today.rescued += 1;
// 声誉:成功接待小幅回血,救单额外奖励。
bumpRep(REP.serveGainTenth + (rescued ? REP.rescueBonusTenth : 0));
// 收银冷却(升级可缩短)。
state.serveCooldown = derived.serveCooldownSec;
const big = revenueJiao >= ECON.bigSaleJiao;
if (big) state.today.bigSales += 1;
removeCustomer(target);
emit('serve_ok', {
id: target.id,
slot: target.slot,
avatar: target.avatar,
productId,
revenueJiao,
combo: n,
big,
rescued,
});
return { ok: true, revenueJiao, combo: n, big, rescued, customerId: target.id };
},
/** 日结确认settle → prep 下一天;逢段落末 → summary。 */
confirmSettle() {
if (state.phase !== 'settle') return { ok: false, reason: 'phase' };
const finished = state.day;
emit('save_point', {});
if (finished % DAY.daysPerSegment === 0) {
const segment = finished / DAY.daysPerSegment;
state.summary = {
segment,
days: finished,
netJiao: state.lifetime.netJiao,
served: state.lifetime.served,
upgradesBought: state.lifetime.upgradesBought,
reputation: reputation(),
};
setPhase('summary');
emit('segment_end', { summary: state.summary });
} else {
enterPrep(finished + 1);
}
return { ok: true };
},
/** 段落小结确认summary → prep 下一天)。 */
confirmSummary() {
if (state.phase !== 'summary') return { ok: false, reason: 'phase' };
enterPrep(state.day + 1);
return { ok: true };
},
/** 当前升级派生参数(渲染层展示货架容量等)。 */
derived() {
return derived;
},
/** 当前声誉0..100,浮点一位)。 */
reputation,
/** 当前库存总件数(货架容量展示)。 */
totalStock,
/* ── 存档P9 由宿主接线;逻辑层只产/收纯数据)── */
/** 导出存档快照(仅 prep 间持久字段;当日过程态不存)。 */
snapshotForSave() {
return {
v: SAVE_VERSION,
day: state.day,
cash: state.cash,
repTenth: state.repTenth,
priceMul: state.priceMul,
stock: Object.assign({}, state.stock),
unlocked: state.unlocked.slice(),
upgrades: Object.assign({}, state.upgrades),
lifetime: Object.assign({}, state.lifetime),
};
},
/**
* 从存档恢复恢复到存档日的 prep结构非法/版本不符 返回 false调用方回退新开局
* @param {object} save
* @returns {boolean}
*/
restoreFromSave(save) {
if (!save || save.v !== SAVE_VERSION) return false;
if (typeof save.day !== 'number' || typeof save.cash !== 'number' || typeof save.repTenth !== 'number') return false;
if (!save.unlocked || !Array.isArray(save.unlocked) || save.unlocked.length === 0) return false;
state.cash = Math.floor(save.cash);
state.repTenth = clamp(Math.floor(save.repTenth), 0, REP.max * 10);
state.priceMul = clamp(save.priceMul || 1, ECON.priceMin, ECON.priceMax);
state.stock = Object.assign({}, save.stock || {});
state.unlocked = save.unlocked.filter((id) => !!productOf(id));
state.upgrades = Object.assign({}, save.upgrades || {});
state.lifetime = Object.assign({ days: 0, served: 0, angry: 0, netJiao: 0, upgradesBought: 0 }, save.lifetime || {});
state.gameoverReason = null;
refreshDerived();
combo.reset();
emit('game_start', { continued: true });
enterPrep(Math.max(1, Math.floor(save.day)));
return true;
},
};
return core;
}
/** 升级全表再导出(渲染层/测试便利)。 */
export { UPGRADES, PRODUCTS };

View File

@ -0,0 +1,114 @@
/**
* shopkeeper-lines.js 店主台词库纯数据 + 注入随机的取词器零依赖
* ownerwanglanmei-ref 1 消费方宿主层事件气泡台词/ test条数与去重断言
*
* 角色壳
* 店主人设 = 通用市井小卖部老板娘占位形象IP 签署后换正式形象嘴快心热
* 精打细算爱跟街坊唠两句台词全部口语化带性格按场景分组 6 合计 20
* 红线IP gate台词与一切对外可见文本零受保护 IP 原名仅用 wanglanmei 代号指代
*/
'use strict';
/**
* 台词库场景 key 候选句子数组
* 场景与逻辑事件的映射由宿主层决定fx-director本文件只管说什么
* @type {Record<string, string[]>}
*/
export const LINES = {
/** 开店/进入准备 */
open_shop: [
'开张喽——街坊们都等着呢!',
'今儿个进点啥好货?我心里有数。',
'小本生意,账可得算明白咯。',
],
/** 开门营业瞬间 */
day_start: [
'开门迎客!都麻利点儿~',
'哟,今天太阳不错,准是个好日子。',
],
/** 闲时随口 */
idle_chat: [
'慢悠悠的下午,最适合盘账啦。',
'隔壁老李又来赊账?想得美。',
'货架擦得锃亮,看着就舒坦。',
],
/** 高峰来临 */
rush_start: [
'放学啦放学啦,打起精神来!',
'人都涌过来了,手上别停!',
],
/** 成功接待(普通) */
serve_ok: [
'好嘞,拿稳当!',
'您慢走,常来啊~',
'这就给您拿,快得很!',
],
/** 连击起势combo ≥3 */
combo_hot: [
'手感来了,谁都别拦我!',
'一个接一个,越干越顺溜!',
],
/** 大额成交 */
big_sale: [
'哎哟,大主顾!今晚加个鸡腿!',
'这单顶半天买卖,美滋滋~',
],
/** 惊险救单 */
rescue: [
'别走别走,这就给您拿上!',
'好险好险,差点儿黄了一单。',
],
/** 顾客愤怒离店 */
angry: [
'哎呀,把人晾跑了……心疼。',
'砸招牌了砸招牌了,得上点心!',
],
/** 缺货被看穿 */
stockout: [
'空货架最戳心窝子,明儿多备点。',
'又卖断了……是好事也是坏事。',
],
/** 日结盈利 */
settle_win: [
'今儿账面红火,记上记上!',
'辛苦一天,值当!',
],
/** 日结亏损/被罚 */
settle_lose: [
'这账……越算越心凉。',
'罚单都贴脸上了,明天得换个活法。',
],
/** 买升级 */
upgrade: [
'舍不得孩子套不着狼,升!',
'鸟枪换炮,咱也讲究一回!',
],
/** 失败终态 */
gameover: [
'唉……卷帘门一拉,从头再来吧。',
],
};
/** 台词场景总数(断言用)。 */
export const LINE_GROUP_COUNT = Object.keys(LINES).length;
/**
* 创建取词器同场景轮转不复读记住上一条下标注入随机挑非上一条
* @param {{ next:()=>number }} random 受控随机源
* @returns {(key:string)=>string} 取词函数未知场景返回空串
*/
export function createLinePicker(random) {
/** @type {Record<string, number>} 各场景上次用的下标。 */
const lastIdx = {};
return function pick(key) {
const arr = LINES[key];
if (!arr || arr.length === 0) return '';
if (arr.length === 1) return arr[0];
// 在「非上一条」里随机挑,保证连续两次不同句。
let idx = Math.floor(random.next() * arr.length) % arr.length;
if (idx === lastIdx[key]) idx = (idx + 1) % arr.length;
lastIdx[key] = idx;
return arr[idx];
};
}

View File

@ -0,0 +1,60 @@
✔ 商品表:开局 3 件解锁全部毛利为正priceMul=1 不卖一件亏一件) (3.120321ms)
✔ 定价→客流曲线:单调不增,低价多客高价少客,有下限 (4.153737ms)
✔ 声誉→客流50 分=基准 1.0,越高越旺 (0.555963ms)
✔ 二阶决策核心性质:定价两头都能输、中间能赢(解析模型钉死) (2.414891ms)
✔ 库存侧同样有真代价:备货不足封顶成交(缺货流失的经济面) (0.724014ms)
✔ 高峰系数曲线:进出有 3s 斜坡、峰区恒 1、外区恒 0 (1.863329ms)
✔ 连击小费1 连无加成,逐级 +4%,封顶 +32% (0.530341ms)
✔ 升级派生参数:容量/冷却/装修逐级生效 (0.756734ms)
✔ 售价取整与金额格式化(角整数口径) (0.918999ms)
[ref] localStorage 不可用,存档退内存 adapter[save-progress] createLocalStorageAdapter 需要全局 localStorage仅浏览器集成段可用。node/host-dev 请用缺省内存 adapter 或 createMemoryAdapter()。
[ref] localStorage 不可用,存档退内存 adapter[save-progress] createLocalStorageAdapter 需要全局 localStorage仅浏览器集成段可用。node/host-dev 请用缺省内存 adapter 或 createMemoryAdapter()。
[ref] localStorage 不可用,存档退内存 adapter[save-progress] createLocalStorageAdapter 需要全局 localStorage仅浏览器集成段可用。node/host-dev 请用缺省内存 adapter 或 createMemoryAdapter()。
✔ 无浏览器全链路boot→开店→进货定价→营业接待→日结→次日升级真 tap 路由) (1776.945792ms)
✔ 无浏览器确定性:同 seed 同驱动脚本 → 状态终点一致 (151.34042ms)
✔ import-safe../src/balance.js 可在 node 导入且导出齐备 (17.517917ms)
✔ import-safe../src/shop-core.js 可在 node 导入且导出齐备 (15.054321ms)
✔ import-safe../src/shopkeeper-lines.js 可在 node 导入且导出齐备 (3.169908ms)
✔ import-safe../src/loop-proof.js 可在 node 导入且导出齐备 (19.166177ms)
✔ import-safe../src/palette.js 可在 node 导入且导出齐备 (3.083209ms)
✔ import-safe../src/assets.js 可在 node 导入且导出齐备 (6.154749ms)
✔ import-safe../src/hostfx.js 可在 node 导入且导出齐备 (11.534494ms)
✔ import-safe../src/audio-director.js 可在 node 导入且导出齐备 (5.879451ms)
✔ import-safe../src/render.js 可在 node 导入且导出齐备 (13.195904ms)
✔ import-safe../src/main.js 可在 node 导入且导出齐备 (41.435327ms)
✔ import-safe../entry.js 可在 node 导入且导出齐备 (20.014131ms)
✔ palette取色器在 node 侧即可运转(纯计算,无 canvas 依赖) (9.702205ms)
✔ computeZones各阶段命中区几何自洽中心+半宽高,无 NaNid 唯一) (10.651098ms)
✔ 角色壳:台词场景 ≥6 组、总条数 ≥20、全部非空中文短句 (2.585166ms)
✔ 取词器:同场景连续两次不复读;同种子序列确定 (1.297027ms)
✔ IP gate本参考件全部源码/文档/资产清单零受保护字样 (11.865686ms)
✔ 资产清单manifest 存在则逐条校验文件在位且字段齐备 (1.504569ms)
✔ 闭环可达:开局→进货定价→营业接待→首日结算→首次升级,操作数 ≤ 64 (51.377226ms)
✔ 闭环证明确定性:同种子重跑逐字段一致 (29.534474ms)
✔ 失败态可达:摆烂经营 → 罚没 + 停业(非只胜不败) (11.483678ms)
✔ 五阶段面板绘制零运行时错误(含罚款行/小结/失败态/存档标题) (30.242431ms)
✔ 存档往返:玩出进度 → snapshot → P9 set/get → restore → 快照逐字段一致 (35.38545ms)
✔ 坏档容错:版本不符 / 结构残缺 → 拒绝并回退新开局(不崩) (3.026769ms)
✔ 命名空间隔离 + 清档clear 只清本命名空间 (0.812016ms)
✔ 阶段流转title→(startGame)prep→(startDay)open非法阶段命令被拒 (8.730899ms)
✔ 进货:资金占用 + 货架容量上限 + 本次 prep 可退 (1.07444ms)
✔ 定价:钳制 [0.8,1.5] 且吸附 0.05 步进 (0.495487ms)
✔ 接待闭环:到客→接待→收钱(价随定价)→冷却→连击小费 (3.88231ms)
✔ 耐心耗尽先进救单宽限grace 事件)再愤怒离店掉声誉 4 (3.856226ms)
✔ 救单宽限宽限期内接待成功rescued 标记 + 额外声誉奖励 (2.124579ms)
✔ 缺货:进店即失望停留 → 离店掉声誉 2.5;空货架点击返回 no_stock (1.108312ms)
✔ 日结账目:勤快经营一天 → settle 报表自洽(净利=营收−货成本−房租−罚款),房租入账 (6.031153ms)
✔ 罚没:日结时声誉 < 30 → 罚款 = 基数 + 差评加罚fined 事件) (7.747264ms)
✔ 失败终态①:声誉跌到 0 → 日结判停业reputation (7.533603ms)
✔ 失败终态②:日结付不起房租 → 停业bankrupt (10.444134ms)
✔ 升级:资金扣减 + 前置校验 + 商品解锁 + 收银冷却缩短生效 (1.215569ms)
✔ 三日一段落:第 3 天日结确认 → 段落小结 → 确认 → 第 4 天 prep (7.064072ms)
✔ 确定性:同种子同操作 → 事件流逐字段一致;异种子 → 到店节拍不同 (9.005889ms)
tests 49
suites 0
pass 49
fail 0
cancelled 0
skipped 0
todo 0
duration_ms 2207.457411

View File

@ -0,0 +1,108 @@
/**
* balance.test.mjs 数值模型测试二阶决策两头都能输是结构深度的根基
* 运行node --test games/wanglanmei-ref/test/零依赖
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
PRODUCTS,
productOf,
DAY,
rushFactorAt,
flowMulOfPrice,
flowMulOfReputation,
estimateDayOutcome,
comboTipMul,
derivedOfUpgrades,
priceJiaoOf,
ECON,
fmtYuan,
} from '../src/balance.js';
test('商品表:开局 3 件解锁全部毛利为正priceMul=1 不卖一件亏一件)', () => {
const initial = PRODUCTS.filter((p) => p.initial);
assert.equal(initial.length, 3);
for (const p of PRODUCTS) {
assert.ok(p.basePrice > p.wholesale, `${p.id} 基准价应高于进货价`);
assert.ok(p.popularity > 0);
}
});
test('定价→客流曲线:单调不增,低价多客高价少客,有下限', () => {
assert.ok(flowMulOfPrice(0.8) > flowMulOfPrice(1.0));
assert.ok(flowMulOfPrice(1.0) > flowMulOfPrice(1.25));
assert.ok(flowMulOfPrice(1.25) > flowMulOfPrice(1.5));
assert.equal(flowMulOfPrice(1.0), 1.0);
assert.ok(flowMulOfPrice(1.5) >= 0.2, '高价端客流有下限(不会归零导致除零)');
});
test('声誉→客流50 分=基准 1.0,越高越旺', () => {
assert.ok(Math.abs(flowMulOfReputation(50) - 1.0) < 1e-9);
assert.ok(flowMulOfReputation(100) > flowMulOfReputation(50));
assert.ok(flowMulOfReputation(0) < flowMulOfReputation(50));
});
test('二阶决策核心性质:定价两头都能输、中间能赢(解析模型钉死)', () => {
// 默认参数(开局声誉/接待力/段落 1 房租)下:
const lo = estimateDayOutcome({ priceMul: ECON.priceMin }); // 0.8 贱卖
const mid = estimateDayOutcome({ priceMul: 1.0 });
const hi = estimateDayOutcome({ priceMul: ECON.priceMax }); // 1.5 贵卖
assert.ok(mid.profitJiao > 0, `中间定价应盈利(实际 ${mid.profitJiao} 角)`);
assert.ok(lo.profitJiao < 0, `贱卖应亏损(毛利盖不住房租,实际 ${lo.profitJiao} 角)`);
assert.ok(hi.profitJiao < 0, `贵卖应亏损(客流盖不住房租,实际 ${hi.profitJiao} 角)`);
// 内部最优存在:扫格点,最优点既非最低也非最高(真权衡,不是单调送分题)。
let best = null;
for (let m = ECON.priceMin; m <= ECON.priceMax + 1e-9; m += ECON.priceStep) {
const r = estimateDayOutcome({ priceMul: m });
if (!best || r.profitJiao > best.profit) best = { m, profit: r.profitJiao };
}
assert.ok(best.m > ECON.priceMin + 1e-9 && best.m < ECON.priceMax - 1e-9, `最优定价应在区间内部(实际 ${best.m}`);
assert.ok(best.profit > mid.profitJiao * 0.99, '内部最优不劣于基准定价');
});
test('库存侧同样有真代价:备货不足封顶成交(缺货流失的经济面)', () => {
const full = estimateDayOutcome({ priceMul: 1.1 });
const starved = estimateDayOutcome({ priceMul: 1.1, stockUnits: 8 });
assert.ok(starved.served < full.served);
assert.ok(starved.profitJiao < full.profitJiao, '少备货 → 少赚(资金占用 vs 缺货流失是真权衡)');
});
test('高峰系数曲线:进出有 3s 斜坡、峰区恒 1、外区恒 0', () => {
assert.equal(rushFactorAt(0), 0);
assert.equal(rushFactorAt(DAY.rushStartSec + 1), 1);
assert.equal(rushFactorAt(DAY.rushEndSec + DAY.rushRampSec + 1), 0);
const ramp = rushFactorAt(DAY.rushStartSec - DAY.rushRampSec / 2);
assert.ok(ramp > 0 && ramp < 1, '斜坡区应在 (0,1) 内');
});
test('连击小费1 连无加成,逐级 +4%,封顶 +32%', () => {
assert.equal(comboTipMul(1), 1);
assert.ok(Math.abs(comboTipMul(2) - 1.04) < 1e-9);
assert.ok(Math.abs(comboTipMul(9) - 1.32) < 1e-9);
assert.ok(Math.abs(comboTipMul(50) - 1.32) < 1e-9, '超过 9 连不再增长');
});
test('升级派生参数:容量/冷却/装修逐级生效', () => {
const base = derivedOfUpgrades({});
assert.equal(base.shelfCap, 24);
assert.equal(base.serveCooldownSec, 0.55);
const s1 = derivedOfUpgrades({ shelf1: true, speed1: true, decor1: true });
assert.equal(s1.shelfCap, 34);
assert.equal(s1.serveCooldownSec, 0.42);
assert.ok(s1.patienceMul > 1 && s1.settleRepBonusTenth > 0);
const s2 = derivedOfUpgrades({ shelf1: true, shelf2: true, speed1: true, speed2: true, decor1: true, decor2: true });
assert.equal(s2.shelfCap, 48);
assert.equal(s2.serveCooldownSec, 0.32);
assert.ok(s2.flowMul > 1);
});
test('售价取整与金额格式化(角整数口径)', () => {
assert.equal(priceJiaoOf('soda', 1.0), 40);
assert.equal(priceJiaoOf('snack', 1.15), Math.round(25 * 1.15));
assert.equal(fmtYuan(1200), '120');
assert.equal(fmtYuan(45), '4.5');
assert.equal(productOf('nope'), undefined);
});

View File

@ -0,0 +1,167 @@
/**
* headless-boot.test.mjs 无浏览器全链路冒烟本机最强可跑验证
*
* Proxy 2D 上下文 + window 真跑 bootRef
* boot(evidence) 标题点开店 prep 进货/开门营业 营业期连点货架接待
* 跑满一天到日结 继续 2 prep 点升级卡 断言升级到手
* 整条链路走的就是真实 onTap 命中路由 + 真实渲染调用面每帧 drawFrame 全画
* 任何宿主层运行时错误都会在此抛出像素正确性留集成段浏览器证据两层测试纪律
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
/** 建一个「接受一切调用」的伪 2D 上下文(特定方法给出形状正确的返回值)。 */
function makeFake2d() {
const grad = { addColorStop() {} };
const base = {
canvas: { width: 780, height: 1688 },
createLinearGradient: () => grad,
createRadialGradient: () => grad,
getImageData: () => ({ data: new Uint8ClampedArray(4), width: 1, height: 1 }),
putImageData() {},
measureText: () => ({ width: 0 }),
};
const fnCache = new Map();
return new Proxy(base, {
get(target, prop) {
if (prop in target) return target[prop];
// 其余一律给可调用空函数fillRect/arc/fillText/setTransform/clip/…)。
if (!fnCache.has(prop)) fnCache.set(prop, () => {});
return fnCache.get(prop);
},
set(target, prop, v) {
target[prop] = v; // fillStyle/font/globalAlpha… 任意赋值放行
return true;
},
});
}
/** 装好 window/canvas 桩并 boot。@param {string} search URL 参数 */
async function bootHeadless(search) {
globalThis.window = {
location: { search },
addEventListener() {},
requestAnimationFrame() {
return 0;
},
cancelAnimationFrame() {},
};
const fake2d = makeFake2d();
const canvas = {
width: 0,
height: 0,
style: {},
getContext: () => fake2d,
addEventListener() {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 390, height: 844 }),
};
const { bootRef } = await import('../src/main.js');
const host = await bootRef({ canvas });
return host;
}
test('无浏览器全链路boot→开店→进货定价→营业接待→日结→次日升级真 tap 路由)', async () => {
const host = await bootHeadless('?mode=evidence&seed=99');
const F = globalThis.window.__refForensics;
const C = globalThis.window.__refChecklist;
assert.equal(host.mode, 'evidence');
assert.equal(F.probe().verify.ok, true, '六锚链 verifyChain 应通过');
assert.deepEqual(
F.probe().anchors.slice(0, 6),
['t_boot', 't_plugins_ready', 't_game_init', 't_first_paint', 't_input_bound', 't_game_start'],
'六锚 canonical 顺序'
);
assert.equal(F.state().phase, 'title');
/** 按 zone id 点一下(取证几何=命中真源)。@param {string} id */
function tapZone(id) {
const z = F.geometry().find((zz) => zz.id === id);
assert.ok(z, `命中区缺失:${id}(当前=${F.geometry().map((x) => x.id).join(',')}`);
host.tap(z.x, z.y);
host.stepFrames(2); // 让事件/渲染落帧
}
// ① 标题 → 开店。
tapZone('newGame');
assert.equal(F.state().phase, 'prep');
// ② 进货:每件商品点「+」若干次(走真实按钮路由)。
for (const pid of ['soda', 'snack', 'bread']) {
for (let i = 0; i < 7; i++) tapZone('inc-' + pid);
}
assert.ok(F.state().stock.soda >= 6, '进货应到账');
// ③ 开门营业。
tapZone('openShop');
assert.equal(F.state().phase, 'open');
// ④ 营业:每 30 帧把三个货架槽各点一遍(匹配则接待;冷却点击走输入缓冲)。
let guard = 0;
while (F.state().phase === 'open' && guard++ < 240) {
for (const pid of F.state().unlocked) {
const z = F.geometry().find((zz) => zz.id === 'serve-' + pid);
if (z) host.tap(z.x, z.y);
host.stepFrames(6);
}
host.stepFrames(12);
}
assert.equal(F.state().phase, 'settle', `应到日结guard=${guard}`);
const settled = F.state();
assert.ok(settled.today.served > 0, '当天应有成交');
// ⑤ 日结 → 第 2 天 prep。
tapZone('confirmSettle');
assert.equal(F.state().phase, 'prep');
assert.equal(F.state().day, 2);
// ⑥ 升级:点「收银加速 I」卡¥55首日利润后必买得起不够就先验余额再断言
const cashBefore = F.state().cash;
assert.ok(cashBefore >= 550, `第 2 天开市前应买得起最便宜升级cash=${cashBefore}`);
tapZone('up-speed1');
assert.equal(F.state().upgrades.speed1, true, '升级应到手');
// ⑦ checklist 自动子项抽查(结构深度/角色壳/手感子项在 node 侧即可断言)。
assert.equal(C.shendu.loopReachable, true);
assert.equal(C.shendu.failureReachable, true);
assert.equal(C.shendu.priceTwoSidedLoss, true);
assert.ok(C.shougan.tapFeedbackMaxFrames <= 3, `点击反馈帧延迟 ${C.shougan.tapFeedbackMaxFrames} 应 ≤3`);
assert.ok(C.juese.lineTotal >= 20);
assert.ok(C.juese.linesShownLive > 0, '过程中应展示过台词');
// 评估环境无音频:音效种类为 0 属预期(发声留集成段真浏览器)。
assert.equal(C.yinyue.audioUnlocked, true, 'tap 应触发解锁流程(无 AudioContext 时降级静默)');
// ⑧ 取证几何与状态导出形状健全。
for (const z of F.geometry()) {
assert.ok(Number.isFinite(z.x) && Number.isFinite(z.hw));
}
assert.ok(F.recentEvents().length > 0);
assert.ok(F.measures().frames > 100);
});
test('无浏览器确定性:同 seed 同驱动脚本 → 状态终点一致', async () => {
/** 跑固定脚本到营业 300 帧,返回状态快照。 */
async function run() {
// 每次重置模块图无必要bootRef 每次新建实例互不共享状态window 桩重建)。
const host = await bootHeadless('?mode=evidence&seed=777');
const F = globalThis.window.__refForensics;
const tap = (id) => {
const z = F.geometry().find((zz) => zz.id === id);
if (z) {
host.tap(z.x, z.y);
host.stepFrames(2);
}
};
tap('newGame');
for (const pid of ['soda', 'snack']) for (let i = 0; i < 4; i++) tap('inc-' + pid);
tap('openShop');
host.stepFrames(300);
const s = F.state();
return JSON.stringify({ cash: s.cash, customers: s.customers, stock: s.stock, rep: s.reputation });
}
const a = await run();
const b = await run();
assert.equal(a, b, '同 seed 同脚本应逐字段一致(受控时间/随机生效)');
});

View File

@ -0,0 +1,72 @@
/**
* import-graph.test.mjs 模块图可解析 + import-safe 守门
* 宿主层文件main/render/hostfx/audio/assets/entry必须import 即安全
* 顶层零浏览器 API 调用boot 由页面显式触发node import 不抛错
* 这是本机无浏览器条件下对宿主层语法/依赖图的最强可跑验证像素级证据留集成段
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const MODULES = [
['../src/balance.js', ['PRODUCTS', 'estimateDayOutcome', 'flowMulOfPrice']],
['../src/shop-core.js', ['createShopCore', 'SAVE_VERSION']],
['../src/shopkeeper-lines.js', ['LINES', 'createLinePicker']],
['../src/loop-proof.js', ['proveCoreLoop', 'proveFailure']],
['../src/palette.js', ['createPalette', 'BASE']],
['../src/assets.js', ['loadAssets', 'PRODUCT_IMAGE_KEY']],
['../src/hostfx.js', ['createHostFx']],
['../src/audio-director.js', ['createAudioDirector']],
['../src/render.js', ['drawFrame', 'computeZones', 'shelfSlotRect', 'LAYOUT']],
['../src/main.js', ['bootRef']],
['../entry.js', ['bootRef']],
];
for (const [path, expectedExports] of MODULES) {
test(`import-safe${path} 可在 node 导入且导出齐备`, async () => {
const mod = await import(path);
for (const name of expectedExports) {
assert.ok(name in mod, `${path} 缺导出 ${name}`);
}
});
}
test('palette取色器在 node 侧即可运转(纯计算,无 canvas 依赖)', async () => {
const { createPalette } = await import('../src/palette.js');
const pal = createPalette();
const calm = pal.c('gold');
pal.setRush(1);
const rush = pal.c('gold');
assert.ok(/^rgb\(/.test(calm) && /^rgb\(/.test(rush));
assert.notEqual(calm, rush, '高峰色板应与平静色板不同(压力换色真实生效)');
assert.equal(pal.c('ink', 0.5).startsWith('rgba('), true);
});
test('computeZones各阶段命中区几何自洽中心+半宽高,无 NaNid 唯一)', async () => {
const { computeZones } = await import('../src/render.js');
const { createShopCore } = await import('../src/shop-core.js');
const { SeededRandom } = await import('../../../src/core/plugin.js');
const core = createShopCore({ random: new SeededRandom(3) });
// title 阶段。
let zones = computeZones(core, { hasSave: true });
assert.ok(zones.some((z) => z.id === 'newGame') && zones.some((z) => z.id === 'continue'));
// prep 阶段。
core.startGame(null);
zones = computeZones(core, { hasSave: false });
assert.ok(zones.some((z) => z.kind === 'slider'));
assert.ok(zones.filter((z) => z.kind === 'upgrade').length === 8, '8 个升级卡都可点');
// open 阶段:货架槽=接待区。
core.buyStock('soda', 2);
core.startDay();
zones = computeZones(core, { hasSave: false });
assert.equal(zones.filter((z) => z.kind === 'serve').length, core.state.unlocked.length);
// 几何健康:唯一 id + 数值有限。
const ids = new Set();
for (const z of zones) {
assert.ok(!ids.has(z.id), `命中区 id 重复:${z.id}`);
ids.add(z.id);
for (const k of ['x', 'y', 'hw', 'hh']) assert.ok(Number.isFinite(z[k]) && z[k] >= 0, `${z.id}.${k} 非法`);
}
});

View File

@ -0,0 +1,90 @@
/**
* lines-and-ipgate.test.mjs 店主台词库角色壳+ IP gate 静态扫描
* 覆盖场景组数 6 / 总条数 20 / 取词器不复读且确定性 / 源码与台词零受保护 IP 字样
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SeededRandom } from '../../../src/core/plugin.js';
import { LINES, LINE_GROUP_COUNT, createLinePicker } from '../src/shopkeeper-lines.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const GAME_ROOT = join(HERE, '..');
// 受保护 IP 字样IP gate 红线):对外文本/源码字符串一律不得出现。
// 用码位拼接构造,避免本测试文件自身含该字面量。
const BANNED = String.fromCharCode(0x738b, 0x84dd, 0x8393); // 「王」「蓝」「莓」
test('角色壳:台词场景 ≥6 组、总条数 ≥20、全部非空中文短句', () => {
assert.ok(LINE_GROUP_COUNT >= 6, `场景组数 ${LINE_GROUP_COUNT} 应 ≥6`);
let total = 0;
for (const key of Object.keys(LINES)) {
for (const s of LINES[key]) {
assert.ok(typeof s === 'string' && s.length >= 2, `${key} 存在空台词`);
total += 1;
}
}
assert.ok(total >= 20, `台词总数 ${total} 应 ≥20`);
});
test('取词器:同场景连续两次不复读;同种子序列确定', () => {
const pick1 = createLinePicker(new SeededRandom(8));
const pick2 = createLinePicker(new SeededRandom(8));
let prev = '';
for (let i = 0; i < 20; i++) {
const a = pick1('serve_ok');
const b = pick2('serve_ok');
assert.equal(a, b, '同种子取词序列应一致');
assert.notEqual(a, prev, '连续两次不应复读同句');
assert.ok(a.length > 0);
prev = a;
}
assert.equal(pick1('unknown_scene'), '', '未知场景返回空串不抛错');
});
test('IP gate本参考件全部源码/文档/资产清单零受保护字样', () => {
/** 递归收集待扫描文本文件。@param {string} dir */
function collect(dir) {
/** @type {string[]} */
const out = [];
for (const name of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, name.name);
if (name.isDirectory()) {
// dist=构建产物(由集成段 grep 门另测evidence=主会话证据归档,不属本件产出。
if (name.name === 'dist' || name.name === 'node_modules' || name.name === 'evidence') continue;
out.push(...collect(p));
} else if (/\.(js|mjs|html|json|md|txt)$/.test(name.name)) {
// LOOP-LOG.md 是内部循环留痕(执行版 §4内部文档允许提及原名不进对外 IP gate。
if (name.name === 'LOOP-LOG.md') continue;
out.push(p);
}
}
return out;
}
for (const file of collect(GAME_ROOT)) {
const text = readFileSync(file, 'utf8');
assert.ok(!text.includes(BANNED), `IP gate 违例:${file} 含受保护字样`);
}
});
test('资产清单manifest 存在则逐条校验文件在位且字段齐备', () => {
const manifestPath = join(GAME_ROOT, 'assets', 'manifest.json');
if (!existsSync(manifestPath)) {
// 资产生成前允许缺位(生成后本测试自动转严格)。
assert.ok(true);
return;
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
assert.ok(Array.isArray(manifest.assets) && manifest.assets.length > 0);
for (const a of manifest.assets) {
assert.ok(a.file && typeof a.bytes === 'number' && a.sha256 && a.source, `资产记录字段残缺:${JSON.stringify(a)}`);
assert.ok(existsSync(join(GAME_ROOT, 'assets', a.file)), `资产文件缺位:${a.file}`);
const src = JSON.stringify(a.source);
assert.ok(!src.includes(BANNED), `资产 prompt 含受保护字样:${a.file}`);
}
});

View File

@ -0,0 +1,37 @@
/**
* loop-proof.test.mjs 闭环可达 / 失败态可达结构深度的两份客观证明
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { proveCoreLoop, proveFailure } from '../src/loop-proof.js';
test('闭环可达:开局→进货定价→营业接待→首日结算→首次升级,操作数 ≤ 64', () => {
const proof = proveCoreLoop();
assert.ok(proof.ok, `闭环证明失败:${proof.failReason}ops=${proof.ops.join('|')}`);
assert.ok(proof.opCount <= 64, `操作数超限:${proof.opCount}`);
assert.ok(proof.served > 0, '首日应有成交');
assert.ok(proof.upgradeId, '应完成首次升级');
assert.equal(proof.day, 2, '首次升级落在第 2 天开市前(首段落内)');
// 关键路径节点齐备(人读得懂的 op 流水)。
assert.ok(proof.ops[0] === 'startGame');
assert.ok(proof.ops.includes('startDay'));
assert.ok(proof.ops.includes('confirmSettle'));
assert.ok(proof.ops.some((o) => o.startsWith('buyUpgrade(')));
});
test('闭环证明确定性:同种子重跑逐字段一致', () => {
const a = proveCoreLoop(20260612);
const b = proveCoreLoop(20260612);
assert.deepEqual(a, b);
});
test('失败态可达:摆烂经营 → 罚没 + 停业(非只胜不败)', () => {
const proof = proveFailure();
assert.ok(proof.ok, `失败态证明未达成(${proof.days} 天内未停业)`);
assert.ok(proof.reason === 'reputation' || proof.reason === 'bankrupt', `失败原因应为声誉/破产,实际 ${proof.reason}`);
assert.ok(proof.events.some((s) => s.includes('fined')), '失败路径上应出现罚没');
assert.ok(proof.events.some((s) => s.includes('gameover')), '应出现失败终态事件');
});

View File

@ -0,0 +1,100 @@
/**
* panel-draw.test.mjs 各阶段面板绘制路径冒烟白盒造状态 + 2D 上下文
* 覆盖 headless 全链路未触达的绘制分支带罚款的日结 / 段落小结 / 失败终态 / 带存档的标题 / prep
* 任何绘制路径运行时错误空引用/NaN 入参等在此抛出像素正确性留集成段
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHostDevContext, SeededRandom } from '../../../src/core/plugin.js';
import { createParticlesJuicePlugin } from '../../../src/plugins/particles-juice/impl.js';
import { createShopCore } from '../src/shop-core.js';
import { createPalette } from '../src/palette.js';
import { createHostFx } from '../src/hostfx.js';
import { drawFrame } from '../src/render.js';
/** 伪 2D 上下文(与 headless-boot 同构)。 */
function makeFake2d() {
const grad = { addColorStop() {} };
const base = {
canvas: { width: 780, height: 1688 },
createLinearGradient: () => grad,
createRadialGradient: () => grad,
getImageData: () => ({ data: new Uint8ClampedArray(4), width: 1, height: 1 }),
putImageData() {},
measureText: () => ({ width: 0 }),
};
const fns = new Map();
return new Proxy(base, {
get(t, p) {
if (p in t) return t[p];
if (!fns.has(p)) fns.set(p, () => {});
return fns.get(p);
},
set(t, p, v) {
t[p] = v;
return true;
},
});
}
/** 组装最小 view。 */
function makeView(core) {
const bundle = createHostDevContext({ context2d: null, seed: 5 });
const juice = createParticlesJuicePlugin({ seed: 5 });
juice.init(bundle.deriveContextFor('particles-juice', 'particles-juice'));
const fx = createHostFx({ bundle, juice });
return {
core,
pal: createPalette(),
assets: { images: {}, missing: [], loaded: 0, total: 0 }, // 全程序化回退分支
fx,
ui: { bubbleText: '试营业~', bubbleUntil: 9999, keeperWaveUntil: 0, tickerText: 't', banner: { text: '高峰来了!', color: 'brick', dur: 1000, until: 500 }, panelSlide: 1, settleAnim: 1, ghosts: [{ slot: 0, avatar: 1, emote: '怒', until: 400 }] },
nowMs: 100,
customerBornMs: {},
hasSave: true,
};
}
test('五阶段面板绘制零运行时错误(含罚款行/小结/失败态/存档标题)', () => {
const g = makeFake2d();
const core = createShopCore({ random: new SeededRandom(5) });
const view = makeView(core);
// title带存档按钮
assert.equal(core.state.phase, 'title');
drawFrame(g, view);
// prep含升级网格/滑条/进货行)。
core.startGame(null);
core.buyStock('soda', 3);
drawFrame(g, view);
// open造几位顾客 + 各 mood 分支)。
core.startDay();
for (let i = 0; i < 240; i++) core.update(1 / 30);
assert.equal(core.state.phase, 'open');
drawFrame(g, view);
// settle白盒注入带罚款报表
core.state.phase = 'settle';
core.state.settle = {
day: 1, revenueJiao: 800, soldCostJiao: 300, rentJiao: 400, fineJiao: 260, netJiao: -160,
served: 12, angry: 3, stockout: 2, walkaway: 1, rescued: 1, maxCombo: 4, repBackTenth: 15, reputation: 28,
};
drawFrame(g, view);
// summary。
core.state.phase = 'summary';
core.state.summary = { segment: 1, days: 3, netJiao: 520, served: 60, upgradesBought: 2, reputation: 66 };
drawFrame(g, view);
// gameover两种失败原因
core.state.phase = 'gameover';
core.state.gameoverReason = 'reputation';
drawFrame(g, view);
core.state.gameoverReason = 'bankrupt';
drawFrame(g, view);
});

View File

@ -0,0 +1,80 @@
/**
* save-roundtrip.test.mjs 存档读写往返P9 save-progress 插件公开 API内存 adapter
* 证明游戏进度经 P9 set/get 往返后逐字段一致且坏档/缺档安全回退新开局
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PluginRegistry, createHostDevContext, SeededRandom } from '../../../src/core/plugin.js';
import { createSaveProgressPlugin, createMemoryAdapter } from '../../../src/plugins/save-progress/impl.js';
import { createShopCore } from '../src/shop-core.js';
/** 经注册器把 P9 接上受控面(与浏览器集成同形,仅 adapter 不同)。 */
function makeSave() {
const { context } = createHostDevContext({ context2d: null });
const reg = new PluginRegistry();
reg.useContext(context);
const save = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter: createMemoryAdapter() });
reg.register(save);
const r = reg.initAll();
assert.ok(r.ok, 'P9 init 应成功');
return { save, reg };
}
test('存档往返:玩出进度 → snapshot → P9 set/get → restore → 快照逐字段一致', () => {
const { save } = makeSave();
// ① 玩出一点真实进度(升级 + 进货 + 调价)。
const core1 = createShopCore({ random: new SeededRandom(99) });
core1.startGame(null);
core1.buyUpgrade('speed1');
core1.buyStock('soda', 4);
core1.buyStock('bread', 2);
core1.setPrice(1.25);
const snap1 = core1.snapshotForSave();
// ② 写入 P9内存 adapter→ 读回。
assert.ok(save.set('profile', snap1), 'P9 set 应成功');
const loaded = save.get('profile');
assert.deepEqual(loaded, snap1, 'P9 往返后逐字段一致');
// ③ 用读回的存档恢复新核 → 再快照应与原快照一致(续档语义完整)。
const core2 = createShopCore({ random: new SeededRandom(99) });
const r = core2.startGame(loaded);
assert.ok(r.ok && r.continued, '应走续档路径');
assert.equal(core2.state.phase, 'prep', '续档恢复到存档日 prep');
assert.deepEqual(core2.snapshotForSave(), snap1);
assert.equal(core2.derived().serveCooldownSec, 0.42, '升级派生参数随档恢复');
});
test('坏档容错:版本不符 / 结构残缺 → 拒绝并回退新开局(不崩)', () => {
const { save } = makeSave();
save.set('profile', { v: 999, day: 3 }); // 版本不符
const core = createShopCore({ random: new SeededRandom(5) });
const r = core.startGame(save.get('profile'));
assert.ok(r.ok);
assert.equal(r.continued, false, '坏档应回退新开局');
assert.equal(core.state.day, 1);
// 缺档undefined同样回退。
const core2 = createShopCore({ random: new SeededRandom(5) });
const r2 = core2.startGame(save.get('missing_key'));
assert.equal(r2.continued, false);
});
test('命名空间隔离 + 清档clear 只清本命名空间', () => {
const { context } = (() => ({ context: createHostDevContext({ context2d: null }).context }))();
const reg = new PluginRegistry();
reg.useContext(context);
const adapter = createMemoryAdapter();
const mine = createSaveProgressPlugin({ namespace: 'wanglanmei-ref', adapter });
const other = createSaveProgressPlugin({ namespace: 'other-game', adapter });
reg.register(mine); // 同名插件只能注册一个other 直接 initP9 init 不读 ctx
reg.initAll();
other.init(context);
mine.set('profile', { a: 1 });
other.set('profile', { b: 2 });
assert.equal(mine.clear(), 1, '只清掉本命名空间 1 条');
assert.deepEqual(other.get('profile'), { b: 2 }, '他人命名空间不受影响');
});

View File

@ -0,0 +1,336 @@
/**
* shop-core.test.mjs 营业日状态机测试确定性驱动SeededRandom + 固定步长
* 覆盖阶段流转 / 接待收钱连击 / 耐心耗尽掉声誉 / 缺货流失 / 救单宽限 /
* 日结账目房租/罚没/ 失败终态两条路径 / 升级效果 / 定价钳制
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SeededRandom } from '../../../src/core/plugin.js';
import { createShopCore } from '../src/shop-core.js';
import { REP, ECON, DAY, PATIENCE, priceJiaoOf } from '../src/balance.js';
const STEP = 1 / 30;
/** 造一局并跳到 prep。@param {number} seed */
function newGame(seed) {
const core = createShopCore({ random: new SeededRandom(seed) });
core.startGame(null);
core.drainEvents();
return core;
}
/** 推进直到谓词成立或超时(秒)。@returns {boolean} 是否在时限内成立 */
function runUntil(core, predicate, maxSec) {
const steps = Math.ceil((maxSec || 60) / STEP);
for (let i = 0; i < steps; i++) {
core.update(STEP);
if (predicate()) return true;
}
return predicate();
}
/** 收集事件直到谓词命中(事件保留返回)。 */
function collectUntil(core, typeWanted, maxSec) {
/** @type {any[]} */
const all = [];
const ok = runUntil(
core,
() => {
all.push(...core.drainEvents());
return all.some((e) => e.type === typeWanted);
},
maxSec
);
return { ok, events: all };
}
test('阶段流转title→(startGame)prep→(startDay)open非法阶段命令被拒', () => {
const core = createShopCore({ random: new SeededRandom(7) });
assert.equal(core.state.phase, 'title');
assert.equal(core.serve('soda').ok, false); // title 不能接待
core.startGame(null);
assert.equal(core.state.phase, 'prep');
assert.equal(core.confirmSettle().ok, false); // prep 不能确认日结
assert.ok(core.buyStock('soda', 2).ok);
assert.ok(core.startDay().ok);
assert.equal(core.state.phase, 'open');
assert.equal(core.buyStock('soda', 1).ok, false, '营业中禁止进货');
assert.equal(core.setPrice(1.2).ok, false, '营业中禁止调价');
});
test('进货:资金占用 + 货架容量上限 + 本次 prep 可退', () => {
const core = newGame(11);
const cash0 = core.state.cash;
assert.ok(core.buyStock('soda', 5).ok);
assert.equal(core.state.cash, cash0 - 5 * 20, '进货立即占用资金(汽水进价 2 元/件)');
assert.equal(core.state.stock.soda, 5);
// 容量上限(基础 24
assert.ok(core.buyStock('snack', 19).ok);
assert.equal(core.buyStock('bread', 1).ok, false, '24 件满架后再买应被拒');
assert.equal(core.buyStock('bread', 1).reason, 'shelf_full');
// 退货(仅退本次 prep 买的,原价)。
const cashBefore = core.state.cash;
assert.ok(core.refundStock('soda', 2).ok);
assert.equal(core.state.cash, cashBefore + 2 * 20);
assert.equal(core.state.stock.soda, 3);
// 未解锁商品不可进货。
assert.equal(core.buyStock('toy', 1).ok, false);
});
test('定价:钳制 [0.8,1.5] 且吸附 0.05 步进', () => {
const core = newGame(13);
core.setPrice(0.3);
assert.equal(core.state.priceMul, 0.8);
core.setPrice(9);
assert.equal(core.state.priceMul, 1.5);
core.setPrice(1.13);
assert.equal(core.state.priceMul, 1.15);
});
test('接待闭环:到客→接待→收钱(价随定价)→冷却→连击小费', () => {
const core = newGame(21);
core.buyStock('soda', 8);
core.buyStock('snack', 8);
core.buyStock('bread', 8);
core.setPrice(1.0);
core.startDay();
core.drainEvents();
// 等到至少 2 位顾客在场(闲时间隔 ~4s两位 ~9s 内必到)。
assert.ok(runUntil(core, () => core.state.customers.length >= 2, 30), '30s 内应有两位顾客');
const c1 = core.state.customers[0];
const cashBefore = core.state.cash;
const r1 = core.serve(c1.productId);
assert.ok(r1.ok);
assert.equal(r1.combo, 1);
assert.equal(r1.revenueJiao, priceJiaoOf(c1.productId, 1.0), '首单无小费=标价');
assert.equal(core.state.cash, cashBefore + r1.revenueJiao);
// 冷却期内再接待被拒。
const c2 = core.state.customers[0];
assert.equal(core.serve(c2 ? c2.productId : 'soda').reason, 'cooldown');
// 等冷却结束0.55s第二单落在连击窗2.6s)内 → combo=2小费 +4%。
assert.ok(runUntil(core, () => core.state.serveCooldown === 0, 2));
const c2b = core.state.customers.find((c) => c.mood !== 'stockout');
assert.ok(c2b, '应仍有可接待顾客');
const r2 = core.serve(c2b.productId);
assert.ok(r2.ok);
assert.equal(r2.combo, 2);
assert.equal(r2.revenueJiao, Math.round(priceJiaoOf(c2b.productId, 1.0) * 1.04), '2 连击小费 +4%');
assert.equal(core.state.today.maxCombo, 2);
});
test('耐心耗尽先进救单宽限grace 事件)再愤怒离店掉声誉 4', () => {
const core = newGame(31);
core.buyStock('soda', 8);
core.buyStock('snack', 8);
core.buyStock('bread', 8); // 备足货 → 排除缺货路径
core.startDay();
const repBefore = core.state.repTenth;
const got = collectUntil(core, 'angry_leave', 40); // 闲时耐心 ~13s留足余量
assert.ok(got.ok, '不接待应出现愤怒离店');
const graceIdx = got.events.findIndex((e) => e.type === 'customer_grace');
const angryIdx = got.events.findIndex((e) => e.type === 'angry_leave');
assert.ok(graceIdx >= 0 && graceIdx < angryIdx, '愤怒离店前必先进入救单宽限');
const angryCount = got.events.filter((e) => e.type === 'angry_leave').length;
assert.equal(core.state.repTenth, repBefore - angryCount * REP.angryLossTenth, '每个差评 4.0 声誉');
assert.ok(got.events.some((e) => e.type === 'customer_impatient'), '低耐心应触发不耐烦事件');
});
test('救单宽限宽限期内接待成功rescued 标记 + 额外声誉奖励', () => {
const core = newGame(33);
core.buyStock('soda', 8);
core.buyStock('snack', 8);
core.buyStock('bread', 8);
core.startDay();
// 等第一位顾客进入宽限态。
assert.ok(
runUntil(core, () => core.state.customers.some((c) => c.mood === 'leaving'), 40),
'应出现进入宽限态的顾客'
);
const leaving = core.state.customers.find((c) => c.mood === 'leaving');
const repBefore = core.state.repTenth;
const r = core.serve(leaving.productId);
assert.ok(r.ok && r.rescued, '宽限期内接待应成功且标记 rescued');
assert.equal(core.state.repTenth, repBefore + REP.serveGainTenth + REP.rescueBonusTenth);
assert.equal(core.state.today.rescued, 1);
});
test('缺货:进店即失望停留 → 离店掉声誉 2.5;空货架点击返回 no_stock', () => {
const core = newGame(41);
// 全程不进货 → 所有顾客都是 stockout 路径。
core.startDay();
const repBefore = core.state.repTenth;
const got = collectUntil(core, 'stockout_leave', 30);
assert.ok(got.ok, '缺货顾客应失望离店');
const n = got.events.filter((e) => e.type === 'stockout_leave').length;
assert.equal(core.state.repTenth, repBefore - n * REP.stockoutLossTenth);
// 空货架点击。
const r = core.serve('soda');
assert.equal(r.ok, false);
assert.equal(r.reason, 'no_stock');
});
test('日结账目:勤快经营一天 → settle 报表自洽(净利=营收−货成本−房租−罚款),房租入账', () => {
const core = newGame(51);
core.buyStock('soda', 8);
core.buyStock('snack', 9);
core.buyStock('bread', 6);
core.setPrice(1.15);
core.startDay();
// 勤快策略:能接就接。
runUntil(
core,
() => {
if (core.state.phase !== 'open') return true;
if (core.state.serveCooldown === 0) {
const c = core.state.customers.find(
(x) => (x.mood === 'queue' || x.mood === 'leaving') && (core.state.stock[x.productId] || 0) > 0
);
if (c) core.serve(c.productId);
}
return false;
},
DAY.lengthSec + 10
);
assert.equal(core.state.phase, 'settle', '营业日走完应进日结');
const s = core.state.settle;
assert.ok(s.served > 0, '应有成交');
assert.equal(s.netJiao, s.revenueJiao - s.soldCostJiao - s.rentJiao - s.fineJiao, '净利公式自洽');
assert.equal(s.rentJiao, ECON.rentBaseJiao, '第 1 段落房租=基础房租');
assert.equal(s.fineJiao, 0, '声誉健康不应被罚');
// 日结确认 → 第 2 天 prep。
assert.ok(core.confirmSettle().ok);
assert.equal(core.state.phase, 'prep');
assert.equal(core.state.day, 2);
});
test('罚没:日结时声誉 < 30 → 罚款 = 基数 + 差评加罚fined 事件)', () => {
const core = newGame(61);
core.buyStock('soda', 8);
core.buyStock('snack', 8);
core.buyStock('bread', 8);
core.startDay();
// 推到临近打烊,然后白盒制造「低声誉但现金充裕」局面(隔离罚没路径,不连带破产)。
runUntil(core, () => core.state.dayTimeSec >= DAY.lengthSec - 2, DAY.lengthSec + 5);
core.state.repTenth = 200; // 20 分 < 罚没阈值 30> 0 不触发声誉失败
core.state.cash = 8000; // 现金充裕 → 罚得起,不触发破产失败
const got = collectUntil(core, 'day_end', 5);
assert.ok(got.ok);
assert.ok(got.events.some((e) => e.type === 'fined'), '应触发罚没事件');
const s = core.state.settle;
assert.equal(s.fineJiao, ECON.fineBaseJiao + s.angry * ECON.finePerAngryJiao, '罚款=基数+每差评加罚');
assert.equal(core.state.phase, 'settle', '被罚但未死 → 正常进日结');
});
test('失败终态①:声誉跌到 0 → 日结判停业reputation', () => {
const core = newGame(71);
core.buyStock('soda', 8);
core.buyStock('snack', 8);
core.buyStock('bread', 8);
core.startDay();
runUntil(core, () => core.state.dayTimeSec >= DAY.lengthSec - 2, DAY.lengthSec + 5);
core.state.repTenth = 0; // 白盒:声誉归零
runUntil(core, () => core.state.phase !== 'open', 5);
assert.equal(core.state.phase, 'gameover');
assert.equal(core.state.gameoverReason, 'reputation');
});
test('失败终态②:日结付不起房租 → 停业bankrupt', () => {
const core = newGame(73);
core.buyStock('soda', 8);
core.buyStock('snack', 8);
core.buyStock('bread', 8);
core.startDay();
runUntil(core, () => core.state.dayTimeSec >= DAY.lengthSec - 2, DAY.lengthSec + 5);
core.state.cash = 100; // 白盒:只剩 ¥10房租 ¥40 付不起
core.state.repTenth = 800; // 声誉健康 → 隔离破产路径(不被声誉失败抢跑)
runUntil(core, () => core.state.phase !== 'open', 5);
assert.equal(core.state.phase, 'gameover');
assert.equal(core.state.gameoverReason, 'bankrupt');
});
test('升级:资金扣减 + 前置校验 + 商品解锁 + 收银冷却缩短生效', () => {
const core = newGame(81);
core.state.cash = 3000; // 白盒:给足资金,专测升级语义(开局资金能否买齐由闭环证明管)
assert.equal(core.buyUpgrade('speed2').reason, 'requires', '未买 I 不能买 II');
const cash0 = core.state.cash;
assert.ok(core.buyUpgrade('speed1').ok);
assert.equal(core.state.cash, cash0 - 550);
assert.equal(core.buyUpgrade('speed1').reason, 'owned', '不可重复购买');
assert.equal(core.derived().serveCooldownSec, 0.42, '收银冷却生效');
// 解锁新品后可进货。
assert.ok(core.buyUpgrade('unlock_ice').ok);
assert.ok(core.state.unlocked.includes('ice'));
assert.ok(core.buyStock('ice', 2).ok);
// 资金不足被拒。
core.state.cash = 10;
assert.equal(core.buyUpgrade('decor1').reason, 'no_cash');
});
test('三日一段落:第 3 天日结确认 → 段落小结 → 确认 → 第 4 天 prep', () => {
const core = newGame(91);
for (let day = 1; day <= 3; day++) {
// 每日按「补到 8 件」备货(防缺货流失把声誉拖死,能完整跑满 3 天)。
for (const id of ['soda', 'snack', 'bread']) {
const need = 8 - (core.state.stock[id] || 0);
if (need > 0) core.buyStock(id, need);
}
core.startDay();
runUntil(
core,
() => {
if (core.state.phase !== 'open') return true;
if (core.state.serveCooldown === 0) {
const c = core.state.customers.find(
(x) => (x.mood === 'queue' || x.mood === 'leaving') && (core.state.stock[x.productId] || 0) > 0
);
if (c) core.serve(c.productId);
}
return false;
},
DAY.lengthSec + 10
);
assert.equal(core.state.phase, 'settle', `${day} 天应到日结`);
core.confirmSettle();
if (day < 3) {
assert.equal(core.state.phase, 'prep');
}
}
assert.equal(core.state.phase, 'summary', '第 3 天后应出段落小结');
assert.equal(core.state.summary.segment, 1);
assert.ok(core.confirmSummary().ok);
assert.equal(core.state.phase, 'prep');
assert.equal(core.state.day, 4);
});
test('确定性:同种子同操作 → 事件流逐字段一致;异种子 → 到店节拍不同', () => {
/** 跑固定脚本收事件。@param {number} seed */
function run(seed) {
const core = createShopCore({ random: new SeededRandom(seed) });
core.startGame(null);
core.buyStock('soda', 6);
core.buyStock('snack', 6);
core.setPrice(1.1);
core.startDay();
const events = [];
for (let i = 0; i < 30 * 30; i++) {
core.update(STEP);
if (core.state.serveCooldown === 0) {
const c = core.state.customers.find(
(x) => (x.mood === 'queue' || x.mood === 'leaving') && (core.state.stock[x.productId] || 0) > 0
);
if (c) core.serve(c.productId);
}
events.push(...core.drainEvents());
}
return events;
}
const a = JSON.stringify(run(123));
const b = JSON.stringify(run(123));
const c = JSON.stringify(run(456));
assert.equal(a, b, '同种子完全可复现');
assert.notEqual(a, c, '异种子应产生不同节拍');
});