P06-01: Implement approved A06 finite Mana reserve (MP-01-r02 recommended)
Initial 100, capacity 300, world tree +20 per 10 game minutes. Each boundary pays registered upkeep demand before storing surplus; overflow is discarded and recorded, 0 stops the whole tower. Furnace offers are judged after generation and demand payment. New saves carry mana_reserve and Operations rollback includes it; legacy saves keep a 0..300 amount and reject others. Outage tests that relied on Mana 0 persisting now hold the outage with a disabled demand sink, since the world tree recovers at the next boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c366012446
commit
ae0f998ff4
@@ -231,6 +231,15 @@ func get_storage_current(node_id: StringName) -> int:
|
||||
return int(_nodes[node_id][&"current"])
|
||||
|
||||
|
||||
## Upkeep demand of every registered consumer, including disabled ones.
|
||||
func get_total_consumer_demand() -> int:
|
||||
var total := 0
|
||||
for node_id: StringName in _nodes:
|
||||
if int(_nodes[node_id][&"kind"]) == NodeKind.CONSUMER:
|
||||
total += int(_nodes[node_id][&"demand"])
|
||||
return total
|
||||
|
||||
|
||||
func get_last_report() -> Dictionary:
|
||||
return _last_report.duplicate(true)
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
extends RefCounted
|
||||
|
||||
## Approved finite Mana reserve contract A06 (MP-01-r02 recommended plan).
|
||||
## GameState keeps the stored amount so existing spending stays authoritative;
|
||||
## this owns the processed 10-minute boundary and the last flow, so a boundary
|
||||
## is consumed exactly once and replays after rollback reproduce the same flow.
|
||||
const VERSION := 1
|
||||
const POLICY_ID := &"MP-01-r02"
|
||||
const INITIAL_MANA := 100
|
||||
const CAPACITY := 300
|
||||
const WORLD_TREE_GENERATION := 20
|
||||
const INTERVAL_MINUTES := 10
|
||||
const FURNACE_YIELD := 100
|
||||
const LIMIT := 1000000000
|
||||
const FLOW_KEYS: Array[String] = [
|
||||
"from_minute", "to_minute", "before", "generation", "furnace", "demand",
|
||||
"consumed", "shortage", "overflow", "after",
|
||||
]
|
||||
|
||||
var _minute := -1
|
||||
var _last_flow: Dictionary = {}
|
||||
|
||||
func start_at(minute: Variant) -> bool:
|
||||
if _minute >= 0 or typeof(minute) != TYPE_INT or minute < 0:
|
||||
return false
|
||||
_minute = minute - minute % INTERVAL_MINUTES
|
||||
return true
|
||||
|
||||
func get_minute() -> int:
|
||||
return _minute
|
||||
|
||||
func get_last_flow() -> Dictionary:
|
||||
return _last_flow.duplicate(true)
|
||||
|
||||
## Processes every boundary in (last boundary, minute]. Boundaries already
|
||||
## processed are no-ops, so per-minute callers and same-minute retries are safe.
|
||||
func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary:
|
||||
if _minute < 0:
|
||||
return _failure(&"NOT_STARTED")
|
||||
if typeof(minute) != TYPE_INT or not _amount(before) or before > CAPACITY \
|
||||
or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute:
|
||||
return _failure(&"INVALID_FLOW")
|
||||
var boundary: int = minute - minute % INTERVAL_MINUTES
|
||||
if boundary <= _minute:
|
||||
return {&"ok": true, &"processed": false, &"after": before}
|
||||
var amount: int = before
|
||||
var flow := {}
|
||||
var count := 0
|
||||
for to_minute: int in range(_minute + INTERVAL_MINUTES, boundary + 1, INTERVAL_MINUTES):
|
||||
flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute)
|
||||
amount = flow[&"after"]
|
||||
count += 1
|
||||
_minute = boundary
|
||||
_last_flow = flow
|
||||
return {&"ok": true, &"processed": true, &"boundaries": count, &"after": amount, &"flow": flow.duplicate(true)}
|
||||
|
||||
## Furnace offers are judged after natural generation and upkeep demand are
|
||||
## paid: base_after = min(C, max(0, B + G - D)); accept only with 100 free.
|
||||
static func furnace_offer_accepted(before: int, demand: int) -> bool:
|
||||
var base_after := mini(CAPACITY, maxi(0, before + WORLD_TREE_GENERATION - demand))
|
||||
return CAPACITY - base_after >= FURNACE_YIELD
|
||||
|
||||
static func plan(before: int, demand: int, furnace: int, start: int, end: int) -> Dictionary:
|
||||
var available := before + WORLD_TREE_GENERATION + furnace
|
||||
var consumed := mini(available, demand)
|
||||
var remaining := available - consumed
|
||||
return {&"from_minute": start, &"to_minute": end, &"before": before,
|
||||
&"generation": WORLD_TREE_GENERATION, &"furnace": furnace, &"demand": demand,
|
||||
&"consumed": consumed, &"shortage": demand - consumed,
|
||||
&"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)}
|
||||
|
||||
static func amount_is_valid(value: Variant) -> bool:
|
||||
return _amount(value) and value <= CAPACITY
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {&"version": VERSION, &"policy_id": POLICY_ID, &"capacity": CAPACITY,
|
||||
&"generation": WORLD_TREE_GENERATION, &"interval_minutes": INTERVAL_MINUTES,
|
||||
&"minute": _minute, &"last_flow": _last_flow.duplicate(true)}
|
||||
|
||||
## Validates fully before committing; a rejected snapshot leaves state intact.
|
||||
func restore_snapshot(source: Variant) -> bool:
|
||||
if not source is Dictionary or not _keys(source, ["version", "policy_id", "capacity", "generation", "interval_minutes", "minute", "last_flow"]):
|
||||
return false
|
||||
if typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \
|
||||
or typeof(source["policy_id"]) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
||||
or StringName(source["policy_id"]) != POLICY_ID \
|
||||
or typeof(source["capacity"]) != TYPE_INT or source["capacity"] != CAPACITY \
|
||||
or typeof(source["generation"]) != TYPE_INT or source["generation"] != WORLD_TREE_GENERATION \
|
||||
or typeof(source["interval_minutes"]) != TYPE_INT or source["interval_minutes"] != INTERVAL_MINUTES \
|
||||
or not _amount(source["minute"]) or source["minute"] % INTERVAL_MINUTES != 0 \
|
||||
or not source["last_flow"] is Dictionary:
|
||||
return false
|
||||
var flow: Dictionary = source["last_flow"]
|
||||
if not flow.is_empty():
|
||||
if not _keys(flow, FLOW_KEYS):
|
||||
return false
|
||||
for key: Variant in flow:
|
||||
if not _amount(flow[key]):
|
||||
return false
|
||||
if flow["to_minute"] != source["minute"] or flow["before"] > CAPACITY \
|
||||
or flow["generation"] != WORLD_TREE_GENERATION \
|
||||
or flow["from_minute"] + INTERVAL_MINUTES != flow["to_minute"]:
|
||||
return false
|
||||
var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"])
|
||||
if not _same_flow(expected, flow):
|
||||
return false
|
||||
_minute = source["minute"]
|
||||
var normalized := {}
|
||||
for key: Variant in flow:
|
||||
normalized[StringName(key)] = flow[key]
|
||||
_last_flow = normalized
|
||||
return true
|
||||
|
||||
static func _same_flow(expected: Dictionary, actual: Dictionary) -> bool:
|
||||
for key: String in FLOW_KEYS:
|
||||
if expected[StringName(key)] != actual[key]:
|
||||
return false
|
||||
return true
|
||||
|
||||
static func _amount(value: Variant) -> bool:
|
||||
return typeof(value) == TYPE_INT and value >= 0 and value <= LIMIT
|
||||
|
||||
static func _keys(source: Dictionary, expected: Array) -> bool:
|
||||
if source.size() != expected.size():
|
||||
return false
|
||||
for key: Variant in source:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected:
|
||||
return false
|
||||
return true
|
||||
|
||||
static func _failure(code: StringName) -> Dictionary:
|
||||
return {&"ok": false, &"code": code}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhi1bipyd67tx
|
||||
Reference in New Issue
Block a user