Files
gplan-2/game/core/shop_session.gd
T

344 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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)