344 lines
15 KiB
GDScript
344 lines
15 KiB
GDScript
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||
## 商店会话件:关卡商店双模式(legacy 固定三件加定价/pool 共享令牌池)的货架、定价、购买与刷新唯一宿主。
|
||
## 钱包/主角账/装配集归宿主(GpWallet/GainJournal 共享引用经 bind 注入——GP 读写走钱包、购买加算走日记单点);
|
||
## Core 层零 UI 依赖;外部调用面=调用方经宿主 LevelRunner 公开成员 shop 直连(调用直连不留委托壳);
|
||
## legacy 模式零 RNG 零池消费(off 对照承载)。
|
||
class_name ShopSession
|
||
extends RefCounted
|
||
|
||
var db: ConfigDb
|
||
var rng: RngServiceCore
|
||
var _refs: Dictionary[Variant, Variant] = {} # hero/level_row/tree_rules/attunement 宿主共享引用绑定
|
||
var _hooks: Dictionary[Variant, Variant] = {} # wallet_emit/max_hp_gain/battle 宿主回调绑定
|
||
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.run.shop.product_rows():
|
||
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.run.shop.price_level_bonus_at(_level_index()) \
|
||
+ int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", ""))))
|
||
|
||
|
||
# 散折价单点(财富之道·散 economy_spend 激活时商品购买价 ×discount_pct/100〔规则键参数字典——
|
||
# 折率读 economy_spend 参数,零表字段〕,
|
||
# floor 取整偏向玩家、下限 1 防 1 元商品折成 0;范围=商品标价定义边界〔legacy 商品面 current_price
|
||
# + 池货架面 pool_price 同折〕——商店刷新价 shop_refresh_cost 与三选一付费刷新不折)
|
||
func _spend_discounted(price: int) -> int:
|
||
if _rules().has("economy_spend"):
|
||
var spend_params: Dictionary[Variant, Variant] = _rules()["economy_spend"] as Dictionary
|
||
return maxi(1, int(floor(float(price) * float(spend_params["discount_pct"]) / 100.0)))
|
||
return price
|
||
|
||
|
||
func purchased_tier(product_id: String) -> int:
|
||
return _journal().purchase_tier(product_id)
|
||
|
||
|
||
# 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用):
|
||
# 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1
|
||
func buy_product(product_id: String) -> bool:
|
||
var prow: Dictionary[Variant, Variant] = db.run.shop.product_row(product_id)
|
||
if prow.is_empty():
|
||
push_error("[关卡节点地图] 商店房未知商品: %s" % product_id)
|
||
return false
|
||
var attr := String(prow.get("attr", ""))
|
||
if not GainJournal.BONUS_AXES.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))
|
||
_journal().gain_bonus(attr, flat, "shop_product", product_id) # 加算区单写原语(明细+汇总同拍——wr1-s 同值承载)
|
||
if attr == "max_hp":
|
||
(_hooks["max_hp_gain"] as Callable).call(flat) # 购血:上限与当前生命同步 +Δ(封顶新上限)
|
||
_journal().record_purchase(product_id)
|
||
_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.run.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() >= int(db.run.shop.shelf_size):
|
||
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.run.shop.pool_row(kind)
|
||
if row.is_empty():
|
||
if kind == "relic":
|
||
return _spend_discounted(int(ceil(float(db.run.shop.relic_base_price) * pow(db.run.shop.price_b, float(maxi(0, _level_index() - 1))) * pow(db.run.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.run.shop.price_b, float(maxi(0, _level_index() - 1))) \
|
||
* pow(db.run.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.run.shop.pool_row(kind)
|
||
var display := String(row.get("display_name", kind))
|
||
var value := int(db.run.adventure.curio_value(kind, tier))
|
||
if row.is_empty() and kind == "relic":
|
||
var rrow: Dictionary[Variant, Variant] = db.crossrun.feats.row_by_id(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.run.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.crossrun.treasure_items.row_by_id(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.run.adventure.shop_refresh_cost_base)
|
||
* pow(db.run.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 _wallet().gp
|
||
|
||
|
||
func _set_gp(v: int) -> void:
|
||
_wallet().gp = v
|
||
|
||
|
||
func _wallet() -> GpWallet:
|
||
return _refs["wallet"] as GpWallet
|
||
|
||
|
||
func _journal() -> GainJournal:
|
||
return _refs["journal"] as GainJournal
|
||
|
||
|
||
func _level_index() -> int:
|
||
return int((_refs["level_row"] as Dictionary).get("level_index", 0))
|
||
|
||
|
||
func _rules() -> Dictionary[Variant, Variant]:
|
||
return _refs["tree_rules"] as Dictionary
|
||
|
||
|
||
func _attunement() -> Array:
|
||
return _refs["attunement"] as Array
|
||
|
||
|
||
func _emit(event: String, extra: Dictionary) -> void:
|
||
(_hooks["wallet_emit"] as Callable).call(event, _gp(), extra)
|