Files
gplan-2/game/core/reward_economy.gd
T
DKChan 96d31705b3 feat(卡② T3): 7 施加点 ctx 化 + dig_combo 死路删除 + run_ledger 搬运链方法删除
- action_economy/damage_controller/reward_economy: dispatch 加 _ctx、写口改 EffectEngine.counter_add/counter(_ctx,...)
- battle_orchestrator: _fx_emit→dispatch(counters_ctx)、counter_spend 静态化、删 counters_seed 回灌与 counters_export 战末导出、_feat_yield_adjust 删 dig_combo 死读数与 combo_cap 第五参(零行为差)
- grant_segment: counter 读/value_counter/per ctx 化、_apply_counter_hit 写口静态化、combo_cap 收集删除
- run_ledger: 删 counters_seed_snapshot/counters_merge_export + 头注/字段注改 ctx 直写口径
数据行 domain/reset_at 删除与 feats.tres id 改名见 T6;核心层调用面已对齐新 API。
2026-10-20 23:02:13 +08:00

369 lines
14 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
## 格面资源身份层:install 期一次性预分配每格资源身份,授予期零随机、Ready 后只读无重掷(垂直切片验证:格面资源同种子逐位回放)。
## roll_board 每板恰一次(Ready 相位再调 = double-roll 拒绝;换图 = 合法新批);禁在本类引入授予期随机或重掷。
## 极性路由归战斗结算授予段——本类只提供 polarity_row 的(类型, 极性)→ 字典行查询面。
class_name RewardEconomy
extends RefCounted
var db: ConfigDb
var rng: RngServiceCore
var _phase := "unrolled" # unrolled → ready → retired
var _eligible: Array[Dictionary] = [] # [{cell}] 行主序规范序
var _mapping := {} # Vector2i → reward_id(正向行身份——格面资源类型由 cell_type 承载)
var _power_roll := {} # Vector2i → 区间行 roll 值(身份批内追加取号)
var _clear_power := -1 # 清图槽区间行 roll 值(-1 = 定值行)
var _granted := {} # Vector2i → 快照(重复受理幂等返回)
var _clear_identity := ""
var _clear_granted := false
var _clear_snapshot := {}
# 效果引擎(reward_roll 发射面;偏运硬币 bias_rewards 订阅面)
var _fx: EffectEngine = null
var _ctx: EffectContext = null
func set_fx(fx: EffectEngine, ctx: EffectContext) -> void:
_fx = fx
_ctx = ctx
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
db = db_ref
rng = rng_ref
# ---- install 期 roll(恰一次;Ready 相位再调拒绝)----
# eligible = 全部非封锁格(安全格 + 危险格,战斗结算 install 编排供给;封锁格不入清单)
func roll_board(eligible: Array) -> bool:
# 相位守卫:Ready 再调 = double-roll 拒绝(E5);Retired(换图后)= 合法新批重置(E6 roll 半)
if _phase == "ready":
push_warning("[地图战斗资源] roll_board 相位守卫拒绝(E5——double-roll 编排违约显式暴露)")
return false
if eligible.is_empty():
_fatal("roll_board 空资格清单(E1 兜底 fail-fast——排雷玩法 不变式④前置)")
return false
# 行主序规范化 + 去重(排序责任不外推给 战斗结算;排序键单点 BoardData.row_major_key)
var seen := {}
var list: Array[Dictionary] = []
for e in eligible:
var c: Vector2i = e.cell
if seen.has(c):
push_warning("[地图战斗资源] 资格清单重复格已去重: %s" % str(c))
continue
seen[c] = true
list.append({"cell": c})
list.sort_custom(func(a, b): return BoardData.row_major_key(a.cell) < BoardData.row_major_key(b.cell))
_eligible = list
_granted.clear() # 新批重置映射受理面(旧图快照消亡)
_power_roll.clear()
_clear_power = -1
_clear_granted = false
_clear_snapshot = {}
var stream: RandomNumberGenerator = rng.open_stream("reward_roll") # 恰一 scope 批(格批 + 清图批尾 + 区间行追加取号)
# reward_roll 发射+施加(格面资源掷定、摇号前权重合成层:
# bias_rewards 订阅命中合计=偏运权重加算(行 value_from magnitude 定影);零命中=原行集零扰动)
var luck_bias := 0
if _fx != null:
for h in _fx.dispatch(_ctx, "reward_roll", {"polarity": "positive", "cell_count": list.size()}):
if String(h.get("kind", "")) == "bias_rewards":
luck_bias += int(h.get("value", 0))
var cell_rows: Array[Variant] = _biased_cell_rows(luck_bias) # cell 池正向行 weight>0(偏运硬币装配时稀有正向 kinds 权重 +N——池结构零触碰)
var clear_rows: Array[Variant] = _pool_rows("clear_default")
# ① 身份批:每非封锁格恰一类型身份(雷安同池——同一行集同一权重分布)
var draft := {}
for e in list:
draft[e.cell] = weighted_pick(cell_rows, stream.randi_range(0, _pool_weight(cell_rows) - 1))
# ② 清图槽批尾:同流同批判尾摇定清图奖励对象身份
var clear_rid := weighted_pick(clear_rows, stream.randi_range(0, _pool_weight(clear_rows) - 1))
# ③ 区间行 power roll(身份定稿后逐格恰一追加取号,定值行零取号)
for cell in draft.keys():
var row: Dictionary[Variant, Variant] = _row_by_id(draft[cell])
if row.has("power_min"):
_power_roll[cell] = stream.randi_range(int(row.power_min), int(row.power_max))
var clear_row: Dictionary[Variant, Variant] = _row_by_id(clear_rid)
if clear_row.has("power_min"):
_clear_power = stream.randi_range(int(clear_row.power_min), int(clear_row.power_max))
_mapping = draft
_clear_identity = clear_rid
_phase = "ready"
return true
# ---- 查询面(Ready 只读;未开格恒可查——秘宝 桥接结构前提)----
# 相位只读出口(诊断观测面——unrolled/ready/retired;消费方禁直读 _phase 下划线字段)
func phase() -> String:
return _phase
# 静默映射查询(批量工具用——零 E19 诊断;语义 = query_identity 非 null 的布尔投影)
func is_mapped(cell: Vector2i) -> bool:
return _phase == "ready" and _mapping.has(cell)
func query_identity(cell: Vector2i):
if _phase != "ready":
_diag("query_identity 非 Ready 相位")
return null
var rid: String = _mapping.get(cell, "")
if rid == "":
_diag("query_identity 清单外坐标(E19——未分配非错误态;封锁格不入清单)")
return null
var row: Dictionary[Variant, Variant] = _row_by_id(rid)
return {"reward_id": rid, "cell_type": String(row.cell_type), "tier": int(row.tier)}
# 身份完整快照(轮内刷新批次隔离用——facts 缓冲期捕获,授予期 mapping 可能已换批):
# 返回与 grant 相同结构的快照(cell_type/display_name/power/effect_id/stacks 齐备),
# 供 战斗结算 冻结授予直通
func query_full_snapshot(cell: Vector2i):
if _phase != "ready":
_diag("query_full_snapshot 非 Ready 相位")
return null
var rid: String = _mapping.get(cell, "")
if rid == "":
return null
return _snapshot(_row_by_id(rid), int(_power_roll.get(cell, -1)))
# ---- 受理面(战斗结算 编排期调用;E6 收口:失败统一返回 null)----
func grant(cell: Vector2i):
if _phase != "ready":
_diag("grant 非 Ready 相位(E4/E7)")
return null
if not _mapping.has(cell):
_diag("grant 清单外坐标(E19)")
return null
if _granted.has(cell):
_diag("grant 同格重复受理(E18 幂等兜底——受理恰一次归 战斗结算)")
return _granted[cell]
var snap := _snapshot(_row_by_id(_mapping[cell]), int(_power_roll.get(cell, -1)))
_granted[cell] = snap
return snap
func grant_clear():
if _phase != "ready":
_diag("grant_clear 非 Ready 相位")
return null
if _clear_granted:
_diag("grant_clear 同批次重调(E18 幂等兜底)")
return _clear_snapshot
var row: Dictionary[Variant, Variant] = _row_by_id(_clear_identity)
_clear_snapshot = _snapshot(row, _clear_power)
_clear_granted = true
return _clear_snapshot
func retire_mapping() -> bool:
if _phase != "ready":
_diag("retire_mapping 非 Ready 相位(幂等受理族)")
return false
_phase = "retired"
_mapping.clear()
_power_roll.clear()
_clear_power = -1
_clear_identity = ""
return true
# ---- 极性路由字典面(战斗结算 授予段消费):(类型, 极性)→ 格面池字典行(拷贝出口)----
func polarity_row(cell_type: String, polarity: String) -> Dictionary:
for row in db.reward.rows:
if String(row.get("pool_tag", "")) == "cell" \
and String(row.get("cell_type", "")) == cell_type \
and String(row.get("polarity", "")) == polarity:
return row.duplicate() # E6:表行值拷贝出口——消费方禁改配置源
_fatal("极性字典缺行(八型×正负齐备为加载前提): %s %s" % [cell_type, polarity])
return {}
# ---- F1 选行算子纯函数 ----
# 偏运硬币 N(reward_roll bias_rewards 订阅命中合计经 _biased_cell_rows 入参;0 = 未装配零扰动)
# 稀有正向资源 kinds(底稿 §3——权重投影面,池结构零触碰)
const LUCK_BIAS_KINDS := ["heal", "gp", "pierce", "magic"]
# 图级偏斜旋钮(battle_orchestrator 装板决议注入,全局型行
# effect_value 语义=生存族权重下调百分比;0 = 中性 off 态。每板决议时显式重置/写入)
var board_skew: int = 0
# 偏斜族(需求卡片 §二:进攻族等比上浮/生存族按 v% 下调/白名单豁免恒不动——判定键=cell_type;
# 其余 cell_type(weaken/gp/action 白名单与未来扩型)= 豁免;负向行不入摇号已在 _rollable_rows 滤除)
const SKEW_DOWN_KINDS := ["heal", "defense"]
const SKEW_UP_KINDS := ["attack", "blast", "pierce", "magic"]
# 偏运硬币权重投影(底稿 §3:只动池内权重、雷安仍同池同分布;luck_bias=0 逐位返回原行集——off 态零扰动)
# 合成序:先经图级偏斜投影再叠件级加法——合成序锚=图级先、件级后
#(环境事实先定型、玩家构筑叠其上;两投影均为池内权重面,雷安同池与取号次数零变化)
func _biased_cell_rows(bias: int) -> Array:
var base: Array[Variant] = _skewed_cell_rows()
if bias <= 0:
return base
var out: Array[Variant] = []
for row in base:
if LUCK_BIAS_KINDS.has(String(row.get("cell_type", ""))):
var biased: Dictionary[Variant, Variant] = row.duplicate()
biased["weight"] = int(row.weight) + bias
out.append(biased)
else:
out.append(row)
return out
# 图级资源面偏斜投影(整数最大余数法:生存族按 board_skew% 下调,下调量按进攻族
# 原权重比例整数重分配,合计恒等于原池合计 77;负向行与白名单三行逐位不动)。
# board_skew<=0 逐位返回原行集 = off 态零扰动第一断言:不进投影域、行集/行序/取号域与基线逐位同。
func _skewed_cell_rows() -> Array:
if board_skew <= 0:
return _rollable_rows()
var base: Array[Variant] = _rollable_rows()
var down: Array[Variant] = []
var up: Array[Variant] = []
for row in base:
var k := String(row.get("cell_type", ""))
if SKEW_DOWN_KINDS.has(k):
down.append(row)
elif SKEW_UP_KINDS.has(k):
up.append(row)
if down.is_empty() or up.is_empty():
return base # 结构缺族 = 无偏斜作用面(保守直通——池结构变更须走表加载校验另批)
var v := board_skew
# 下调总量 = round_half_up(Σ生存×v/100);逐行精确份额 w×v/100 最大余数取整(行序平局)
var drops := _largest_remainder_split(down, v, 100, (2 * _pool_weight(down) * v + 100) / 200)
# 上浮总量 = 下调总量(守恒),逐行精确份额 drop×w/Σ进攻 最大余数取整
var gains := _largest_remainder_split(up, drops_total(drops), _pool_weight(up), drops_total(drops))
var di := 0
var ui := 0
var out: Array[Variant] = []
for row in base:
var k := String(row.get("cell_type", ""))
var w := int(row.weight)
if SKEW_DOWN_KINDS.has(k):
w -= drops[di]
di += 1
elif SKEW_UP_KINDS.has(k):
w += gains[ui]
ui += 1
assert(w > 0, "图级偏斜投影产生非正权重(池结构变更须走另批重审)")
var skewed: Dictionary[Variant, Variant] = row.duplicate()
skewed["weight"] = w
out.append(skewed)
assert(_pool_weight(out) == _pool_weight(base), "图级偏斜守恒破(偏斜后合计须等于原池)")
return out
func drops_total(amounts: Array[int]) -> int:
var t := 0
for a in amounts:
t += a
return t
# 最大余数整数分摊(行序平局——确定性):把 total 单位按精确份额 w_i×numer/denom 分到 rows
# (floor 后余数单位依次给分数最大者,平局取行序在前)——Σ结果恒 = total
static func _largest_remainder_split(rows: Array, numer: int, denom: int, total: int) -> Array[int]:
var floors: Array[int] = []
var fracs: Array[int] = []
var used := 0
for row in rows:
var exact: int = int(row.weight) * numer
floors.append(int(exact / denom))
fracs.append(exact % denom)
used += int(exact / denom)
var order: Array[int] = []
for i in rows.size():
order.append(i)
order.sort_custom(func(a: int, b: int) -> bool:
if fracs[a] != fracs[b]:
return fracs[a] > fracs[b]
return a < b)
var out: Array[int] = floors
var rest: int = total - used
assert(rest >= 0 and rest <= order.size(), "最大余数分摊余数越界(不应可达)")
for j in rest:
out[order[j]] += 1
return out
func weighted_pick(rows: Array, r: int) -> String:
var total := _pool_weight(rows)
assert(r >= 0 and r < total, "weighted_pick 取号越域(纯函数域守卫)")
var acc := 0
for row in rows:
acc += int(row.weight)
if r < acc:
return String(row.reward_id)
_fatal("weighted_pick 未命中(不应可达)")
return ""
# aux 解锁门控集(「解锁→进投放池」:未解锁 aux 行不入摇号池。
# 空集默认 = 全部 aux 不投放(白板/夹具态零投放——模拟读数零扰动;真实侧 main 组装工坊解锁集传入)
var _treasure_unlocked := {}
func set_treasure_unlocked(ids: Dictionary) -> void:
_treasure_unlocked = ids
func _rollable_rows() -> Array:
# 摇号池 = cell 池正向行 weight>0(负向行纯字典不消费权重;封锁格不在本函数职责域——清单由调用方裁剪)
var out: Array[Dictionary] = []
for row in db.reward.rows:
if String(row.get("cell_type", "")) == RewardTable.TREASURE_ITEM_TYPE:
var iid := String(row.get("effect_id", ""))
if iid == "" or not _treasure_unlocked.has(iid):
continue
if String(row.get("pool_tag", "")) == "cell" and String(row.get("polarity", "")) == "positive" and int(row.weight) > 0:
out.append(row)
return out
func _pool_rows(pool_tag: String) -> Array:
var out: Array[Dictionary] = []
for row in db.reward.rows:
if row.pool_tag == pool_tag and int(row.weight) > 0:
out.append(row)
return out
func _pool_weight(rows: Array) -> int:
var w := 0
for row in rows:
w += int(row.weight)
return w
func _row_by_id(rid: String) -> Dictionary:
for row in db.reward.rows:
if row.reward_id == rid:
return row
return {}
func _snapshot(row: Dictionary, rolled_power: int = -1) -> Dictionary:
var snap := {
"reward_id": String(row.reward_id),
"cell_type": String(row.cell_type),
"display_name": String(row.display_name),
"power": int(row.power) if rolled_power < 0 else rolled_power, # 区间行 = 身份批 roll 值(E14 授予时快照口径)
"tier": int(row.tier),
"stacks": int(row.get("stacks", 1)),
}
if String(row.get("effect_id", "")) != "":
snap["effect_id"] = String(row.effect_id)
return snap
func _diag(msg: String) -> void:
push_warning("[地图战斗资源] " + msg)
func _fatal(msg: String) -> void:
push_error("[地图战斗资源/fatal] " + msg)
assert(false, msg)