- curio_deck.gd(新增):令牌多重集/抽签/cap/凭证三计数与判定唯一宿主,随机经注入 rg 面;宿主公开面委托保持原签名 - shop_session.gd(新增):双模式货架/定价/购买/刷新入件,钱包原语留宿主经回调落账 - adventure_runner:BREAKPOINT_FIELDS 27 键登记单源驱动写侧拼装与两处读侧回退;续玩段拆为六小函数 - level_runner:商店族 21 函数转委托层,9 个成员改转发属性(类变量计数保持 38) - breakpoint_resume_test:新增 P8 键集==写侧实拼键集与策略标签词表断言〔wr2〕
260 lines
11 KiB
GDScript
260 lines
11 KiB
GDScript
## 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
|