Skip to content

Commit 11d3281

Browse files
committed
夜铺定稿:通关兑换、深夜来客修复与双版本审计
1 parent 9c5eefd commit 11d3281

13 files changed

Lines changed: 942 additions & 711 deletions

‎cartridges/pocket-night-shop/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
# 口袋夜铺
22

3+
「夜铺之外」解锁后,可用 `exchange_resources {"resource":"stardust","amount":50}` 将星沙或声望换成古币:每粒星沙2古币,每点声望1古币(声望用 `reputation`),不占班次,无每日上限。可将余下资源用于99古币的「货物飞来」。深夜成交或送客后,新客在跨日完成后才入店,不会刚刷新就被打烊送走。
4+
35
第一个结局「第七面墙」解锁后可用「货物飞来」:`summon_goods {"code":"buttonjar"}`。每次99枚古币,指定召来一件已发现旧物,不占班次;需要货架空位。已揭秘的同种旧物到货即修满,未揭秘的仍需修复。可以反复使用,无每日次数上限。
46

57
第二结局「夜铺之外」解锁后,今夜来客与顾客簿会显示来客对应的指定旧物,提示修满后交付以完成告别故事。此功能也同步到盲玩版的来客文字;通关前保持隐藏,不恢复已用完的到访次数。
68

9+
每位来客最多到访8次;第三结局是可能错过的额外挑战,错过也不影响前两个结局。有配对旧物的48位普通客人保留对应线索;无配对的14位普通客人不显示线索,直接说明收货类别。
10+
711
一位AI自主经营旧物夜铺,淘货、修复、接待来客并收集图鉴。无需荷官、模型Key或人类逐轮点击。人类可通过掌机查看逐轮经营记录。
812

913
街巷见闻共48种,包含经营选择、氛围故事、经营提示与轻微挫折。同一见闻不会连续抽中;每班触发概率随店铺成长从一级25%、二级约16.7%降到三级及以上12.5%。新增负面见闻最多扣2枚古币或1点声望,余额最低为0,不损坏藏品。
@@ -14,7 +18,7 @@
1418

1519
没有显示器的版本:单独下载 [口袋夜铺_单机盲玩版/pocket_night_shop.py](../../口袋夜铺_单机盲玩版/pocket_night_shop.py),交给有Python代码执行能力的AI即可。它是纯文字盲玩单机版,不运行游戏机或MCP,与掌机存档独立。
1620

17-
收藏里程碑分两段:发现全部72种旧物时解锁一次文字过场和永久纪念物;全部72种旧物的修复秘密、86位来客图鉴都收齐后解锁真正通关过场。按永久图鉴记录判断,出售或回收旧物不影响进度,每位来客认识一次即可。奖励不占库存,不改经营数值,通关后仍可经营与续档。
21+
收藏里程碑分两段:发现全部72种旧物时解锁一次文字过场和永久纪念物;72种旧物全部修满揭秘后解锁「夜铺之外」,无需认识全部来客。按永久图鉴记录判断,出售或回收旧物不影响进度。奖励不占库存,不改经营数值,通关后仍可经营与续档。
1822

1923
还有第三个结局「万灯送行」(大满贯,文案出自 Sol):前两个结局都已达成,并且 72 位「等它的人」全部收到了修满的那一件、说完了自己的那句话。达成后店铺收起,这一局结束:掌机版的纪念卡变成结档卡(三座奖杯、送行文案、店主手记、最后一句话、闭店时货架上的旧物),盲玩版返回同样内容的文字记录。
2024

‎cartridges/pocket-night-shop/rules.py‎

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,23 @@ def _summon_goods(s, params):
734734
+ (" 这种旧物已经揭秘,到货即修满。" if item.get("pre_repaired") else ""), before)
735735

736736

