239 lines
10 KiB
GDScript
239 lines
10 KiB
GDScript
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||
## 令牌池件:三选一/商店共享池的持久多重集、抽签、库存与专长凭证判定唯一所有者(自 adventure_runner 拆出,零行为变更)。
|
||
## 停点编排与兑付推进归 CurioStop 件——件只管池与判定(纯池化:反向回调退役);随机只消费注入的 RngServiceCore(reward_pick 封闭流)。
|
||
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 上限面)
|
||
|
||
|
||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
|
||
db = db_ref
|
||
rng = rng_ref
|
||
|
||
|
||
# 池与计数归零(宿主停点复位调用的池半;令牌池按表配额重建——池消耗即上限,账本随池重生)
|
||
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
|
||
|
||
|
||
# 已拿上限判定(身份维度: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
|