diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 9929346e..a91d904e 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -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:)随快照还原 # 令牌池余库还原(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: diff --git a/game/core/config_db.gd b/game/core/config_db.gd index 4b316db8..ea4362e0 100644 --- a/game/core/config_db.gd +++ b/game/core/config_db.gd @@ -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 [] diff --git a/game/core/curio_deck.gd b/game/core/curio_deck.gd new file mode 100644 index 00000000..5de243f2 --- /dev/null +++ b/game/core/curio_deck.gd @@ -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 diff --git a/game/core/curio_deck.gd.uid b/game/core/curio_deck.gd.uid new file mode 100644 index 00000000..97abd3ef --- /dev/null +++ b/game/core/curio_deck.gd.uid @@ -0,0 +1 @@ +uid://dwgji8w1qv3js diff --git a/game/core/level_runner.gd b/game/core/level_runner.gd index 2cedb72f..295a8c1b 100644 --- a/game/core/level_runner.gd +++ b/game/core/level_runner.gd @@ -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() diff --git a/game/core/shop_session.gd b/game/core/shop_session.gd new file mode 100644 index 00000000..f44ccbc5 --- /dev/null +++ b/game/core/shop_session.gd @@ -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) diff --git a/game/core/shop_session.gd.uid b/game/core/shop_session.gd.uid new file mode 100644 index 00000000..e4af0d6c --- /dev/null +++ b/game/core/shop_session.gd.uid @@ -0,0 +1 @@ +uid://bwqf28xjs0tnf diff --git a/game/tests/unit/core/breakpoint_resume_test.gd b/game/tests/unit/core/breakpoint_resume_test.gd index e6eba55c..8ce75e20 100644 --- a/game/tests/unit/core/breakpoint_resume_test.gd +++ b/game/tests/unit/core/breakpoint_resume_test.gd @@ -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 — 断点往返/续玩确定性/幂等/退出清断点/装配存档回归/生成图换代/字段单源 全绿")) # 句柄寻址夹具辅助:取生成图中指定句柄节点的首个出边目标(构造中途停点落档形态)