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:
1 parent
5c9c5f8083
commit
e5ee4d04f0
8 files changed
+925
-504
No files matched your search
+220
-281
@@ -1,5 +1,5 @@
|
||||
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
## 冒险结构编排器:三关序列、生命/GP 账本、三选一停点与三结局结算唯一所有者,headless 可跑完整冒险(垂直切片验证:一条命走完三关)。
|
||||
## 冒险结构编排器:三关序列、生命/GP 账本、三选一停点与三结局结算唯一所有者,headless 可跑完整冒险(垂直切片验证:一条命走完三关);令牌池与凭证判定归 CurioDeck 件。
|
||||
## Core 层禁 UI 引用;数值零字面量只读 db;core↔core 连信号一律方法 Callable,禁 lambda 捕获 self(RefCounted 成环)。
|
||||
## 主种子恰一次注入归本类(LevelRunner 不重播种);set_save_manager 缺省 null = 断点面整体静默(模拟/冒烟零扰动)。
|
||||
## 呈现层须在 level_started 后重取 runner()(每关新建实例)——先重取再开选路口,否则被空防御丢弃。
|
||||
@@ -56,23 +56,33 @@ var _time_sand_queue := 0 # 时沙待注入(回合级——下
|
||||
var _save_manager = null # 断点承载注入(null = 不写断点;模拟/冒烟缺省)
|
||||
var _resume_bp := {} # 续玩快照暂存(resume_adventure → _start_current_level 单跳传递)
|
||||
# ---- 三选一停点态 ----
|
||||
var _curio_taken := {} # 件获取次数计数(kind 维度派生账本——断点附随件与摘要读数承载;池库存现行事实源=_curio_deck)
|
||||
var _curio_deck: Array[Variant] = [] # 令牌池(TP0 v2.2——持久多重集,元素 {"kind","tier"};洗牌抽候选/选中踢池未选回池/池耗即枯竭;商店货架同库共账;规则改写型特性带=relic 令牌随表追加)
|
||||
var _curio: CurioDeck = null # 令牌池件(_init 建;多重集/抽签/cap/凭证判定唯一宿主)
|
||||
var _curio_taken: Dictionary[Variant, Variant]: # 件获取次数计数(kind 维度派生账本——断点附随件与摘要读数承载)
|
||||
get: return _curio.taken
|
||||
set(v): _curio.taken = v
|
||||
var _curio_deck: Array[Variant]: # 令牌池(TP0 v2.2——持久多重集,元素 {"kind","tier"};商店货架同库共账)
|
||||
get: return _curio.deck
|
||||
set(v): _curio.deck = v
|
||||
var _feats: Array[String] = [] # 特性持有集(局内随机渠道获得、冒险级跨关携带、收束清零;🔴 层级纪律承载面:不入同调位不入局外树)
|
||||
var _tree_rules: Array[Variant] = [] # 树 rule 键集(Fork 激活侧 effects 透传;下传 enc.tree_rules 消费面)
|
||||
var _curio_pending := {} # 停点态:{"candidates": Array, "payload": Dictionary, "chosen"/"incoming": 替换在途}——空 = 无停点
|
||||
var _curio_result := {} # 已兑付待推进态(curio_resolved 载荷副本——中断续玩不重兑付)
|
||||
var _keystone_drops: Array[Variant] = [] # Boss 逐杀掉落明细(additive 摘要键 keystone_drops——结算明细/复盘归因数据源)
|
||||
var _node_curio_pending := {} # 奖励节点三选一停点态(共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽)
|
||||
var _feat_ticket_pity := 0 # 专长凭证保底计数(T5——展示次数:节点开面/货架抽签/刷新重展各计一次;触发清零)
|
||||
var _feat_ticket_sold := 0 # 专长凭证限购计数(T5——选中/买下即 +1;拿到即开不进持有集)
|
||||
var _feat_ticket_appeared := 0 # 专长凭证出现计数(T5——quota 上限面)
|
||||
var _feat_ticket_pity: int: # 专长凭证保底计数(T5——展示次数:节点开面/货架抽签/刷新重展各计一次;触发清零)
|
||||
get: return _curio.ticket_pity
|
||||
set(v): _curio.ticket_pity = v
|
||||
var _feat_ticket_sold: int: # 专长凭证限购计数(T5——选中/买下即 +1;拿到即开不进持有集)
|
||||
get: return _curio.ticket_sold
|
||||
set(v): _curio.ticket_sold = v
|
||||
var _shop_mode := "legacy" # 商店模式("legacy"=固定三件加定价〔off 对照承载,缺省零扰动〕/"pool"=共享池抽 3 买 1 重抽;真实入口 main.gd 显式投 pool)
|
||||
|
||||
|
||||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
|
||||
db = db_ref
|
||||
rng = rng_ref
|
||||
_curio = CurioDeck.new(db, rng)
|
||||
_curio.bind_handlers(_apply_curio_pick, _redeem_feat_ticket)
|
||||
|
||||
|
||||
# ---- 冒险生命周期 ----
|
||||
@@ -150,6 +160,32 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
|
||||
return false
|
||||
master_seed = int(bp.get("master_seed", 0))
|
||||
rng.restore_adventure(master_seed, int(bp.get("rng_counter", 0)))
|
||||
_resume_ledger(bp)
|
||||
_resume_curio_state(bp)
|
||||
_resume_params(bp, params)
|
||||
_resume_counters(bp)
|
||||
var bp_pending: Dictionary[Variant, Variant] = bp.get("bonus_pending", {}) as Dictionary
|
||||
var bp_result: Dictionary[Variant, Variant] = bp.get("bonus_result", {}) as Dictionary
|
||||
if not bp_pending.is_empty():
|
||||
_resume_pending(bp_pending)
|
||||
return true
|
||||
if not bp_result.is_empty():
|
||||
_pending_next_level = true
|
||||
_curio_result = bp_result.duplicate(true)
|
||||
_state = State.RUNNING
|
||||
curio_resolved.emit(_curio_result.duplicate(true))
|
||||
return true
|
||||
_resume_bp = bp.duplicate(true)
|
||||
# 充能账本随断点回局(缺键 = 旧档/无断点,保持 _seed_attune_charges 种子态)
|
||||
var bp_charges = _bp_read("attunement_charges", bp)
|
||||
if bp_charges is Dictionary:
|
||||
run_ledger.attune_charges = (bp_charges as Dictionary).duplicate(true)
|
||||
_state = State.RUNNING
|
||||
return _start_current_level()
|
||||
|
||||
|
||||
# 续玩账面回填(账本/加算区/已购档/钥石/关序——段序与原读侧一致;bonus 子键 clamp 类夹 ≥0)
|
||||
func _resume_ledger(bp: Dictionary) -> void:
|
||||
ledger = {"hero_hp": int(bp.get("hero_hp", 0)), "gp": int(bp.get("gp", 0))}
|
||||
var bp_bonus: Dictionary[Variant, Variant] = bp.get("bonus", {}) as Dictionary
|
||||
run_ledger.bonus = {"atk": maxi(0, int(bp_bonus.get("atk", 0))),
|
||||
@@ -162,6 +198,10 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
|
||||
levels_total = db.level_count() # 表访问收口(关数读 level 表行数)
|
||||
level_index = int(bp.get("level_index", 1))
|
||||
_pending_next_level = false
|
||||
|
||||
|
||||
# 续玩三选一态回填(池与件计数归件、停点态归本类;dual_merge/keep_on_null 策略见 BREAKPOINT_FIELDS)
|
||||
func _resume_curio_state(bp: Dictionary) -> void:
|
||||
_reset_curio_state()
|
||||
var bp_taken: Dictionary[Variant, Variant] = bp.get("bonus_taken", {}) as Dictionary
|
||||
for id in _curio_taken.keys():
|
||||
@@ -170,14 +210,14 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
|
||||
_curio_taken[String(id)] = maxi(0, int(bp_taken.get(id, 0))) # 身份维键(relic:<feat_id>)随快照还原
|
||||
# 令牌池余库还原(TP0 v2.2——快照缺键 = 旧版断点,保持满库重建态:旧档只读一版周期的防御口径;
|
||||
# relic 令牌 feat_id 随还原)
|
||||
var bp_deck = bp.get("bonus_deck", null)
|
||||
var bp_deck = _bp_read("bonus_deck", bp)
|
||||
if bp_deck is Array:
|
||||
var restored: Array[Variant] = []
|
||||
for t in (bp_deck as Array):
|
||||
var tok: Dictionary[Variant, Variant] = t as Dictionary
|
||||
var rtok := {"kind": String(tok.get("kind", "")), "tier": String(tok.get("tier", ""))}
|
||||
if String(rtok["kind"]) == "relic":
|
||||
rtok["feat_id"] = String(tok.get("feat_id", ""))
|
||||
rtok["feat_id"] = String(tok.get("feat_id", "")) # relic 令牌 feat_id 随还原
|
||||
restored.append(rtok)
|
||||
_curio_deck = restored
|
||||
# 特性持有集还原(缺键 = 旧版断点防御空集)
|
||||
@@ -185,6 +225,9 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
|
||||
for r in (bp.get("relics", []) as Array):
|
||||
_feats.append(String(r))
|
||||
_keystone_drops = (bp.get("keystone_drops", []) as Array).duplicate(true)
|
||||
|
||||
# 续玩参数面回填(同 start_adventure 参数面;生命账本越界钳制在此落——clamp 类)
|
||||
func _resume_params(bp: Dictionary, params: Dictionary) -> void:
|
||||
_base_override = _normalize_override(params.get("hero_override", {}))
|
||||
hero_attack_type = String(params.get("attack_type", "")) # 续玩参数面同 start_adventure(账号层选中态随 resume 重注入)
|
||||
hero_armor_type = String(params.get("armor_type", ""))
|
||||
@@ -198,58 +241,46 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool:
|
||||
_shop_mode = "pool" if String(params.get("shop_mode", "legacy")) == "pool" else "legacy" # 续玩参数面同 start_adventure(缺省 legacy)
|
||||
_treasure_unlocked = params.get("treasure_unlocked", {}) if params.get("treasure_unlocked", {}) is Dictionary else {}
|
||||
_attunement = params.get("attunement", []) if params.get("attunement", []) is Array else [] # additive
|
||||
_attune_refresh_y = maxi(0, int(bp.get("attune_refresh_y", int(params.get("attune_refresh_y", 0))))) # Y 余量优先读断点(花掉不回血=堵「用完退菜单再续玩满血复活」可利用漏洞);缺键=旧版断点,回退参数面整额注入(旧档行为零变化)
|
||||
# Y 余量优先读断点(花掉不回血=堵「用完退菜单再续玩满血复活」可利用漏洞);缺键=旧版断点,
|
||||
# 回退参数面整额注入(旧档行为零变化)——params_fallback 类
|
||||
_attune_refresh_y = _bp_read_int("attune_refresh_y", bp, 0, params)
|
||||
_seed_attune_charges() # 冒险开始充能账本按装备主动件重置(每冒险充能语义)
|
||||
_tree_rules = params.get("tree_rules", []) if params.get("tree_rules", []) is Array else [] # A/B 面 rule 键集(续玩参数面同 start_adventure 同式,消费面 set_tree_rules 下传)
|
||||
# 三选一停点续玩分支(选择态/已兑付态不重跑关卡;奖励选择态不重抽、已兑付不重付)
|
||||
var bp_pending: Dictionary[Variant, Variant] = bp.get("bonus_pending", {}) as Dictionary
|
||||
|
||||
|
||||
# 续玩计数器回填(凭证计数/冒险域计数/刷新存量/真视计数)
|
||||
func _resume_counters(bp: Dictionary) -> void:
|
||||
_feat_ticket_pity = int(bp.get("feat_ticket_pity", 0)) # T5 扩列——旧档缺键缺省 0(存档兼容)
|
||||
_feat_ticket_sold = int(bp.get("feat_ticket_sold", 0))
|
||||
# adventure 域计数器回填(缺键 = 旧版断点防御空集;_reset_curio_state 已清零在先)
|
||||
var bp_counters = bp.get("adventure_counters", null)
|
||||
var bp_counters = _bp_read("adventure_counters", bp)
|
||||
if bp_counters is Dictionary:
|
||||
run_ledger.adventure_counters = (bp_counters as Dictionary).duplicate(true)
|
||||
_refresh_stock = maxi(0, int(bp.get("refresh_stock", 0))) # 旧档缺键缺省 0
|
||||
run_ledger.vision_counter = {"used": maxi(0, int(bp.get("vision_counter_used", 0)))} # 真视计数随断点回填(缺键=旧断点缺省 0)
|
||||
var bp_result: Dictionary[Variant, Variant] = bp.get("bonus_result", {}) as Dictionary
|
||||
if not bp_pending.is_empty():
|
||||
_pending_next_level = true
|
||||
# 候选令牌化迁移(TP0——旧版断点候选为 kind 字符串,按普通档令牌防御归一;单版本周期口径)
|
||||
var migrated: Array[Variant] = []
|
||||
for c in (bp_pending.get("candidates", []) as Array):
|
||||
if c is Dictionary:
|
||||
# T4——feat_id 随迁移(通关渠道候选=纯专长令牌,身份键 relic|tier|feat_id 三段全要)
|
||||
var mig := {"kind": String((c as Dictionary).get("kind", "")), "tier": String((c as Dictionary).get("tier", ""))}
|
||||
if String(mig["kind"]) == "relic":
|
||||
mig["feat_id"] = String((c as Dictionary).get("feat_id", ""))
|
||||
migrated.append(mig)
|
||||
else:
|
||||
migrated.append({"kind": String(c), "tier": db.adventure.TIER_COMMON})
|
||||
_curio_pending = {"candidates": migrated}
|
||||
var payload: Dictionary[Variant, Variant] = (bp_pending.get("payload", {}) as Dictionary).duplicate(true)
|
||||
_state = State.RUNNING
|
||||
curio_choice_presented.emit(payload)
|
||||
return true
|
||||
if not bp_result.is_empty():
|
||||
_pending_next_level = true
|
||||
_curio_result = bp_result.duplicate(true)
|
||||
_state = State.RUNNING
|
||||
curio_resolved.emit(_curio_result.duplicate(true))
|
||||
return true
|
||||
_resume_bp = bp.duplicate(true)
|
||||
# 充能账本随断点回局(缺键 = 旧档/无断点,保持 _seed_attune_charges 种子态)
|
||||
var bp_charges = bp.get("attunement_charges", null)
|
||||
if bp_charges is Dictionary:
|
||||
run_ledger.attune_charges = (bp_charges as Dictionary).duplicate(true)
|
||||
_refresh_stock = _bp_read_int("refresh_stock", bp, 0) # 旧档缺键缺省 0(clamp 类)
|
||||
run_ledger.vision_counter = {"used": _bp_read_int("vision_counter_used", bp, 0)} # 真视计数随断点回填(clamp 类)
|
||||
|
||||
|
||||
# 续玩选择停点回填(token_migration 类:旧版断点候选为 kind 字符串,按普通档令牌防御归一)
|
||||
func _resume_pending(bp_pending: Dictionary) -> void:
|
||||
_pending_next_level = true
|
||||
var migrated: Array[Variant] = []
|
||||
for c in (bp_pending.get("candidates", []) as Array):
|
||||
if c is Dictionary:
|
||||
# T4——feat_id 随迁移(通关渠道候选=纯专长令牌,身份键 relic|tier|feat_id 三段全要)
|
||||
var mig := {"kind": String((c as Dictionary).get("kind", "")), "tier": String((c as Dictionary).get("tier", ""))}
|
||||
if String(mig["kind"]) == "relic":
|
||||
mig["feat_id"] = String((c as Dictionary).get("feat_id", ""))
|
||||
migrated.append(mig)
|
||||
else:
|
||||
migrated.append({"kind": String(c), "tier": db.adventure.TIER_COMMON})
|
||||
_curio_pending = {"candidates": migrated}
|
||||
var payload: Dictionary[Variant, Variant] = (bp_pending.get("payload", {}) as Dictionary).duplicate(true)
|
||||
_state = State.RUNNING
|
||||
return _start_current_level()
|
||||
curio_choice_presented.emit(payload)
|
||||
|
||||
|
||||
# 三选一停点态归零(冒险开始/结算收束共用;令牌池按表配额重建——池消耗即上限,账本随池重生。
|
||||
# adventure 域计数器账本随冒险开始清零;resume 路径调用后由断点附随件回填)
|
||||
func _reset_curio_state() -> void:
|
||||
_curio_taken = {"attack": 0, "max_hp": 0, "armor": 0, "gp": 0, "ap": 0, "heal": 0}
|
||||
_curio_deck = _build_curio_deck()
|
||||
_curio.reset() # 池与件获取计数(池半归件;停点态归本类下行清理)
|
||||
_feats = []
|
||||
_curio_pending = {}
|
||||
_curio_result = {}
|
||||
@@ -261,30 +292,6 @@ func _reset_curio_state() -> void:
|
||||
_time_sand_queue = 0
|
||||
|
||||
|
||||
# 令牌池构建(TP0 v2.2——按表 kind×tier 配额展开为持久多重集;构建序确定 = CURIO_KINDS×TIERS 逐层,
|
||||
# 随机性只发生在洗牌消费 reward_pick 封闭流的时刻。规则改写型特性带=relic 活行每件 1 张追加。
|
||||
# relic 令牌档 = 行 rarity 直映射(四级词表内直用;词表外的 transitional 值回退映射
|
||||
# rarest→rare/其余→excellent);relic 可入普通档(奇物.md 池结构节无档位限制)。
|
||||
func _build_curio_deck() -> Array:
|
||||
var deck: Array[Variant] = []
|
||||
for kind in db.adventure.CURIO_KINDS:
|
||||
for tier in db.adventure.TIERS:
|
||||
for i in int(db.adventure.curio_quota(String(kind), String(tier))):
|
||||
deck.append({"kind": String(kind), "tier": String(tier)})
|
||||
for row in db.feat_rows():
|
||||
var rraw := String(row.get("rarity", ""))
|
||||
var rtier := rraw if db.adventure.TIERS.has(rraw) else ("rare" if rraw == "rarest" else "excellent")
|
||||
deck.append({"kind": "relic", "tier": rtier, "feat_id": String(row.get("feat_id", ""))})
|
||||
return deck
|
||||
|
||||
|
||||
# 令牌唯一身份(抽签去重/兑付匹配共用——属性加成型特性=kind,规则改写型特性=feat_id;同档多张不同 relic 可同屏)
|
||||
func _token_identity(tok: Dictionary) -> String:
|
||||
if String(tok.get("kind", "")) == "relic":
|
||||
return "relic:" + String(tok.get("feat_id", ""))
|
||||
return String(tok.get("kind", ""))
|
||||
|
||||
|
||||
# 调试浮窗百分比通道(测试工具覆盖——每关新 runner 都要应用;正式侧归元进度)
|
||||
func set_hero_override(atk_pct: int, hp_pct: int) -> void:
|
||||
_override = [atk_pct, hp_pct]
|
||||
@@ -347,14 +354,7 @@ func skip_empty_curio() -> bool:
|
||||
|
||||
# 候选令牌匹配(身份键恒等——属性加成型特性 kind+tier、规则改写型特性 feat_id)
|
||||
func _find_token(candidates: Array, kind: String, tier: String, feat_id: String = "") -> Dictionary:
|
||||
for t in candidates:
|
||||
var tok: Dictionary[Variant, Variant] = t as Dictionary
|
||||
if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier:
|
||||
continue
|
||||
if kind == "relic" and String(tok.get("feat_id", "")) != feat_id:
|
||||
continue
|
||||
return tok
|
||||
return {}
|
||||
return _curio.find_token(candidates, kind, tier, feat_id)
|
||||
|
||||
|
||||
# 玩家点选兑付(呈现层/冒烟/自动模拟确认入口):令牌 ∈ 停点候选(身份键精确匹配——relic 件
|
||||
@@ -441,15 +441,7 @@ func _apply_curio_pick(kind: String, tier: String, value: int, feat_id: String =
|
||||
|
||||
# 从牌库摘除一枚令牌(TP0——选中踢池单点;relic 按 feat_id 精确摘;找不到 = false)
|
||||
func _consume_deck_token(kind: String, tier: String, feat_id: String = "") -> bool:
|
||||
for i in _curio_deck.size():
|
||||
var tok: Dictionary[Variant, Variant] = _curio_deck[i] as Dictionary
|
||||
if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier:
|
||||
continue
|
||||
if kind == "relic" and String(tok.get("feat_id", "")) != feat_id:
|
||||
continue
|
||||
_curio_deck.remove_at(i)
|
||||
return true
|
||||
return false
|
||||
return _curio.consume_deck_token(kind, tier, feat_id)
|
||||
|
||||
|
||||
# 特性持有集读面(效果接线/求值/摘要唯一取数口——副本防外部改写)
|
||||
@@ -459,37 +451,19 @@ func feats_held() -> Array:
|
||||
|
||||
# ---- 商店共享池消费面(共享池单点账本;TP0——库存=牌库剩余令牌)----
|
||||
|
||||
# 库存余量(TP0:stock = 牌库内该 kind 剩余令牌数——「池消耗即上限」,无种类次数上限;
|
||||
# 奖励三选一兑付摘牌 与 商店购买消费 由同一 _curio_deck 承载——两渠道跨关共库,全冒险一次性)。
|
||||
# 库存余量(池消耗即上限;奖励摘牌与商店购买同库承载,全冒险一次性)
|
||||
func pool_stock(kind: String) -> int:
|
||||
var n := 0
|
||||
for t in _curio_deck:
|
||||
if String((t as Dictionary).get("kind", "")) == kind:
|
||||
n += 1
|
||||
return n
|
||||
return _curio.pool_stock(kind)
|
||||
|
||||
|
||||
# 商店购买消费 handler(LevelRunner.buy_pool_item 经 Callable 调用——S6 方法引用):
|
||||
# 消费在架令牌本体(kind+tier 精确——货架卡即令牌,TP0 令牌级货架)→ _apply_curio_pick 同口
|
||||
# 写加算区+账本+摘牌(与奖励三选一真单点——买一件=奖励面少一件的库存语义由同库成立)。
|
||||
# 未知 kind 拒绝(货架词表封闭的前置防御)。
|
||||
# 商店购买消费 handler(LevelRunner.buy_pool_item 经 Callable 调用——回 _apply_curio_pick 同口)
|
||||
func _consume_pool_purchase(kind: String, tier: String) -> bool:
|
||||
if kind == "feat_ticket":
|
||||
return _redeem_feat_ticket() # T5——专长凭证买下即开(零摘牌不进持有集;临时停点开专长三选一面)
|
||||
if not db.adventure.CURIO_KINDS.has(kind):
|
||||
push_error("[冒险结构] 商店购买消费拒绝——件不在池词表: %s" % kind)
|
||||
return false
|
||||
return _apply_curio_pick(kind, tier, int(db.adventure.curio_value(kind, tier)))
|
||||
return _curio.consume_pool_purchase(kind, tier)
|
||||
|
||||
|
||||
# 终局池快照(_settle 清账前调用——_reset_curio_state 会重置 _curio_taken,摘要 additive 键
|
||||
# pool_stock 须在此前取数;货架四种 stock=cap−taken,读数面 = 商店买走 vs 奖励领走 vs 枯竭)
|
||||
# 终局池快照(_settle 清账前调用——复位会重置计数,摘要 additive 键须在此前取数)
|
||||
func _pool_stock_snapshot() -> Dictionary:
|
||||
var out := {}
|
||||
for row in db.shop_pool_rows():
|
||||
var kind := String(row.get("kind_id", ""))
|
||||
out[kind] = pool_stock(kind)
|
||||
return out
|
||||
return _curio.stock_snapshot()
|
||||
|
||||
|
||||
# 兑付收口(恰一次):清停点态 → 记已兑付态 → emit → 停点快照(等继续确认——中断续玩不重兑付)。
|
||||
@@ -656,134 +630,19 @@ func _finish_node_bonus(picked: String, value: int, tier: String = "", feat_id:
|
||||
curio_resolved.emit(payload)
|
||||
|
||||
|
||||
# 令牌池洗牌不放回抽 3(TP0 v2.2——Fisher-Yates 全库洗牌后按序收集;**同 kind 去重每 kind 至多
|
||||
# 一枚**〔先到先得,档位取该 kind 首枚——「三选一=三种不同选项」体验延续,与货架抽签同规则〕;
|
||||
# kind 出现率∝剩余令牌数〔档位配额即分布——无独立权重 knob〕;抽签不摘牌、兑付才摘牌=未选
|
||||
# 自动回池;池耗即枯竭——余库不足 N kind 时出齐剩余,空库 = 零候选合法态)。候选 = 令牌字典
|
||||
# {"kind","tier"}。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同候选。
|
||||
# 渠道白名单过滤(渠道轴定位:关底 Boss 击败=通关渠道只出专长(feat 令牌);
|
||||
# 奖励节点/商店只出属性值提升+资源(非 relic 令牌),专长在非 Boss 渠道只以专长凭证形态出现(T5)。
|
||||
# 过滤式而非物理拆池:deck 物理结构/存档键 bonus_deck 混编余库/消耗语义(选中踢池未选回池)原样,
|
||||
# 白名单在候选视图层生效——配额结构锚 TIER_QUOTAS 70/25/3=98 零改动(专长令牌本系活行追加面,在锚外)。
|
||||
# 曝光槽:非 Boss 渠道三选一 = **2 格全池 + 1 格优秀档以上子池**
|
||||
# (rank≥excellent;子池空该格回退全池;池空直通同族兜底);凭证(feat_ticket)不进子池
|
||||
# 〔独立计数体系〕;已拿上限 cap:taken≥cap 的身份在候选视图剔除〔牌库本体不动,
|
||||
# 拿了才计、刷出不计——_curio_cap_ok〕。
|
||||
# 三选一候选抽签(实装归 CurioDeck 件;消费 reward_pick 封闭流——一次停点恰一 scope)
|
||||
func _draw_curio_candidates(channel: String = "reward_node") -> Array:
|
||||
var pool := _curio_deck.duplicate()
|
||||
if channel == "level_clear":
|
||||
pool = pool.filter(func(tok): return String((tok as Dictionary).get("kind", "")) == "relic")
|
||||
else:
|
||||
pool = pool.filter(func(tok):
|
||||
var k := String((tok as Dictionary).get("kind", ""))
|
||||
return k != "relic" and k != "feat_ticket")
|
||||
pool = pool.filter(func(tok):
|
||||
var t: Dictionary[Variant, Variant] = tok as Dictionary
|
||||
return _curio_cap_ok(String(t.get("kind", "")), String(t.get("feat_id", ""))))
|
||||
var rg: RandomNumberGenerator = rng.open_stream("reward_pick")
|
||||
if channel == "level_clear":
|
||||
# Boss 渠道按档配比加权抽取(无放回加权抽:逐席按 boss_feat_weight 掷点,
|
||||
# 身份去重;rarity 语义约束兑付面——奇物.md v3.1,配比数值归 adventure 表)
|
||||
var out_w: Array[Variant] = []
|
||||
var seen_w: Array[Variant] = []
|
||||
var want_w := int(db.adventure.curio_candidate_count)
|
||||
while out_w.size() < want_w and not pool.is_empty():
|
||||
var total_w := 0
|
||||
for tok_w in pool:
|
||||
total_w += db.adventure.boss_feat_weight(String((tok_w as Dictionary).get("tier", "")))
|
||||
if total_w <= 0:
|
||||
break
|
||||
var roll_w := rg.randi_range(1, total_w)
|
||||
var pick_i := 0
|
||||
for i_w in pool.size():
|
||||
roll_w -= db.adventure.boss_feat_weight(String((pool[i_w] as Dictionary).get("tier", "")))
|
||||
if roll_w <= 0:
|
||||
pick_i = i_w
|
||||
break
|
||||
var tok_p: Dictionary[Variant, Variant] = pool[pick_i]
|
||||
var ident_p := _token_identity(tok_p)
|
||||
if seen_w.has(ident_p):
|
||||
pool.remove_at(pick_i) # 同身份已出——剔牌重掷(无放回)
|
||||
continue
|
||||
seen_w.append(ident_p)
|
||||
var cand_p := {"kind": String(tok_p.get("kind", "")), "tier": String(tok_p.get("tier", "")), "feat_id": String(tok_p.get("feat_id", ""))}
|
||||
out_w.append(cand_p)
|
||||
pool.remove_at(pick_i)
|
||||
return out_w
|
||||
for i in pool.size():
|
||||
var j := rg.randi_range(i, pool.size() - 1)
|
||||
var tmp = pool[i]
|
||||
pool[i] = pool[j]
|
||||
pool[j] = tmp
|
||||
# 子池(优秀档以上)独立洗牌——同 stream 续消费,确定性保持
|
||||
var sub := pool.filter(func(tok):
|
||||
return db.adventure.tier_rank(String((tok as Dictionary).get("tier", ""))) >= db.adventure.tier_rank(db.adventure.TIER_EXCELLENT))
|
||||
for i in sub.size():
|
||||
var j2 := rg.randi_range(i, sub.size() - 1)
|
||||
var tmp2 = sub[i]
|
||||
sub[i] = sub[j2]
|
||||
sub[j2] = tmp2
|
||||
var out: Array[Variant] = []
|
||||
var seen: Array[Variant] = []
|
||||
# 收集器:全池序取 n_full 格(普通档打底)→ 子池序取 1 格(曝光槽)→ 子池空回退全池续取
|
||||
var want := int(db.adventure.curio_candidate_count)
|
||||
var n_full := maxi(1, want - 1) if want >= 2 else want
|
||||
while out.size() < want:
|
||||
var src: Array[Variant] = pool
|
||||
if out.size() >= n_full and not sub.is_empty():
|
||||
src = sub # 曝光槽位——优先子池
|
||||
var placed := false
|
||||
for tok in src:
|
||||
var ident := _token_identity(tok)
|
||||
if seen.has(ident):
|
||||
continue
|
||||
seen.append(ident)
|
||||
var cand := {"kind": String((tok as Dictionary).get("kind", "")), "tier": String((tok as Dictionary).get("tier", ""))}
|
||||
if ident.begins_with("relic:"):
|
||||
cand["feat_id"] = String((tok as Dictionary).get("feat_id", ""))
|
||||
out.append(cand)
|
||||
placed = true
|
||||
break
|
||||
if not placed:
|
||||
if out.size() >= n_full and not sub.is_empty() and not pool.is_empty():
|
||||
sub = [] # 子池枯竭——曝光槽回退全池续取
|
||||
continue
|
||||
break # 全池枯竭——出齐剩余(池空直通同族兜底)
|
||||
# T5 专长凭证注入(非 Boss 渠道唯一专长入口——替换末位候选占一席,候选数不变;判定含 pity 清零记账)
|
||||
if channel == "reward_node" and not out.is_empty() and _feat_ticket_consider(rg):
|
||||
out[out.size() - 1] = {"kind": "feat_ticket", "tier": "excellent"}
|
||||
return out
|
||||
return _curio.draw_candidates(channel)
|
||||
|
||||
|
||||
# 已拿上限判定(身份维度:kind 走 curio_cap、relic 走 feats 行 cap 字段;
|
||||
# taken 只在兑付时 +1〔拿了才计〕、刷出不计)。缺省放行=白名单外 kind 兜底。
|
||||
# 已拿上限判定(身份维度;缺省放行=白名单外 kind 兜底)
|
||||
func _curio_cap_ok(kind: String, feat_id: String = "") -> bool:
|
||||
var cap := 99
|
||||
if kind == "relic":
|
||||
var row: Dictionary[Variant, Variant] = db.feat_row(feat_id)
|
||||
cap = int(row.get("cap", 1)) if not row.is_empty() else 1
|
||||
return _curio_taken.get("relic:" + feat_id, 0) < cap
|
||||
if not db.adventure.CURIO_KINDS.has(kind):
|
||||
return true
|
||||
cap = int(db.adventure.curio_cap(kind))
|
||||
return int(_curio_taken.get(kind, 0)) < cap
|
||||
return _curio.cap_ok(kind, feat_id)
|
||||
|
||||
|
||||
# —— 专长获取专长凭证(T5——非 Boss 渠道的专长入口;四口径契约④:拿到即开不可囤积/
|
||||
# 罕见只在概率不在价格/一局限购一次/计步保底连续 5 展示未出第 6 必出——计数=展示次数、刷新
|
||||
# 重展计入、节点与商店共享、触发清零、限购满不再触发。调用即一次展示判定与记账)——
|
||||
# —— 专长获取专长凭证四口径判定(实装归 CurioDeck 件:调用即一次展示判定与记账)——
|
||||
func _feat_ticket_consider(rg: RandomNumberGenerator) -> bool:
|
||||
if _feat_ticket_sold >= int(db.adventure.feat_ticket_limit):
|
||||
return false # 限购满——不再触发(保底冻结)
|
||||
if _feat_ticket_appeared >= int(db.adventure.feat_ticket_quota):
|
||||
return false # 配额满——本局不再出现
|
||||
var offer: bool = _feat_ticket_pity >= int(db.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.adventure.feat_ticket_chance)
|
||||
if offer:
|
||||
_feat_ticket_pity = 0
|
||||
_feat_ticket_appeared += 1
|
||||
else:
|
||||
_feat_ticket_pity += 1
|
||||
return offer
|
||||
return _curio.feat_ticket_consider(rg)
|
||||
|
||||
|
||||
# 专长凭证兑付(拿到即开——停点候选替换为专长三选一;不进持有集/零摘牌;限购计数 +1):
|
||||
@@ -837,6 +696,29 @@ func abandon() -> bool:
|
||||
|
||||
func _start_current_level() -> bool:
|
||||
_runner = LevelRunner.new(db, rng)
|
||||
_bind_level_runner()
|
||||
# 断点续玩分支:图由主种子+参数档重生成同图(产图与计数器号位无关,停点零重抽语义随之消解),
|
||||
# resume_state 带停点句柄与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
|
||||
var resume_state: Dictionary[Variant, Variant] = {}
|
||||
if not _resume_bp.is_empty():
|
||||
resume_state = {"node_id": int(_bp_read("node_id", _resume_bp, -1)),
|
||||
"visited_ids": _bp_read("visited_ids", _resume_bp, []) as Array}
|
||||
# 接口①开始关卡:生命经共享账本交接(账本当前值即开局生命——冒险开始置满 / 大奖励回满已写好);
|
||||
# _base_override 共享引用随载荷传入(永久强化 flat——LevelRunner.effective_* 叠加序第二项)。
|
||||
# defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在
|
||||
# 该回调重取 runner 实例),后入口候选呈现;顺序反了选路口会被呈现层的 runner 空防御静默丢弃。
|
||||
# 呈现零 RNG 消耗、pick 走同一 _enter_node——测量面逐位零漂移。
|
||||
var ok: bool = _runner.start_level_external(level_index, ledger, purchased,
|
||||
apply_rest, _base_override, true, resume_state)
|
||||
if ok:
|
||||
_inject_level_params()
|
||||
_present_level_entry(resume_state)
|
||||
_resume_bp = {}
|
||||
return ok
|
||||
|
||||
|
||||
# 关卡行进器接线(每关新实例:信号转发 + 共享账下传)
|
||||
func _bind_level_runner() -> void:
|
||||
# 树限次计数按关清零现由 LevelRunner.level_counters 随每关 new 天然承载(旧硬编码清除名单函数已删)
|
||||
# core↔core 接线只许方法 Callable(S6——lambda 捕获 self 会延长 RefCounted 生命周期乃至成环)
|
||||
_runner.node_entered.connect(_forward_node_entered)
|
||||
@@ -847,46 +729,36 @@ func _start_current_level() -> bool:
|
||||
_runner.wallet_changed.connect(_forward_wallet_changed)
|
||||
_runner.level_finished.connect(_on_level_finished)
|
||||
_runner.set_hero_override(int(_override[0]), int(_override[1]))
|
||||
# 断点续玩分支:图由主种子+参数档重生成同图(产图与计数器号位无关,停点零重抽语义随之消解),
|
||||
# resume_state 带停点句柄与已到序——重建到停点后经 present_resumed_choice 呈现(信号顺序契约同入口)。
|
||||
var resume_state: Dictionary[Variant, Variant] = {}
|
||||
if not _resume_bp.is_empty():
|
||||
resume_state = {"node_id": int(_resume_bp.get("node_id", -1)),
|
||||
"visited_ids": _resume_bp.get("visited_ids", []) as Array}
|
||||
# 接口①开始关卡:生命经共享账本交接(账本当前值即开局生命——冒险开始置满 / 大奖励回满已写好);
|
||||
# _base_override 共享引用随载荷传入(永久强化 flat——LevelRunner.effective_* 叠加序第二项)。
|
||||
# defer_entry=true 起每关(含首关)——先 emit level_started(呈现层在
|
||||
# 该回调重取 runner 实例),后入口候选呈现;顺序反了选路口会被呈现层的 runner 空防御静默丢弃。
|
||||
# 呈现零 RNG 消耗、pick 走同一 _enter_node——测量面逐位零漂移。
|
||||
_runner.set_run_ledger(run_ledger) # 共享四账单类下传(run_ledger=四账类,ledger=关卡账)
|
||||
_runner.sync_attune_inventory() # 能力件骑库存面注入(随账下传同拍)
|
||||
var ok: bool = _runner.start_level_external(level_index, ledger, purchased,
|
||||
apply_rest, _base_override, true, resume_state)
|
||||
if ok:
|
||||
_runner.set_attunement(_attunement) # 同调装配下传(enc 携带进战斗结算)
|
||||
_runner.set_feats(_feats) # 特性持有集下传(begin_battle enc.feats 求值面)
|
||||
_runner.set_tree_rules(_tree_rules) # 树 rule 键集下传(A/B 面消费)
|
||||
_runner.set_hero_axes(hero_attack_type, hero_armor_type) # 两根类型轴快照下传(effective_ac 甲型基础 AC 层+enc 携带进 spawn)
|
||||
if _shop_mode == "pool":
|
||||
# 商店共享池注入(库存账本引用+消费 handler 双下传;legacy 模式零注入零扰动。
|
||||
# cap 检查回调四参注入)
|
||||
_runner.set_shop_pool(_curio_deck, _consume_pool_purchase, _feat_ticket_consider, _curio_cap_ok)
|
||||
# 凭证件三账注入(节点级/回合级一次性:注入即清零,未用即过期=寿命档语义)
|
||||
_runner.set_voucher_grants(_pending_rap, _time_sand_queue)
|
||||
_pending_rap = 0
|
||||
_time_sand_queue = 0
|
||||
# 三选一道具件跨关携带注入面不存在(aux 卡不在池种内→ carry 恒空集;
|
||||
# 容器字段保留=替换子交互惰性承载,零投放零行为)
|
||||
level_started.emit(level_index, String(_runner.level_row.get("display_name", "")))
|
||||
# 入口停点断点(node_id=-1)与 defer_entry 同态(node_index=-1+单候选)——走入口呈现;
|
||||
# 中途停点(node_id>=0)走停点呈现(restore_treasure_items 先于呈现=C 区库存就位)。
|
||||
if resume_state.is_empty() or int(resume_state.get("node_id", -1)) < 0:
|
||||
_runner.present_entry_choice()
|
||||
else:
|
||||
_runner.restore_treasure_items(_resume_bp.get("aux_items", []) as Array)
|
||||
_runner.present_resumed_choice()
|
||||
_resume_bp = {}
|
||||
return ok
|
||||
|
||||
|
||||
# 关卡参数注入(装配集/特性/rule 键/类型轴/商店池/凭证件 + level_started 广播)
|
||||
func _inject_level_params() -> void:
|
||||
_runner.set_attunement(_attunement) # 同调装配下传(enc 携带进战斗结算)
|
||||
_runner.set_feats(_feats) # 特性持有集下传(begin_battle enc.feats 求值面)
|
||||
_runner.set_tree_rules(_tree_rules) # 树 rule 键集下传(A/B 面消费)
|
||||
_runner.set_hero_axes(hero_attack_type, hero_armor_type) # 两根类型轴快照下传(effective_ac 甲型基础 AC 层+enc 携带进 spawn)
|
||||
if _shop_mode == "pool":
|
||||
# 商店共享池注入(库存账本引用+消费 handler 双下传;legacy 模式零注入零扰动。
|
||||
# cap 检查回调四参注入)
|
||||
_runner.set_shop_pool(_curio_deck, _consume_pool_purchase, _feat_ticket_consider, _curio_cap_ok)
|
||||
# 凭证件三账注入(节点级/回合级一次性:注入即清零,未用即过期=寿命档语义)
|
||||
_runner.set_voucher_grants(_pending_rap, _time_sand_queue)
|
||||
_pending_rap = 0
|
||||
_time_sand_queue = 0
|
||||
# 三选一道具件跨关携带注入面不存在(aux 卡不在池种内→ carry 恒空集;
|
||||
# 容器字段保留=替换子交互惰性承载,零投放零行为)
|
||||
level_started.emit(level_index, String(_runner.level_row.get("display_name", "")))
|
||||
|
||||
|
||||
# 入口呈现(入口停点与中途停点两态;restore_treasure_items 先于呈现=C 区库存就位)
|
||||
func _present_level_entry(resume_state: Dictionary) -> void:
|
||||
if resume_state.is_empty() or int(resume_state.get("node_id", -1)) < 0:
|
||||
_runner.present_entry_choice()
|
||||
else:
|
||||
_runner.restore_treasure_items(_bp_read("aux_items", _resume_bp, []) as Array)
|
||||
_runner.present_resumed_choice()
|
||||
|
||||
|
||||
# 接口④关卡结束(结果 + 生命终值 + 节点位置——胜负皆报;结算受理只算一次)
|
||||
@@ -983,20 +855,62 @@ func _forward_choices_presented(candidates: Array) -> void:
|
||||
_snapshot_breakpoint() # 停点快照(选路停点=唯一可恢复停点形态;战斗中不存)
|
||||
|
||||
|
||||
# ---- 断点快照(七字段契约面+恢复必需附随件)----
|
||||
# 七字段(契约面):关卡序号/节点位/生命/属性加成区/GP 钱包/商店已购档/已抽模板;
|
||||
# 附随件(续玩正确性必需):主种子+取数计数器(rng 快照契约回填——续接原时间线)、
|
||||
# 钥石累计(大奖励中途值——终局摘要五件事不失真)、已到节点序(呈现层图条重建)、道具库存
|
||||
# (奖励节点已选件——防静默丢失)。注入缺省 null = 整面静默(模拟/冒烟零扰动)。
|
||||
# ---- 断点快照(字段登记单源:写侧拼装与读侧回退策略同登记一表)----
|
||||
# 键序即快照键序(27 顶层键——写侧/读侧/键集断言三面同源)。读侧回退策略词表:
|
||||
# plain 无特殊回退面(缺键取代码内缺省)/clamp 数值夹 ≥0/keep_on_null 缺键或类型不符保持既有态
|
||||
# dual_merge 双向合并(bonus_taken)/params_fallback 缺键回退参数面(attune_refresh_y)
|
||||
# token_migration 旧版候选形态迁移(bonus_pending.candidates)
|
||||
# 注入缺省 null = 整面静默(模拟/冒烟零扰动)。
|
||||
const BREAKPOINT_FIELDS := {
|
||||
"level_index": {"strategy": "plain"},
|
||||
"node_id": {"strategy": "plain"},
|
||||
"hero_hp": {"strategy": "clamp"},
|
||||
"bonus": {"strategy": "clamp"},
|
||||
"gp": {"strategy": "plain"},
|
||||
"shop_purchased": {"strategy": "plain"},
|
||||
"shop_shelf": {"strategy": "plain"},
|
||||
"shop_visit_buys": {"strategy": "plain"},
|
||||
"attunement_charges": {"strategy": "keep_on_null"},
|
||||
"graph_gen_v2": {"strategy": "plain"},
|
||||
"master_seed": {"strategy": "plain"},
|
||||
"rng_counter": {"strategy": "plain"},
|
||||
"keystone": {"strategy": "plain"},
|
||||
"visited_ids": {"strategy": "plain"},
|
||||
"aux_items": {"strategy": "plain"},
|
||||
"bonus_taken": {"strategy": "dual_merge"},
|
||||
"bonus_deck": {"strategy": "keep_on_null"},
|
||||
"relics": {"strategy": "plain"},
|
||||
"bonus_pending": {"strategy": "token_migration"},
|
||||
"feat_ticket_pity": {"strategy": "plain"},
|
||||
"feat_ticket_sold": {"strategy": "plain"},
|
||||
"adventure_counters": {"strategy": "keep_on_null"},
|
||||
"vision_counter_used": {"strategy": "clamp"},
|
||||
"refresh_stock": {"strategy": "clamp"},
|
||||
"attune_refresh_y": {"strategy": "params_fallback"},
|
||||
"bonus_result": {"strategy": "plain"},
|
||||
"keystone_drops": {"strategy": "plain"},
|
||||
}
|
||||
|
||||
|
||||
func _snapshot_breakpoint() -> void:
|
||||
if _save_manager == null or state != "running":
|
||||
return
|
||||
var parts: Dictionary[Variant, Variant] = _bp_parts()
|
||||
var bp: Dictionary[Variant, Variant] = {}
|
||||
for key in BREAKPOINT_FIELDS.keys():
|
||||
bp[key] = parts[key]
|
||||
_save_manager.set_breakpoint(bp)
|
||||
_save_manager.write_to_disk()
|
||||
|
||||
|
||||
# 断点写侧取值(键集以 BREAKPOINT_FIELDS 为准——未登记键即取值越界,fail-fast)
|
||||
func _bp_parts() -> Dictionary[Variant, Variant]:
|
||||
var treasure_snapshot: Array[Variant] = []
|
||||
if _runner != null and _runner.treasure_inventory != null:
|
||||
treasure_snapshot = _runner.treasure_inventory.snapshot_inventory()
|
||||
# 三选一停点续玩分支 _runner 为 null(关卡已收束、下一关未开)——节点域字段按缺省落档,
|
||||
# 该两态的续玩只消费 bonus_* 附随件(选择停点/已兑付停点快照)
|
||||
var bp: Dictionary[Variant, Variant] = {
|
||||
return {
|
||||
"level_index": level_index,
|
||||
"node_id": _runner.current_node_id() if _runner != null else -1,
|
||||
"hero_hp": int(ledger.get("hero_hp", 0)),
|
||||
@@ -1030,8 +944,33 @@ func _snapshot_breakpoint() -> void:
|
||||
"bonus_result": _curio_result.duplicate(true), # 已兑付待推进态(空 = 无——中断续玩不重兑付)
|
||||
"keystone_drops": _keystone_drops.duplicate(true), # 逐杀掉落明细(摘要归因续接)
|
||||
}
|
||||
_save_manager.set_breakpoint(bp)
|
||||
_save_manager.write_to_disk()
|
||||
|
||||
|
||||
# 读侧单点(BREAKPOINT_FIELDS 策略标签分发;未登记键 fail-fast 不静默按 plain 兜)。
|
||||
# 二次消费面(_start_current_level 经 _resume_bp 取 node_id/visited_ids/aux_items)同走此口。
|
||||
func _bp_read(key: String, bp: Dictionary, fallback: Variant = null) -> Variant:
|
||||
if not BREAKPOINT_FIELDS.has(key):
|
||||
push_error("[冒险结构] 断点字段未登记(读侧): %s" % key)
|
||||
var v: Variant = bp.get(key, fallback)
|
||||
if _bp_strategy(key) == "keep_on_null" and v != null and not (v is Dictionary or v is Array):
|
||||
return null # 类型不符——保持既有态(调用方跳过覆盖)
|
||||
return v
|
||||
|
||||
|
||||
# 读侧策略标签(BREAKPOINT_FIELDS 读侧消费面单点)
|
||||
func _bp_strategy(key: String) -> String:
|
||||
return String((BREAKPOINT_FIELDS.get(key, {}) as Dictionary).get("strategy", "plain"))
|
||||
|
||||
|
||||
# 数值类读侧(clamp 夹 ≥0 / params_fallback 断点缺键回退参数面整额注入 / plain 直取)
|
||||
func _bp_read_int(key: String, bp: Dictionary, fallback: int, params: Dictionary = {}) -> int:
|
||||
match _bp_strategy(key):
|
||||
"clamp":
|
||||
return maxi(0, int(bp.get(key, fallback)))
|
||||
"params_fallback":
|
||||
return maxi(0, int(bp.get(key, int(params.get(key, fallback)))))
|
||||
_:
|
||||
return int(bp.get(key, fallback))
|
||||
|
||||
|
||||
func _forward_step_row(row: Dictionary) -> void:
|
||||
|
||||
@@ -161,7 +161,7 @@ func feat_row(feat_id: String) -> Dictionary:
|
||||
return feats.row_by_id(feat_id) if feats != null else {}
|
||||
|
||||
|
||||
# 规则改写型特性带令牌构建源(池接线——adventure_runner._build_curio_deck 消费;行 rarity→投放档映射)
|
||||
# 规则改写型特性带令牌构建源(池接线——CurioDeck.build 消费;行 rarity→投放档映射)
|
||||
func feat_rows() -> Array:
|
||||
return feats.rows if feats != null else []
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
## 令牌池件:三选一/商店共享池的持久多重集、抽签、库存与专长凭证判定唯一所有者(自 adventure_runner 拆出,零行为变更)。
|
||||
## 停点编排与兑付推进不在此件(留冒险编排器)——件只管池与判定;随机只消费注入的 RngServiceCore(reward_pick 封闭流)。
|
||||
## 宿主兑付单点经 bind_handlers 注入方法 Callable(选中摘牌后的加算区/账本落点仍在宿主)。
|
||||
class_name CurioDeck
|
||||
extends RefCounted
|
||||
|
||||
var db: ConfigDb
|
||||
var rng: RngServiceCore
|
||||
var deck: Array[Variant] = [] # 令牌多重集(元素 {"kind","tier"};relic 令牌带 feat_id)
|
||||
var taken: Dictionary[Variant, Variant] = {} # 件获取次数计数(kind 维度派生账本;池库存现行事实源 = deck)
|
||||
var ticket_pity := 0 # 专长凭证保底计数(展示次数;触发清零)
|
||||
var ticket_sold := 0 # 专长凭证限购计数(选中/买下即 +1)
|
||||
var ticket_appeared := 0 # 专长凭证出现计数(quota 上限面)
|
||||
var _apply_pick := Callable() # 宿主兑付回调(AdventureRunner._apply_curio_pick)
|
||||
var _redeem_ticket := Callable() # 宿主凭证兑付回调(AdventureRunner._redeem_feat_ticket)
|
||||
|
||||
|
||||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
|
||||
db = db_ref
|
||||
rng = rng_ref
|
||||
|
||||
|
||||
# 宿主回调注入(商店购买消费面经此回宿主兑付单点——core↔core 方法 Callable,禁 lambda 捕获 self)
|
||||
func bind_handlers(apply_pick: Callable, redeem_ticket: Callable) -> void:
|
||||
_apply_pick = apply_pick
|
||||
_redeem_ticket = redeem_ticket
|
||||
|
||||
|
||||
# 池与计数归零(宿主停点复位调用的池半;令牌池按表配额重建——池消耗即上限,账本随池重生)
|
||||
func reset() -> void:
|
||||
taken = {"attack": 0, "max_hp": 0, "armor": 0, "gp": 0, "ap": 0, "heal": 0}
|
||||
deck = build()
|
||||
|
||||
|
||||
# 令牌池构建(按表 kind×tier 配额展开为持久多重集;构建序确定 = CURIO_KINDS×TIERS 逐层,
|
||||
# 随机性只发生在洗牌消费 reward_pick 封闭流的时刻。规则改写型特性带 = relic 活行每件 1 张追加;
|
||||
# relic 令牌档 = 行 rarity 直映射,词表外 transitional 值回退映射 rarest→rare/其余→excellent)
|
||||
func build() -> Array:
|
||||
var out: Array[Variant] = []
|
||||
for kind in db.adventure.CURIO_KINDS:
|
||||
for tier in db.adventure.TIERS:
|
||||
for i in int(db.adventure.curio_quota(String(kind), String(tier))):
|
||||
out.append({"kind": String(kind), "tier": String(tier)})
|
||||
for row in db.feat_rows():
|
||||
var rraw := String(row.get("rarity", ""))
|
||||
var rtier := rraw if db.adventure.TIERS.has(rraw) else ("rare" if rraw == "rarest" else "excellent")
|
||||
out.append({"kind": "relic", "tier": rtier, "feat_id": String(row.get("feat_id", ""))})
|
||||
return out
|
||||
|
||||
|
||||
# 令牌唯一身份(抽签去重/兑付匹配共用——属性加成型特性=kind,规则改写型特性=feat_id;同档多张不同 relic 可同屏)
|
||||
func token_identity(tok: Dictionary) -> String:
|
||||
if String(tok.get("kind", "")) == "relic":
|
||||
return "relic:" + String(tok.get("feat_id", ""))
|
||||
return String(tok.get("kind", ""))
|
||||
|
||||
|
||||
# 候选令牌匹配(身份键恒等——属性加成型特性 kind+tier、规则改写型特性 feat_id)
|
||||
func find_token(candidates: Array, kind: String, tier: String, feat_id: String = "") -> Dictionary:
|
||||
for t in candidates:
|
||||
var tok: Dictionary[Variant, Variant] = t as Dictionary
|
||||
if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier:
|
||||
continue
|
||||
if kind == "relic" and String(tok.get("feat_id", "")) != feat_id:
|
||||
continue
|
||||
return tok
|
||||
return {}
|
||||
|
||||
|
||||
# 库存余量(stock = 牌库内该 kind 剩余令牌数——「池消耗即上限」,无种类次数上限;
|
||||
# 奖励三选一兑付摘牌 与 商店购买消费 由同一 pool 承载——两渠道跨关共库,全冒险一次性)
|
||||
func pool_stock(kind: String) -> int:
|
||||
var n := 0
|
||||
for t in deck:
|
||||
if String((t as Dictionary).get("kind", "")) == kind:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
# 终局池快照(宿主清账前调用——复位会重置 taken,摘要 additive 键须在此前取数;
|
||||
# 读数面 = 商店买走 vs 奖励领走 vs 枯竭)
|
||||
func stock_snapshot() -> Dictionary:
|
||||
var out := {}
|
||||
for row in db.shop_pool_rows():
|
||||
var kind := String(row.get("kind_id", ""))
|
||||
out[kind] = pool_stock(kind)
|
||||
return out
|
||||
|
||||
|
||||
# 从牌库摘除一枚令牌(选中踢池单点;relic 按 feat_id 精确摘;找不到 = false)
|
||||
func consume_deck_token(kind: String, tier: String, feat_id: String = "") -> bool:
|
||||
for i in deck.size():
|
||||
var tok: Dictionary[Variant, Variant] = deck[i] as Dictionary
|
||||
if String(tok.get("kind", "")) != kind or String(tok.get("tier", "")) != tier:
|
||||
continue
|
||||
if kind == "relic" and String(tok.get("feat_id", "")) != feat_id:
|
||||
continue
|
||||
deck.remove_at(i)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# 商店购买消费入口(LevelRunner.buy_pool_item 经 Callable 调用):专长凭证买下即开(零摘牌不进持有集);
|
||||
# 其余件同口回宿主兑付单点(消费在架令牌本体 kind+tier——写加算区+账本+摘牌与奖励三选一真单点)。
|
||||
# 未知 kind 拒绝(货架词表封闭的前置防御)。
|
||||
func consume_pool_purchase(kind: String, tier: String) -> bool:
|
||||
if kind == "feat_ticket":
|
||||
return bool(_redeem_ticket.call())
|
||||
if not db.adventure.CURIO_KINDS.has(kind):
|
||||
push_error("[冒险结构] 商店购买消费拒绝——件不在池词表: %s" % kind)
|
||||
return false
|
||||
return bool(_apply_pick.call(kind, tier, int(db.adventure.curio_value(kind, tier))))
|
||||
|
||||
|
||||
# 已拿上限判定(身份维度:kind 走 curio_cap、relic 走 feats 行 cap 字段;
|
||||
# taken 只在兑付时 +1〔拿了才计〕、刷出不计)。缺省放行=白名单外 kind 兜底。
|
||||
func cap_ok(kind: String, feat_id: String = "") -> bool:
|
||||
var cap := 99
|
||||
if kind == "relic":
|
||||
var row: Dictionary[Variant, Variant] = db.feat_row(feat_id)
|
||||
cap = int(row.get("cap", 1)) if not row.is_empty() else 1
|
||||
return taken.get("relic:" + feat_id, 0) < cap
|
||||
if not db.adventure.CURIO_KINDS.has(kind):
|
||||
return true
|
||||
cap = int(db.adventure.curio_cap(kind))
|
||||
return int(taken.get(kind, 0)) < cap
|
||||
|
||||
|
||||
# 专长凭证展示判定(四口径:一局限购一次/罕见只在概率不在价格/计步保底/触发清零。
|
||||
# 调用即一次展示判定与记账——展示次数计 pity、配额与限购双闸)
|
||||
func feat_ticket_consider(rg: RandomNumberGenerator) -> bool:
|
||||
if ticket_sold >= int(db.adventure.feat_ticket_limit):
|
||||
return false # 限购满——不再触发(保底冻结)
|
||||
if ticket_appeared >= int(db.adventure.feat_ticket_quota):
|
||||
return false # 配额满——本局不再出现
|
||||
var offer: bool = ticket_pity >= int(db.adventure.feat_ticket_pity_step) or rg.randi_range(1, 100) <= int(db.adventure.feat_ticket_chance)
|
||||
if offer:
|
||||
ticket_pity = 0
|
||||
ticket_appeared += 1
|
||||
else:
|
||||
ticket_pity += 1
|
||||
return offer
|
||||
|
||||
|
||||
# 令牌池洗牌不放回抽签(Fisher-Yates 全库洗牌后按序收集;同 kind 去重每 kind 至多一枚;
|
||||
# kind 出现率∝剩余令牌数〔档位配额即分布——无独立权重 knob〕;抽签不摘牌、兑付才摘牌;池耗即枯竭)。
|
||||
# 渠道白名单在候选视图层生效(过滤式而非物理拆池):level_clear=关底 Boss 渠道只出 relic;
|
||||
# reward_node=非 relic 令牌+cap 剔除,专长只以凭证形态注入末位。消费 reward_pick 封闭流——一次停点恰一 scope。
|
||||
func draw_candidates(channel: String = "reward_node") -> Array:
|
||||
var pool: Array[Variant] = _candidate_pool(channel)
|
||||
var rg: RandomNumberGenerator = rng.open_stream("reward_pick")
|
||||
if channel == "level_clear":
|
||||
return _draw_weighted(pool, rg)
|
||||
var out := _draw_exposure(pool, rg)
|
||||
# T5 专长凭证注入(非 Boss 渠道唯一专长入口——替换末位候选占一席,候选数不变;判定含 pity 清零记账)
|
||||
if channel == "reward_node" and not out.is_empty() and feat_ticket_consider(rg):
|
||||
out[out.size() - 1] = {"kind": "feat_ticket", "tier": "excellent"}
|
||||
return out
|
||||
|
||||
|
||||
# 候选池视图(白名单+cap 剔除;牌库本体不动——过滤只在视图层)
|
||||
func _candidate_pool(channel: String) -> Array[Variant]:
|
||||
var pool: Array[Variant] = []
|
||||
for tok in deck:
|
||||
var k := String((tok as Dictionary).get("kind", ""))
|
||||
if channel == "level_clear":
|
||||
if k == "relic":
|
||||
pool.append(tok)
|
||||
continue
|
||||
if k == "relic" or k == "feat_ticket":
|
||||
continue
|
||||
var t: Dictionary[Variant, Variant] = tok as Dictionary
|
||||
if cap_ok(k, String(t.get("feat_id", ""))):
|
||||
pool.append(tok)
|
||||
return pool
|
||||
|
||||
|
||||
# Boss 渠道按档配比加权抽取(无放回加权抽:逐席按 boss_feat_weight 掷点,身份去重;
|
||||
# 配比数值归 adventure 表)
|
||||
func _draw_weighted(pool: Array[Variant], rg: RandomNumberGenerator) -> Array:
|
||||
var out: Array[Variant] = []
|
||||
var seen: Array[Variant] = []
|
||||
var want := int(db.adventure.curio_candidate_count)
|
||||
while out.size() < want and not pool.is_empty():
|
||||
var total := _pool_weight(pool)
|
||||
if total <= 0:
|
||||
break
|
||||
var idx := _weighted_index(pool, rg.randi_range(1, total))
|
||||
var tok: Dictionary[Variant, Variant] = pool[idx]
|
||||
var ident := token_identity(tok)
|
||||
if seen.has(ident):
|
||||
pool.remove_at(idx) # 同身份已出——剔牌重掷(无放回)
|
||||
continue
|
||||
seen.append(ident)
|
||||
out.append({"kind": String(tok.get("kind", "")), "tier": String(tok.get("tier", "")), "feat_id": String(tok.get("feat_id", ""))})
|
||||
pool.remove_at(idx)
|
||||
return out
|
||||
|
||||
|
||||
func _pool_weight(pool: Array[Variant]) -> int:
|
||||
var total := 0
|
||||
for tok in pool:
|
||||
total += db.adventure.boss_feat_weight(String((tok as Dictionary).get("tier", "")))
|
||||
return total
|
||||
|
||||
|
||||
func _weighted_index(pool: Array[Variant], roll: int) -> int:
|
||||
for i in pool.size():
|
||||
roll -= db.adventure.boss_feat_weight(String((pool[i] as Dictionary).get("tier", "")))
|
||||
if roll <= 0:
|
||||
return i
|
||||
return 0
|
||||
|
||||
|
||||
# 曝光槽抽签(普通档打底 n_full 格 + 优秀档以上子池 1 格;子池空/枯竭回退全池续取——
|
||||
# 同 stream 续消费,确定性保持)
|
||||
func _draw_exposure(pool: Array[Variant], rg: RandomNumberGenerator) -> Array:
|
||||
_shuffle(pool, rg)
|
||||
var sub: Array[Variant] = []
|
||||
for tok in pool:
|
||||
if db.adventure.tier_rank(String((tok as Dictionary).get("tier", ""))) >= db.adventure.tier_rank(db.adventure.TIER_EXCELLENT):
|
||||
sub.append(tok)
|
||||
_shuffle(sub, rg)
|
||||
var out: Array[Variant] = []
|
||||
var seen: Array[Variant] = []
|
||||
var want := int(db.adventure.curio_candidate_count)
|
||||
var n_full := maxi(1, want - 1) if want >= 2 else want
|
||||
while out.size() < want:
|
||||
var src: Array[Variant] = sub if out.size() >= n_full and not sub.is_empty() else pool
|
||||
if not _collect_one(src, out, seen):
|
||||
if out.size() >= n_full and not sub.is_empty() and not pool.is_empty():
|
||||
sub = [] # 子池枯竭——曝光槽回退全池续取
|
||||
continue
|
||||
break # 全池枯竭——出齐剩余(池空直通同族兜底)
|
||||
return out
|
||||
|
||||
|
||||
# 收集器:按 src 序取首个未出身份(同身份跳过)
|
||||
func _collect_one(src: Array[Variant], out: Array[Variant], seen: Array[Variant]) -> bool:
|
||||
for tok in src:
|
||||
var ident := token_identity(tok)
|
||||
if seen.has(ident):
|
||||
continue
|
||||
seen.append(ident)
|
||||
var cand := {"kind": String((tok as Dictionary).get("kind", "")), "tier": String((tok as Dictionary).get("tier", ""))}
|
||||
if ident.begins_with("relic:"):
|
||||
cand["feat_id"] = String((tok as Dictionary).get("feat_id", ""))
|
||||
out.append(cand)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _shuffle(list: Array[Variant], rg: RandomNumberGenerator) -> void:
|
||||
for i in list.size():
|
||||
var j := rg.randi_range(i, list.size() - 1)
|
||||
var tmp = list[i]
|
||||
list[i] = list[j]
|
||||
list[j] = tmp
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwgji8w1qv3js
|
||||
+79
-221
@@ -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()
|
||||
@@ -0,0 +1,339 @@
|
||||
## 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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwqf28xjs0tnf
|
||||
@@ -181,8 +181,32 @@ func _init() -> void:
|
||||
Kit.check_quiet(adr_n.start_adventure(910042, params) and adr_n.runner().nodes() == graph_j,
|
||||
"P7 同种子重开局同图(开局入口复现承载)〔v7〕")
|
||||
|
||||
# ---- Part 8:断点字段登记单源(登记键集 == 写侧实拼字典键集;键名增删即红)----
|
||||
var fields: Dictionary = AdventureRunnerScript.BREAKPOINT_FIELDS
|
||||
var reg_keys: Array = fields.keys()
|
||||
reg_keys.sort()
|
||||
var bp_keys: Array = bp_j.keys()
|
||||
bp_keys.sort()
|
||||
Kit.check_quiet(reg_keys == bp_keys,
|
||||
"P8 登记单源键集 == 写侧实拼断点键集(%d 键逐一对齐)〔wr2〕" % reg_keys.size())
|
||||
var frozen_keys := ["adventure_counters", "attunement_charges", "attune_refresh_y", "aux_items",
|
||||
"bonus", "bonus_deck", "bonus_pending", "bonus_result", "bonus_taken", "feat_ticket_pity",
|
||||
"feat_ticket_sold", "gp", "graph_gen_v2", "hero_hp", "keystone", "keystone_drops",
|
||||
"level_index", "master_seed", "node_id", "refresh_stock", "relics", "rng_counter",
|
||||
"shop_purchased", "shop_shelf", "shop_visit_buys", "visited_ids", "vision_counter_used"]
|
||||
frozen_keys.sort()
|
||||
Kit.check_quiet(reg_keys == frozen_keys,
|
||||
"P8 键集与冻结清单逐位一致(27 键契约;增删键即红)〔wr2〕")
|
||||
var vocab := ["plain", "clamp", "keep_on_null", "dual_merge", "params_fallback", "token_migration"]
|
||||
var stray: Array = []
|
||||
for k in fields.keys():
|
||||
var strat := String((fields[k] as Dictionary).get("strategy", ""))
|
||||
if not vocab.has(strat):
|
||||
stray.append("%s=%s" % [k, strat])
|
||||
Kit.check_quiet(stray.is_empty(), "P8 策略标签全落六类词表(越界=%s)〔wr2〕" % str(stray))
|
||||
|
||||
_clear_disk()
|
||||
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归/生成图换代 全绿"))
|
||||
quit(Kit.finish("breakpoint_resume_test", "PASS — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归/生成图换代/字段单源 全绿"))
|
||||
|
||||
|
||||
# 句柄寻址夹具辅助:取生成图中指定句柄节点的首个出边目标(构造中途停点落档形态)
|
||||
|
||||
Reference in new issue
Block a user