737+
def _exchange_resources(s, params):
738+
if not any(e["id"] == "true_ending" for e in s.get("endings", [])):
739+
raise IllegalAction("夜铺之外解锁后才能兑换星沙和声望")
740+
resource, amount = params.get("resource"), params.get("amount")
741+
rates = {"stardust": ("星沙", 2), "reputation": ("声望", 1)}
742+
if not isinstance(resource, str) or resource not in rates or type(amount) is not int or amount < 1:
743+
raise IllegalAction("resource 填 stardust 或 reputation,amount 填正整数")
744+
label, rate = rates[resource]
745+
if amount > s.get(resource, 0):
746+
raise IllegalAction(f"{label}不足,未兑换")
747+
before = (s["coins"], s["reputation"])
748+
s[resource] -= amount
749+
s["coins"] += amount * rate
750+
_log(s, s["day"], SHIFTS[s["shift_index"]], "exchange_resources",
751+
f"把{amount}{'粒' if resource == 'stardust' else '点'}{label}换成{amount * rate}枚古币,不占班次。", before)
752+
753+
737754
def _buy_sand(s, params):
738755
if not _buy_sand_open(s):
739756
raise IllegalAction("收齐全部旧物图鉴(第七面墙)以后,才能用古币换星沙")
@@ -979,7 +996,7 @@ def _collect(s, item):
979996
]
980997
TRUE_ENDING_SCENE = [
981998
"门铃响了。魔女莉莉丝站在门口,手里没有货。",
982-
"她走过你的柜台,指尖轻轻拂过异物志。那些曾经修好的东西,有些早已被客人带走;可这一晚,你听见它们的声音从很远的地方回来。每一位来客,都在顾客簿上留下了自己的名字。",
999+
"她走过你的柜台,指尖轻轻拂过异物志。那些曾经修好的东西,有些早已被客人带走;可这一晚,你听见它们的声音从很远的地方回来。顾客簿上的名字,在灯下泛起温暖的光。",
9831000
"『我第一次来时,这里还是一间小铺子。』她看着你,笑了一下。『现在,迷路的东西知道该往哪里去了。』",
9841001
"她推开货架尽头那扇门。门后没有墙。一条幽蓝的隧道伸向深处,远处亮着许多窗。有一扇窗后,似乎也有人正在整理柜台。",
9851002
"莉莉丝把钥匙留在你手里。『恭喜,店主。你可以过去看看。』她替你扶住门,又回头看了一眼还亮着灯的铺子。『也可以先把今晚的生意做完。』",
@@ -1063,11 +1080,10 @@ def _check_endings(s):
10631080
codes = [row[0] for row in ITEMS] # ITEMS 是元组列表,按编号比
10641081
discovered = all(code in s["collection"] for code in codes)
10651082
revealed = discovered and all(s["collection"][code]["revealed"] for code in codes)
1066-
guests = all(customer[0] in s["customer_book"] for customer in CUSTOMERS)
10671083
unlocked = False
10681084
for key, ready, title, reward, pages in (
10691085
("collection", discovered, "第七面墙", "没有背面的店主铭牌", COLLECTION_SCENE),
1070-
("true_ending", revealed and guests, "夜铺之外", "通往亚空间的钥匙", TRUE_ENDING_SCENE),
1086+
("true_ending", revealed, "夜铺之外", "通往亚空间的钥匙", TRUE_ENDING_SCENE),
10711087
("grand_slam", None, "万灯送行", GRAND_SLAM_REWARD, GRAND_SLAM_SCENE),
10721088
):
10731089
if key == "grand_slam": # 前两座奖杯都到手(可能就在这一步),七十二句话都说出口
@@ -1127,6 +1143,10 @@ def new_game(seats, seed):
11271143

11281144

11291145
def _spawn_customer(s):
1146+
if s["shift_index"] == LAST_SHIFT:
1147+
# 深夜行动之后已经没有可接待的班次,下一位等跨日后再入店、计到访。
1148+
s["customer"] = None
1149+
return
11301150
pool = [c for c in CUSTOMERS
11311151
if s["customer_book"].get(c[0], {}).get("visits", 0) < MAX_CUSTOMER_VISITS
11321152
and not s["customer_book"].get(c[0], {}).get("farewell")
@@ -1145,7 +1165,7 @@ def _spawn_customer(s):
11451165
"visits": 0, "deals": 0, "trust": 0,
11461166
"neighbor": set(s["unlocked_locations"]) <= NEIGHBOR_LOCATIONS})
11471167
row["visits"] += 1
1148-
if (row.get("neighbor", True) or cid in SPECIAL_CUSTOMERS
1168+
if (clue is None or row.get("neighbor", True) or cid in SPECIAL_CUSTOMERS
11491169
or row["visits"] >= REGULAR_VISITS):
11501170
row["request"] = request
11511171
request = row["request"]
@@ -1166,9 +1186,9 @@ def _public_item(item):
11661186
return view
11671187

11681188

1169-
CUSTOMER_HINT = ("新手提示:客人只认旧物的「类别」(每件旧物都标着,比如陪伴、照明),不看名字和故事像不像。"
1170-
"从客人说的要求猜他要哪一两类;远方来的生客不直说要什么,只能从线索猜,来过四次成了熟客才会直说;"
1171-
"本局开头就来过的老街坊一直直说。类别对上才会买;手上没有对的类别,就送客(dismiss,不另收费),等下一位客人。")
1189+
CUSTOMER_HINT = ("经营提示:普通客人按旧物类别购买;线索暗示的是能触发个人故事的那一件,修满后交付有额外回馈。"
1190+
"无对应旧物的客人直接说明收货类别;有线索的远方生客第五次起直说类别,老街坊一直直说。"
1191+
"特殊客人只收点名的那一件。库存缺合适的货可主动淘货;送客会消耗一次有限到访机会。")
11721192

11731193

11741194
def _is_open(s):
@@ -1185,11 +1205,13 @@ def _waiting_item(s, cid):
11851205

11861206
def _customer_view(row, cid):
11871207
view = {key: deepcopy(value) for key, value in row.items() if not key.startswith("_")}
1188-
clue = next(c[3] for c in CUSTOMERS if c[0] == cid)
1208+
template = next(c for c in CUSTOMERS if c[0] == cid)
1209+
clue = template[3]
11891210
if clue:
11901211
view["clue"] = clue
11911212
else:
11921213
view.pop("clue", None)
1214+
view["request"] = template[2]
11931215
return view
11941216

11951217

@@ -1278,6 +1300,9 @@ def _legal_actions(s):
12781300
if _buy_sand_open(s):
12791301
actions.append({"action_id": "summon_goods", "params": {
12801302
"code": "图鉴collection.entries中的code;99枚古币指定召来一件旧物,不占班次,需要货架空位"}})
1303+
if any(e["id"] == "true_ending" for e in s.get("endings", [])):
1304+
actions.append({"action_id": "exchange_resources", "params": {
1305+
"resource": ["stardust", "reputation"], "amount": "正整数;每粒星沙换2古币,每点声望换1古币,不占班次"}})
12811306
if s.get("apple_pending"):
12821307
actions.append({"action_id": "golden_apple", "params": {"choice": _apple_text(s)}})
12831308
if s.get("scroll"):
@@ -1324,6 +1349,8 @@ def observe(full_state, seat_id):
13241349
for cid, row in s["customer_book"].items()],
13251350
**({"summon_goods": "货物飞来已解锁:summon_goods {\"code\":\"图鉴编号\"},99古币指定召货,不占班次;需要货架空位"}
13261351
if _buy_sand_open(s) else {}),
1352+
**({"exchange_resources": "通关兑换:exchange_resources {\"resource\":\"stardust或reputation\",\"amount\":数量};1星沙换2古币,1声望换1古币,不占班次"}
1353+
if any(e["id"] == "true_ending" for e in s.get("endings", [])) else {}),
13271354
"customer_collection": {"found": len(s["customer_book"]), "total": len(CUSTOMERS),
13281355
"visit_limit": MAX_CUSTOMER_VISITS,
13291356
"retired": sum(row["visits"] >= MAX_CUSTOMER_VISITS
@@ -1551,6 +1578,8 @@ def apply(full_state, seat_id, action_id, params):
15511578
_buy_sand(s, params)
15521579
elif action_id == "summon_goods":
15531580
_summon_goods(s, params)
1581+
elif action_id == "exchange_resources":
1582+
_exchange_resources(s, params)
15541583
elif action_id == "golden_apple":
15551584
_golden_apple(s, params)
15561585
elif action_id == "sand_repair":
@@ -1712,6 +1741,8 @@ def _work_shift(s, params):
17121741
elif c and c.setdefault("_arrived_day", s["day"] - 1) + CUSTOMER_WAIT_DAYS <= s["day"]:
17131742
report += f" {c['name']}等了三天,没等到合适的东西,走了。"
17141743
_spawn_customer(s)
1744+
elif c is None:
1745+
_spawn_customer(s)
17151746
report += " " + _day_summary(s, day)
17161747
if dream: # 梦单独起一行放在最后,别埋在打烊流水里
17171748
report += "\n【夜里的梦】" + dream + (" (用 map 看看新出现的地方。)" if s["dreams"] >= 3 else "")
@@ -1921,7 +1952,9 @@ def _do_focus(s, params, flags):
19211952
if focus == "dismiss":
19221953
name = s["customer"]["name"]
19231954
_spawn_customer(s)
1924-
return f"向{name}说明暂时无货,记下需求送客。" + ("另一位客人进门。" if s["customer"] else "眼下没有新的来客,店铺仍可经营。")
1955+
return f"向{name}说明暂时无货,记下需求送客。" + (
1956+
"深夜将尽,下一位客人待打烊后再安排。" if s["shift_index"] == LAST_SHIFT else
1957+
"另一位客人进门。" if s["customer"] else "眼下没有新的来客,店铺仍可经营。")
19251958
if focus == "curate":
19261959
item["displayed"] = True
19271960
s["reputation"] += 1

‎console/web/app.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ function nightJournalEntry(entry) {
483483
dismiss:"送客",recycle:"回收零件",curate:"布置橱窗",upgrade:"扩建铺子",
484484
choose_event:"处理街巷事件",unlock_location:"开辟淘货路线",merge:"拆件修理",sand_repair:"星沙修满",
485485
teleport:"使用传送卷轴",witch_sell:"卖给魔女",witch_buy:"向魔女买货",buy_slot:"声望换货架",
486-
start_funds:"掷启动资金",call_human:"人类注资救店",summon_goods:"货物飞来",ending:"故事过场"}[entry.focus] || entry.focus;
486+
start_funds:"掷启动资金",call_human:"人类注资救店",summon_goods:"货物飞来",exchange_resources:"通关兑换",ending:"故事过场"}[entry.focus] || entry.focus;
487487
const customer = (c, label) => c ? `<p><strong>${label} · ${esc(c.name)}</strong>
488488
(第${esc(c.visits)}次来店)<br>想找:${esc(c.request ?? "(没开口,只能看线索猜)")}<br>
489489
${c.clue ? `<span class="dim">可观察到:${esc(c.clue)}</span>` : ""}</p>` : "";
@@ -658,6 +658,7 @@ const CART_UI = {
658658
<div class="dim">由 AI 决定,观看者无需操作。</div></div>` : ""}
659659
${rollFunds}${rescue}${customer}
660660
${ps.summon_goods ? `<div class="night-customer"><strong>✨ 货物飞来</strong><p>${esc(ps.summon_goods)}</p></div>` : ""}
661+
${ps.exchange_resources ? `<div class="night-customer"><strong>通关兑换</strong><p>${esc(ps.exchange_resources)}</p></div>` : ""}
661662
${ps.scroll ? `<div class="night-customer"><strong>限时传送卷轴</strong><p>${esc(ps.scroll)}</p><div class="dim">由店主自行决定是否使用,观看者无需操作。</div></div>` : ""}
662663
${ps.witch ? `<details class="night-history" data-panel="witch"><summary>魔女莉莉丝的摊位 · 本次还能卖${ps.witch.can_sell}件</summary>
663664
<p>${esc(ps.witch.hint)}</p>${(ps.witch.stock || []).map(item => `<div class="night-log"><strong>${esc(item.name)}</strong>

‎docs/night-shop-final-audit.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# 口袋夜铺定稿审计 · 2026-10-13
2+
3+
范围:掌机卡带与单文件盲玩版;不包含尚未发布的联机卡带修改。
4+
5+
## 验收结果
6+
7+
- 待发布索引生成的独立副本:286项Python测试、22项界面测试通过。
8+
- 夜铺专项:117项测试通过,包含8个种子各120步的资源、库存、来客上限和日志序号检查。
9+
- 单文件生成一致性通过;相同规则与种子两版结果一致,通关技能、歇业、续档和重新加载已对照验证。
10+
- 新旧存档边界、无效输入回滚、图鉴保留、三个结局与闭店记录已覆盖。本次使用虚拟存档,无真实模型调用。
11+
12+
## 本轮修正与确认
13+
14+
- 第二结局「夜铺之外」仅要求72种旧物全部修满揭秘,不要求86位来客全部认识;相应说明和过场文案一致。
15+
- 深夜成交、送客、报价失败离场后,下一位客人跨日后才入店和计到访,不再未经接待便被打烊送走。
16+
- 第二结局后可将星沙、声望兑换古币;1星沙换2古币,1声望换1古币,不推进时间。星沙购入价为3古币,往返兑换不能增值。
17+
- 无配对物品的14位普通客人直接说明收货类别;旧档展示同样适用。有配对的48位普通客人保留物品线索。
18+
- 盲玩简报明确报告客人离店后当前无人,避免沿用上一位客人的状态。
19+
20+
## 保留的玩法边界
21+
22+
每位客人最多到访8次,第三结局「万灯送行」是可能错过的额外挑战;不会自动恢复耗尽的到访次数。错过大满贯不妨碍前两个结局。真实模型仍可能选错动作,本次审计不保证玩家必定取得全部成就,也不声称消除了所有潜在问题。
23+
24+
通关门槛和技能沿用现有0.3存档格式;已有局在下一次成功经营行动或歇业、续档时补发符合条件的结局。运行中的服务需要另行重启才会加载新规则,本次发布不改写真实存档。

‎tests/test_night_shop_audit.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Regression fixtures for the updated night-shop rules; never uses player saves."""
22
import importlib.util
33
import json
4+
import random
45
from copy import deepcopy
56
from pathlib import Path
67
import tempfile
@@ -74,6 +75,40 @@ def test_invalid_inputs_leave_state_unchanged(self):
7475
rules.apply(s, "seat_a", action, params)
7576
self.assertEqual(s, before)
7677

78+
def test_seeded_long_runs_preserve_resources_inventory_and_guest_limits(self):
79+
for seed in range(8):
80+
s = rules.new_game(["seat_a"], seed)
81+
s = rules.apply(s, "seat_a", "roll_start_funds", {"human_words": "synthetic launch"})
82+
chooser = random.Random(seed)
83+
for _ in range(120):
84+
if s["phase"] == "finished":
85+
break
86+
if s["pending_event"]:
87+
choice = next(k for k, v in rules.EVENTS[s["pending_event"]["kind"]]["choices"].items() if v["cost"] == 0)
88+
action, params = "choose_event", {"event_id": s["pending_event"]["id"], "choice": choice}
89+
else:
90+
focus = chooser.choice(["scavenge", "tidy", "recycle", "repair", "dismiss"])
91+
if focus in ("recycle", "repair") and not s["inventory"] or focus == "dismiss" and not rules._public_customer(s):
92+
focus = "tidy"
93+
params = {"focus": focus}
94+
if focus in ("recycle", "repair"):
95+
params["item_id"] = chooser.choice(s["inventory"])["id"]
96+
action = "work_shift"
97+
before = deepcopy(s)
98+
try:
99+
new = rules.apply(s, "seat_a", action, params)
100+
except rules.IllegalAction:
101+
new = rules.apply(s, "seat_a", "work_shift", {"focus": "tidy"})
102+
self.assertEqual(s, before)
103+
s = new
104+
self.assertGreaterEqual(s["coins"], 0)
105+
self.assertGreaterEqual(s["reputation"], 0)
106+
self.assertGreaterEqual(s.get("stardust", 0), 0)
107+
self.assertLessEqual(len(s["inventory"]), rules._capacity(s))
108+
self.assertEqual(len({i["id"] for i in s["inventory"]}), len(s["inventory"]))
109+
self.assertTrue(all(row["visits"] <= rules.MAX_CUSTOMER_VISITS for row in s["customer_book"].values()))
110+
self.assertEqual([h["seq"] for h in s["history"]], list(range(1, len(s["history"]) + 1)))
111+
77112
def test_bankrupt_shop_waits_for_no_further_player_action(self):
78113
s = self.state()
79114
s["bankrupt"] = True

‎tests/test_night_shop_endings.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,12 @@ def test_collection_reward_is_once_and_does_not_change_economy(self):
3535
self.assertFalse(rules._check_endings(self.state))
3636
self.assertEqual(len(self.state["endings"]), 1)
3737

38-
def test_true_ending_requires_every_secret_and_guest_not_inventory(self):
39-
self.fill(secrets=True, guests=True)
40-
missing = self.state["customer_book"].pop(rules.CUSTOMERS[-1][0])
38+
def test_true_ending_requires_every_secret_not_guests_or_inventory(self):
39+
self.fill(secrets=True)
40+
self.state["customer_book"] = {}
41+
self.state["collection"][rules.ITEMS[0][0]]["revealed"] = None
4142
rules._check_endings(self.state)
4243
self.assertEqual(len(self.state["endings"]), 1)
43-
self.state["customer_book"][rules.CUSTOMERS[-1][0]] = missing
44-
self.state["collection"][rules.ITEMS[0][0]]["revealed"] = None
4544
self.assertFalse(rules._check_endings(self.state))
4645
self.state["collection"][rules.ITEMS[0][0]]["revealed"] = "repaired"
4746
self.state["inventory"] = []
@@ -51,7 +50,8 @@ def test_true_ending_requires_every_secret_and_guest_not_inventory(self):
5150
self.assertFalse(rules._check_endings(self.state))
5251

5352
def test_old_save_catches_up_on_reopen_and_persists(self):
54-
self.fill(secrets=True, guests=True)
53+
self.fill(secrets=True)
54+
self.state["customer_book"] = {}
5555
self.state.pop("endings")
5656
self.state["resting"] = True
5757
restored = rules.apply(self.state, "seat_a", "reopen_shop", {})

0 commit comments

Comments
 (0)