Files
gplan-2/game/core/curio_deck.gd
T

239 lines
10 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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