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

362 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
# Validation Question: 每个非封锁格是否都携带可兑的格面资源身份,且同种子同操作逐位回放
# Date: 2026-09-10(2026-09-15 格面资源批结构性重写:库存/供给轴下线,格面资源身份面纯化)
# 地图战斗资源:格面资源的身份层——install 期身份预分配(roll_board 恰一次、消费 reward_roll 封闭流
# 恰一 scope 批:格批 + 清图批尾)· 授予期零随机 · Ready 后只读、无重掷 · 换图映射消亡。
# 安全格与雷格一视同仁(同一类型池、同一权重分布——雷安同池);封锁格不入摇号清单。
# 极性不在资源内:「动作 × 格型」矩阵的字典定义归 GDD、路由执行归 战斗结算 授予段
# (本类只提供 polarity_row 的(类型, 极性)→ 字典行查询面)。
# 切片登记:取号约定 = randi_range(0, W-1)(实现期 ADR 事实,切片内恒定);
# 区间行 power roll 在身份批内追加取号(伤害量值区间批——快照 power = roll 值,授予期零随机不变)。
# 实现 GDD:design/gdd/systems/战斗循环/地图战斗资源.md(v0.4 格面资源批——registry 追溯锚)
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 := {}
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——2026-10-02 修复批)
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 批(格批 + 清图批尾 + 区间行追加取号)
var cell_rows: Array = _biased_cell_rows() # cell 池正向行 weight>0(偏运硬币装配时稀有正向 kinds 权重 +N——池结构零触碰)
var clear_rows: Array = _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 = _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 = _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 = _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 收口 2026-09-13:失败统一返回 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 = _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(迭代 11 同调批 T5——战斗结算 begin_battle 注入;0 = 未装配零扰动)
var luck_bias: int = 0
# 稀有正向资源 kinds(底稿 §3——权重投影面,池结构零触碰)
const LUCK_BIAS_KINDS := ["heal", "gp", "pierce", "magic"]
# 图级偏斜旋钮(全局作用域变异首发批 2026-10-04——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 态零扰动)
# 基座换源(首发批 2026-10-04):先经图级偏斜投影再叠件级加法——合成序锚=图级先、件级后
#(环境事实先定型、玩家构筑叠其上;两投影均为池内权重面,雷安同池与取号次数零变化)
func _biased_cell_rows() -> Array:
var base: Array = _skewed_cell_rows()
if luck_bias <= 0:
return base
var out: Array = []
for row in base:
if LUCK_BIAS_KINDS.has(String(row.get("cell_type", ""))):
var biased: Dictionary = row.duplicate()
biased["weight"] = int(row.weight) + luck_bias
out.append(biased)
else:
out.append(row)
return out
# 图级资源面偏斜投影(首发批 T3——整数最大余数法:生存族按 board_skew% 下调,下调量按进攻族
# 原权重比例整数重分配,合计恒等于原池合计 77;负向行与白名单三行逐位不动)。
# board_skew<=0 逐位返回原行集 = off 态零扰动第一断言:不进投影域、行集/行序/取号域与基线逐位同。
func _skewed_cell_rows() -> Array:
if board_skew <= 0:
return _rollable_rows()
var base: Array = _rollable_rows()
var down: Array = []
var up: Array = []
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 = []
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 = 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 解锁门控集(迭代 7 T5——拍板③甲「解锁→进投放池」:未解锁 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)