## VERTICAL SLICE - NOT FOR PRODUCTION ## 商店会话件:关卡商店双模式(legacy 固定三件加定价/pool 共享令牌池)的货架、定价、购买与刷新唯一宿主。 ## 账本/钱包/装配集归宿主 LevelRunner(共享引用经 bind 注入,出账直接落同一引用改写宿主账本); ## Core 层零 UI 依赖;公开面由 LevelRunner 同签名委托承载(外部调用面零变化); ## legacy 模式零 RNG 零池消费(off 对照承载)。 class_name ShopSession extends RefCounted var db: ConfigDb var rng: RngServiceCore var _refs: Dictionary[Variant, Variant] = {} # wallet/run_ledger/level_row/tree_rules/attunement 宿主共享引用绑定 var _hooks: Dictionary[Variant, Variant] = {} # wallet_emit/max_hp_gain/battle 宿主回调绑定 var purchased: Dictionary[Variant, Variant] = {} # product_id → 已购档数(现价重算输入;冒险开始清零) var pool_mode := false # pool 模式封印(set_shop_pool 注入时置位) var pool_deck: Array[Variant] = [] # 共享池令牌库共享引用(库存=剩余令牌数单点) var pool_consume := Callable() # 池消费 handler(跨渠道共库兑付) var pool_ticket := Callable() # 专长凭证展示判定 handler(节点与商店共享计数) var _pool_cap := Callable() # cap 检查回调(cap 满剔出货架候选视图) var shelf: Array[Variant] = [] # 当前货架(进店抽 3/买后全重抽——令牌字典 {"kind","tier"} 序) var visit_buys := 0 # 本次进店已购次数(A 链定价输入——离开清零) var paid_refreshes := 0 # 本次进店已付费刷新次数(MC-1——GP 刷新价曲线输入,离开清零) func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void: db = db_ref rng = rng_ref # 宿主引用与回调注入(LevelRunner 建件与引用换代时重绑——钱包/账类/图行/装配集为共享引用) func bind(refs: Dictionary, hooks: Dictionary) -> void: _refs = refs _hooks = hooks # 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式) 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 # 现价 = 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(_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 _rules().has("economy_spend"): return maxi(1, int(floor(float(price) * float(db.shop.spend_discount)))) return price func purchased_tier(product_id: String) -> int: return int(purchased.get(product_id, 0)) # 购买(未知商品/属性键出域/余额不足拒绝——返回 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 _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 # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝+静默留痕) _set_gp(_gp() - price) var flat := int(prow.get("flat_value", 0)) var bonus := _bonus() bonus[attr] = int(bonus[attr]) + flat if attr == "max_hp": (_hooks["max_hp_gain"] as Callable).call(flat) # 购血:上限与当前生命同步 +Δ(封顶新上限) purchased[product_id] = purchased_tier(product_id) + 1 _emit("buy", {"product": product_id, "price": price}) return true func player_request_refresh() -> bool: var battle = (_hooks["battle"] as Callable).call() if battle == null: return false return battle.player_request_refresh() # ---- 商店共享池模式(legacy 固定三件路径保留为 off 对照承载)---- # 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 + # 池消费 handler。legacy 模式不注入(pool_mode 恒 false——off 态零 RNG 零池消费零扰动)。 # 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——专长凭证展示判定(同流取数复用 shop_draw scope) _pool_cap = cap_check pool_mode = true # 进店开店(_enter_node shop 分支 pool 模式):进店购买次计数清零 + 抽 3 落架(恰一 shop_draw scope) func open_pool() -> void: visit_buys = 0 paid_refreshes = 0 # MC-1——进店付费刷新计数清零 _draw_shelf() # 离店清零(continue_after_node shop 分支——断点快照 shop_shelf 键随离开落空架合法态) func leave() -> void: shelf = [] visit_buys = 0 paid_refreshes = 0 # MC-1 # 货架抽签(进店 + 每次买后全重抽共用;TP0 令牌级货架):牌库 = 令牌库中货架词表内 kind 的剩余 # 令牌(库存=剩余令牌数——池消耗即上限,与奖励三选一兑付摘牌同库承载);部分 Fisher-Yates 等概率 # 洗牌不放回(逐枚等概率——kind 出现率∝剩余令牌数,无独立权重 knob;同 kind 去重每 kind 至多一卡, # 购买身份 kind 单键无歧义);消费 shop_draw 封闭流恰一 scope(每次抽 3 恰一取号)。牌库空 = 空货架 # 合法态(池空硬闸——买光即枯竭既定后果)。 func _draw_shelf() -> void: shelf = [] var sellable: Array[Variant] = [] for row in db.shop_pool_rows(): var shelf_kind := String(row.get("kind_id", "")) if _pool_cap.is_valid() and not bool(_pool_cap.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") _shuffle(sellable, stream) var seen: Array[Variant] = [] for tok in sellable: var k := String((tok as Dictionary).get("kind", "")) if seen.has(k): continue seen.append(k) shelf.append(tok) if shelf.size() >= 3: break # T5 专长凭证注入(非 Boss 渠道唯一专长入口——货架与节点共享保底计数;判定含 pity 记账,替换末位占一席。 # 取数复用本函数 shop_draw scope 的 stream——零新 scope,确定性序列不漂移) if pool_ticket.is_valid() and not shelf.is_empty() and bool(pool_ticket.call(stream)): shelf[shelf.size() - 1] = {"kind": "feat_ticket", "tier": "excellent"} # 专长凭证占位档名 = excellent # 商店渠道无专长直接投放(渠道白名单:商店渠道只出属性值提升+资源,专长在非 Boss # 渠道只以专长凭证形态出现——奇物.md v2.7/A4 断言;专长商店入口=T5 专长凭证)。 # 部分 Fisher-Yates 洗牌(牌库抽签序单点——逐枚等概率、同序列取号次数不变) func _shuffle(list: Array[Variant], stream: RandomNumberGenerator) -> void: for i in list.size(): var j := stream.randi_range(i, list.size() - 1) var tmp = list[i] list[i] = list[j] list[j] = tmp # 库存余量(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 # 现价 = 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, _level_index() - 1))) * pow(db.shop_price_a(), float(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, _level_index() - 1))) \ * pow(db.shop_price_a(), float(visit_buys)) return _spend_discounted(int(ceil(price_f))) # 货架只读投影(呈现层数据源——kind/显示名/件值/现价/库存余量齐备,UI 零复算)。 # 件值=在架令牌本体值(TP0——令牌级货架,kind+tier 定值;价格公式 tier-blind〔不随档位变〕)。 func shop_shelf() -> Array: var out: Array[Dictionary] = [] for tok in 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 # 商店独立刷新(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 func shop_refresh_cost() -> int: return int(ceil(float(db.adventure.shop_refresh_cost_base) * pow(db.adventure.shop_refresh_cost_curve, float(paid_refreshes)))) func shop_refresh() -> bool: if not pool_mode or shelf.is_empty(): return false if shop_refresh_free_left() <= 0: var cost := shop_refresh_cost() if _gp() < cost: return false _set_gp(_gp() - cost) paid_refreshes += 1 _draw_shelf() return true # GP 冒险钱包单口支出(三选一 GP 付费刷新与商店刷新共用同一钱包;余额不足 false 零副作用) func spend_gp(amount: int) -> bool: if amount <= 0 or _gp() < amount: return false _set_gp(_gp() - amount) return true # 购买(pool 模式唯一购买口):在架(kind→在架令牌)/余额前置校验零副作用 → handler 消费在架 # 令牌本体(kind+tier——_apply_curio_pick 同口写加算区+账本+摘牌,跨渠道共库)→ 出账 → 进店购买 # 次计数 → wallet 事件 → 三件全重抽(未买不保留——重抽绑购买,无独立刷新动作) func buy_pool_item(kind: String) -> bool: var shelf_tok := {} for tok in shelf: if String((tok as Dictionary).get("kind", "")) == kind: shelf_tok = tok break if not 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 _set_gp(_gp() - price) visit_buys += 1 _emit("buy", {"product": kind, "price": price}) _draw_shelf() # 买 1 → 三件全重抽(恰一 shop_draw scope——同进店取号签) return true # 池模式谓词(呈现层组合根分发用——货架面 vs legacy 商品面) func is_shop_pool() -> bool: return pool_mode # 断点快照读面(P2 甲货架+账本入断点——AdventureRunner._snapshot_breakpoint 消费;离开店即空架) func shop_bp_shelf() -> Array: return shelf.duplicate() func shop_bp_visit_buys() -> int: return visit_buys # ---- 宿主引用读取(共享引用绑定面——钱包/账类/图行/装配集)---- func _gp() -> int: return int((_refs["wallet"] as Dictionary).get("gp", 0)) func _set_gp(v: int) -> void: (_refs["wallet"] as Dictionary)["gp"] = v func _bonus() -> Dictionary: return (_refs["run_ledger"] as RunLedger).bonus func _level_index() -> int: return int((_refs["level_row"] as Dictionary).get("level_index", 0)) func _rules() -> Array: return _refs["tree_rules"] as Array func _attunement() -> Array: return _refs["attunement"] as Array func _emit(event: String, extra: Dictionary) -> void: (_hooks["wallet_emit"] as Callable).call(event, _gp(), extra)