## 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