refactor(runner拆件): 令牌池与商店族拆出两件+断点字段登记单源(零行为变更)

- curio_deck.gd(新增):令牌多重集/抽签/cap/凭证三计数与判定唯一宿主,随机经注入 rg 面;宿主公开面委托保持原签名
- shop_session.gd(新增):双模式货架/定价/购买/刷新入件,钱包原语留宿主经回调落账
- adventure_runner:BREAKPOINT_FIELDS 27 键登记单源驱动写侧拼装与两处读侧回退;续玩段拆为六小函数
- level_runner:商店族 21 函数转委托层,9 个成员改转发属性(类变量计数保持 38)
- breakpoint_resume_test:新增 P8 键集==写侧实拼键集与策略标签词表断言〔wr2〕
This commit is contained in:
DKChan committed 2026-10-25 07:32:20 +08:00
1 parent 5c9c5f8083
commit e5ee4d04f0
8 files changed
+925 -504

No files matched your search

+79 -221
View File
@@ -1,5 +1,5 @@
## VERTICAL SLICE - NOT FOR PRODUCTION
## 关卡域生命周期编排者:节点图行进、HP 跨战斗携带、商店房与恢复点兑付、关卡末收束(垂直切片验证:一关内数值提升可感知)。
## 关卡域生命周期编排者:节点图行进、HP 跨战斗携带、恢复点兑付、关卡末收束(垂直切片验证:一关内数值提升可感知);商店族归 ShopSession 件。
## Core 层零 UI 依赖(headless 可跑完整关卡);零自动推进——兑付确认后经 choices_presented,推进只走 pick_next_node。
## 种子域:单关模式 start_level 自播种;冒险模式 start_level_external 不重播种(主种子恰一次归 AdventureRunner)。
## hero_atk_pct/hero_hp_pct 为测试专用覆盖——经 encounter 显式传入战斗,零表写,db.hero 运行期只读。
@@ -34,7 +34,7 @@ var gp: int: # GP 冒险钱包(局内货币—
get: return int(_ledger["gp"])
set(v): _ledger["gp"] = v
var base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat 注入(元进度实现批——冒险模式 = AdventureRunner 共享字典经 start_level_external 注入;单关测试模式 = 私有全零)
var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零)
var _shop: ShopSession = null # 商店件(_init 建;双模式货架/定价/购买/刷新唯一宿主)
# 协作对象私有(E1/E3 封装收口):settlement 读取经 current_battle() 成文出口
var _settlement: BattleOrchestrator = null # 当前战斗编排器(关卡结束置 null)
var treasure_inventory: TreasureItems = null # 秘宝 关卡域库存(TreasureItems;跨战斗延续,
@@ -46,14 +46,24 @@ var _attunement: Array[Variant] = [] # 同调装配集(adve
var _hero_attack_type := ""
var _hero_armor_type := ""
# ---- 商店共享池模式态(缺省 legacy=固定三件加定价 off 对照承载,零 RNG 零池消费;TP0 令牌池)----
var _shop_pool_mode := false # pool 模式封印(set_shop_pool 注入时置位——AdventureRunner 按 params.shop_mode 下传)
var _pool_deck: Array[Variant] = [] # 共享池令牌库共享引用(AdventureRunner._curio_deck——库存=剩余令牌数单点)
var _pool_consume := Callable() # 池消费 handler(AdventureRunner._consume_pool_purchase——_apply_curio_pick 同口写加算区+账本+摘牌)
var _pool_ticket := Callable() # 专长凭证展示判定 handler(T5——AdventureRunner._feat_ticket_consider;节点与商店共享计数)
var _pool_cap_check := Callable() # cap 检查回调(AdventureRunner._curio_cap_ok;cap 满剔出货架候选视图)
var _shop_shelf: Array[Variant] = [] # 当前货架(进店抽 3/买后全重抽——令牌字典 {"kind","tier"} 序,同 kind 去重每 kind 至多一卡)
var _shop_visit_buys := 0 # 本次进店已购次数(A 链定价输入——离开清零)
var _shop_paid_refreshes := 0 # 本次进店已付费刷新次数(MC-1——GP 刷新价曲线输入,离开清零)
# 件内状态白盒读面(断点写侧与既有取数面沿用原字段名;状态本体单点在 ShopSession)
var _shop_pool_mode: bool:
get: return _shop.pool_mode
var _pool_deck: Array[Variant]:
get: return _shop.pool_deck
var _pool_consume: Callable:
get: return _shop.pool_consume
var _pool_ticket: Callable:
get: return _shop.pool_ticket
var _shop_shelf: Array[Variant]:
get: return _shop.shelf
var _shop_visit_buys: int:
get: return _shop.visit_buys
var _shop_paid_refreshes: int:
get: return _shop.paid_refreshes
var _shop_purchased: Dictionary[Variant, Variant]: # product_id → 已购档数(现价重算输入;冒险开始清零)
get: return _shop.purchased
set(v): _shop.purchased = v
var hero_atk_pct := 0 # 测试面板覆盖值(切片专用)
var hero_hp_pct := 0
@@ -70,6 +80,8 @@ var _generator # 参数化长图生成器(惰性建——NodeMapGenerator
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
db = db_ref
rng = rng_ref
_shop = ShopSession.new(db, rng)
_bind_shop()
# 进行中谓词(RUNNING 或 AWAITING_CHOICE 两相位合计;内部守卫专用)
@@ -150,6 +162,7 @@ func start_level(level_index: int, seed_val: int, base_override_in: Dictionary =
run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0(单关入口零元进度——注入面见上)
base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上)
_shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计)
_bind_shop() # 钱包引用换代重绑(_ledger 已重建)
for k in base_override.keys():
base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面)
hero_hp = effective_max_hp()
@@ -189,6 +202,7 @@ func start_level_external(level_index: int, ledger: Dictionary,
_ledger = ledger
base_override = base_override_ref
_shop_purchased = purchased_ref
_bind_shop() # 钱包共享引用换代重绑(_ledger = 冒险账本)
_rest_handler = rest_handler
if not _assign_generated_graph(row, rng.master_seed):
return false
@@ -472,7 +486,7 @@ func _enter_node(node: Dictionary) -> bool:
# 商店房兑付:到达即开店(覆盖层归呈现层),购买出账/
# 离开归玩家意图——零自动推进;离开确认经 continue_after_node 回选路口
if _shop_pool_mode:
_shop_open_pool() # 进店抽 3(恰一 shop_draw scope;买后全重抽同签)
_shop.open_pool() # 进店抽 3(恰一 shop_draw scope;买后全重抽同签)
node_entered.emit(node, {"gp": gp, "shelf": true})
return true
node_entered.emit(node, {"gp": gp}) # legacy 固定三件(off 对照承载——零 RNG 零池消费)
@@ -629,6 +643,7 @@ func set_treasure_unlocked(ids: Dictionary) -> void:
# 同调装配集注入(冒险开始参数面;AdventureRunner 唯一调用方)
func set_attunement(ids: Array) -> void:
_attunement = ids.duplicate()
_bind_shop() # 装配集共享引用换代重绑(刷新执照读数面)
# 特性持有集注入(AdventureRunner._start_current_level 唯一调用方;begin_battle
@@ -652,6 +667,7 @@ var _tree_rules: Array[Variant] = []
func set_tree_rules(rules: Array) -> void:
_tree_rules = rules.duplicate()
_bind_shop() # rule 键集共享引用换代重绑(散折价判定面)
# 两根类型轴注入(adventure 参数面快照;空串=未注入→锻造配置默认值回退。
@@ -669,6 +685,7 @@ var run_ledger: RunLedger = RunLedger.new()
func set_run_ledger(l: RunLedger) -> void:
run_ledger = l
_bind_shop() # 账类换代重绑(加算区 flat 落点面)
# 凭证件三账注入(储备金袋 RAP/时沙 首轮 AP/完美保险 标错豁免;节点级一次性,
@@ -713,9 +730,7 @@ func continue_after_node() -> bool:
if t != "reward" and t != "rest" and t != "shop":
return false
if t == "shop":
_shop_shelf = [] # 离开店面货架态清零(断点快照 shop_shelf 键随离开落空架合法态)
_shop_visit_buys = 0
_shop_paid_refreshes = 0 # MC-1
_shop.leave() # 离开店面货架态清零(断点快照 shop_shelf 键随离开落空架合法态)
return _present_choices()
@@ -735,265 +750,108 @@ func close_wallet() -> void:
# ---- 商店房兑付(GP+商店房实施批:呈现层只读投影 + 购买出账)----
# 实装归 ShopSession 件(双模式货架/定价/购买/刷新唯一宿主);本类保留同签名委托与件注入/
# 白盒入口——外部调用面与断点取数面零变化。
# 件注入(建件与宿主引用换代时调用——钱包/账类/图行/装配集为共享引用,出账直接落同一引用)
func _bind_shop() -> void:
_shop.bind({
"wallet": _ledger,
"run_ledger": run_ledger,
"level_row": level_row,
"tree_rules": _tree_rules,
"attunement": _attunement,
}, {
"wallet_emit": Callable(self, "_emit_wallet_event"),
"max_hp_gain": Callable(self, "_gain_max_hp_flat"),
"battle": Callable(self, "current_battle"),
})
func _emit_wallet_event(event: String, gp_now: int, extra: Dictionary) -> void:
wallet_changed.emit(event, gp_now, extra)
# 购血(商店 max_hp 商品——当前生命 +Δ 并封顶新上限,与账本置满/通关回血同式)
func _gain_max_hp_flat(flat: int) -> void:
hero_hp = mini(hero_hp + flat, effective_max_hp())
# 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式)
func shop_products() -> Array:
var out: Array[Dictionary] = []
for prow in db.shop_products():
out.append({
"product_id": String(prow.get("product_id", "")),
"display_name": String(prow.get("display_name", "")),
"attr": String(prow.get("attr", "")),
"flat_value": int(prow.get("flat_value", 0)),
"price": current_price(prow),
"purchased": purchased_tier(String(prow.get("product_id", ""))),
})
return out
return _shop.shop_products()
# 现价 = base_price + price_level_bonus(level_index) + price_increment × 该商品已购档数
# (散折价:economy_spend 激活时商品标价 ×80%——见 _spend_discounted)
func current_price(prow: Dictionary) -> int:
return _spend_discounted(int(prow.get("base_price", 0)) + db.shop_price_level_bonus(int(level_row.level_index)) \
+ int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", ""))))
# 散折价单点(财富之道·散 economy_spend 激活时商品购买价 ×spend_discount〔表 knob,缺省 0.8〕,
# floor 取整偏向玩家、下限 1 防 1 元商品折成 0;范围=商品标价定义边界〔legacy 商品面 current_price
# + 池货架面 pool_price 同折〕——商店刷新价 shop_refresh_cost 与三选一付费刷新不折)
func _spend_discounted(price: int) -> int:
if _tree_rules.has("economy_spend"):
return maxi(1, int(floor(float(price) * float(db.shop.spend_discount))))
return price
return _shop.current_price(prow)
func purchased_tier(product_id: String) -> int:
return int(_shop_purchased.get(product_id, 0))
return _shop.purchased_tier(product_id)
# 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用):
# 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1
func buy_product(product_id: String) -> bool:
var prow: Dictionary[Variant, Variant] = db.shop_product_row(product_id)
if prow.is_empty():
push_error("[关卡节点地图] 商店房未知商品: %s" % product_id)
return false
var attr := String(prow.get("attr", ""))
if not run_ledger.bonus.has(attr):
push_error("[关卡节点地图] 商品属性键出域: %s(attr=%s)" % [product_id, attr])
return false
var price := current_price(prow)
if gp < price:
push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [product_id, price, gp])
return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝+静默留痕)
gp -= price
var flat := int(prow.get("flat_value", 0))
run_ledger.bonus[attr] = int(run_ledger.bonus[attr]) + flat
if attr == "max_hp":
hero_hp = mini(hero_hp + flat, effective_max_hp()) # 购血:上限与当前生命同步 +Δ(封顶新上限)
_shop_purchased[product_id] = purchased_tier(product_id) + 1
wallet_changed.emit("buy", gp, {"product": product_id, "price": price})
return true
return _shop.buy_product(product_id)
func player_request_refresh() -> bool:
if _settlement == null:
return false
return _settlement.player_request_refresh()
return _shop.player_request_refresh()
# ---- 商店共享池模式(legacy 固定三件路径保留为 off 对照承载)----
# 散折价单点(测试取数面沿用本类调用名)
func _spend_discounted(price: int) -> int:
return _shop._spend_discounted(price)
# 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 +
# 池消费 handler。legacy 模式不注入(_shop_pool_mode 恒 false——off 态零 RNG 零池消费零扰动)。
# cap 检查回调(cap 满 kind 剔出货架候选视图)。
# 池消费 handler + 专长凭证判定 + cap 检查回调(cap 满 kind 剔出货架候选视图)。
func set_shop_pool(deck_ref: Array, consume_handler: Callable, ticket_consider: Callable = Callable(), cap_check: Callable = Callable()) -> void:
_pool_deck = deck_ref
_pool_consume = consume_handler
_pool_ticket = ticket_consider # T5——专长凭证展示判定(AdventureRunner._feat_ticket_consider;同流取数复用 shop_draw scope)
_pool_cap_check = cap_check
_shop_pool_mode = true
_shop.set_shop_pool(deck_ref, consume_handler, ticket_consider, cap_check)
# 进店开店(_enter_node shop 分支 pool 模式):进店购买次计数清零 + 抽 3 落架(恰一 shop_draw scope)
func _shop_open_pool() -> void:
_shop_visit_buys = 0
_shop_paid_refreshes = 0 # MC-1——进店付费刷新计数清零
_shop_draw_shelf()
# 货架抽签(进店 + 每次买后全重抽共用;TP0 令牌级货架):牌库 = 令牌库中货架词表内 kind 的剩余
# 令牌(库存=剩余令牌数——池消耗即上限,与奖励三选一兑付摘牌同库承载);部分 Fisher-Yates 等概率
# 洗牌不放回(逐枚等概率——kind 出现率∝剩余令牌数,无独立权重 knob;同 kind 去重每 kind 至多一卡,
# 购买身份 kind 单键无歧义);消费 shop_draw 封闭流恰一 scope(每次抽 3 恰一取号)。牌库空 = 空货架
# 合法态(池空硬闸——买光即枯竭既定后果)。
# 货架抽签白盒入口(测试取数面沿用本类调用名)
func _shop_draw_shelf() -> void:
_shop_shelf = []
var sellable: Array[Variant] = []
for row in db.shop_pool_rows():
var shelf_kind := String(row.get("kind_id", ""))
if _pool_cap_check.is_valid() and not bool(_pool_cap_check.call(shelf_kind, "")):
continue # cap 满剔除(货架候选视图剔除;牌库本体不动)
for t in _pool_deck:
var tok: Dictionary[Variant, Variant] = t as Dictionary
if String(tok.get("kind", "")) == shelf_kind:
sellable.append({"kind": shelf_kind, "tier": String(tok.get("tier", ""))})
if sellable.is_empty():
return
var stream: RandomNumberGenerator = rng.open_stream("shop_draw")
for i in sellable.size():
var j := stream.randi_range(i, sellable.size() - 1)
var tmp = sellable[i]
sellable[i] = sellable[j]
sellable[j] = tmp
var seen: Array[Variant] = []
for tok in sellable:
var k := String((tok as Dictionary).get("kind", ""))
if seen.has(k):
continue
seen.append(k)
_shop_shelf.append(tok)
if _shop_shelf.size() >= 3:
break
# T5 专长凭证注入(非 Boss 渠道唯一专长入口——货架与节点共享保底计数;判定含 pity 记账,替换末位占一席。
# 取数复用本函数 shop_draw scope 的 stream——零新 scope,确定性序列不漂移)
if _pool_ticket.is_valid() and not _shop_shelf.is_empty() and bool(_pool_ticket.call(stream)):
_shop_shelf[_shop_shelf.size() - 1] = {"kind": "feat_ticket", "tier": "excellent"} # 专长凭证占位档名 = excellent
# 商店渠道无专长直接投放(渠道白名单:商店渠道只出属性值提升+资源,专长在非 Boss
# 渠道只以专长凭证形态出现——奇物.md v2.7/A4 断言;专长商店入口=T5 专长凭证)。
_shop._draw_shelf()
# 库存余量(TP0——令牌库内该 kind 剩余枚数单点读;未注入/防御读 = 0)
func pool_stock(kind: String) -> int:
var n := 0
for t in _pool_deck:
if String((t as Dictionary).get("kind", "")) == kind:
n += 1
return n
return _shop.pool_stock(kind)
# 现价 = ceil(基价 × b^(关序−1) × A^(本次进店已购次数))(P4 甲 ceil 向上单一取整——玩家不利向;
# A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5)。relic 卡(保底占位——shop 词表外)
# 基价 provisional 15(表 knob relic_base_price——shop.tres 词表扩列归数值批)。
func pool_price(kind: String) -> int:
var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind)
if row.is_empty():
if kind == "relic":
return _spend_discounted(int(ceil(float(db.shop.relic_base_price) * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) * pow(db.shop_price_a(), float(_shop_visit_buys)))))
return 999999
var base := float(maxi(1, int(row.get("base_price", 1))))
var price_f: float = base * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) \
* pow(db.shop_price_a(), float(_shop_visit_buys))
return _spend_discounted(int(ceil(price_f)))
return _shop.pool_price(kind)
# 货架只读投影(呈现层数据源——kind/显示名/件值/现价/库存余量齐备,UI 零复算)。
# 件值=在架令牌本体值(TP0——令牌级货架,kind+tier 定值;价格公式 tier-blind〔不随档位变〕)。
func shop_shelf() -> Array:
var out: Array[Dictionary] = []
for tok in _shop_shelf:
var kind := String((tok as Dictionary).get("kind", ""))
var tier := String((tok as Dictionary).get("tier", ""))
var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind)
var display := String(row.get("display_name", kind))
var value := int(db.adventure.curio_value(kind, tier))
if row.is_empty() and kind == "relic":
var rrow: Dictionary[Variant, Variant] = db.feat_row(String(tok.get("feat_id", "")))
display = String(rrow.get("display_name", "特性"))
value = 0
out.append({
"kind_id": kind,
"tier": tier,
"feat_id": String(tok.get("feat_id", "")),
"display_name": display,
"value": value,
"price": pool_price(kind),
"stock_left": pool_stock(kind),
})
return out
return _shop.shop_shelf()
# 商店独立刷新(MC-1):免费次数(天赋增益位,基础
# knob=0=「基础免费 0 次」)用尽后 GP 付费刷新;刷新=货架整架重抽(shop_draw 恰一新 scope,
# 与进店/买后重抽同签——重抽同源共享池);池空/非池模式=不可刷新合法态。
func shop_refresh_free_left() -> int:
# 刷新执照 shop_refresh_bonus 装配合计叠加(进店入账语义=读数即本店可用,
# 与基础 knob 同生命周期;未装配=基值)
return maxi(0, int(db.adventure.shop_refresh_free) + _attune_shop_refresh_bonus())
# 刷新执照装配合计(装配集 kind 分发扫描;零装配=0)
func _attune_shop_refresh_bonus() -> int:
var total := 0
for iid_v in _attunement:
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("shop_refresh_bonus"):
total += int(arow.get("magnitude", 0))
return total
return _shop.shop_refresh_free_left()
func shop_refresh_cost() -> int:
return int(ceil(float(db.adventure.shop_refresh_cost_base)
* pow(db.adventure.shop_refresh_cost_curve, float(_shop_paid_refreshes))))
return _shop.shop_refresh_cost()
func shop_refresh() -> bool:
if not _shop_pool_mode or _shop_shelf.is_empty():
return false
if shop_refresh_free_left() <= 0:
var cost := shop_refresh_cost()
if gp < cost:
return false
gp -= cost
_shop_paid_refreshes += 1
_shop_draw_shelf()
return true
return _shop.shop_refresh()
# GP 冒险钱包单口支出(三选一 GP 付费刷新与商店刷新共用同一钱包;余额不足 false 零副作用)
func spend_gp(amount: int) -> bool:
if amount <= 0 or gp < amount:
return false
gp -= amount
return true
return _shop.spend_gp(amount)
# 购买(pool 模式唯一购买口):在架(kind→在架令牌)/余额前置校验零副作用 → handler 消费在架
# 令牌本体(kind+tier——_apply_curio_pick 同口写加算区+账本+摘牌,跨渠道共库)→ 出账 → 进店购买
# 次计数 → wallet 事件 → 三件全重抽(未买不保留——重抽绑购买,无独立刷新动作)
func buy_pool_item(kind: String) -> bool:
var shelf_tok := {}
for tok in _shop_shelf:
if String((tok as Dictionary).get("kind", "")) == kind:
shelf_tok = tok
break
if not _shop_pool_mode or shelf_tok.is_empty():
push_error("[关卡节点地图] 商店购买拒绝——非池模式或件不在架: %s" % kind)
return false
var price := pool_price(kind)
if gp < price:
push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [kind, price, gp])
return false
var tier := String(shelf_tok.get("tier", ""))
if _pool_consume.is_null() or not bool(_pool_consume.call(kind, tier)):
push_error("[关卡节点地图] 购买拒绝——池库存消费失败: %s|%s" % [kind, tier])
return false
gp -= price
_shop_visit_buys += 1
wallet_changed.emit("buy", gp, {"product": kind, "price": price})
_shop_draw_shelf() # 买 1 → 三件全重抽(恰一 shop_draw scope——同进店取号签)
return true
return _shop.buy_pool_item(kind)
# 池模式谓词(呈现层组合根分发用——货架面 vs legacy 商品面)
func is_shop_pool() -> bool:
return _shop_pool_mode
return _shop.is_shop_pool()
# 断点快照读面(P2 甲货架+账本入断点——AdventureRunner._snapshot_breakpoint 消费;离开店即空架)
func shop_bp_shelf() -> Array:
return _shop_shelf.duplicate()
return _shop.shop_bp_shelf()
func shop_bp_visit_buys() -> int:
return _shop_visit_buys
return _shop.shop_bp_visit_buys()