Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
298 lines
15 KiB
GDScript
298 lines
15 KiB
GDScript
class_name SupplyPurchaseQuoteV2
|
|
extends RefCounted
|
|
|
|
## Explicit total-order quote. v1 unit receipts are never reinterpreted.
|
|
## Placement uses the existing staff UI plan-first/legacy-live fallback rule.
|
|
## Total-order rounding reuses Pricing; attendance/role/floor multipliers are not added.
|
|
const Pricing = preload("res://scripts/facility/facility_pricing.gd")
|
|
const V1Plans = preload("res://scripts/supply/supply_auto_purchase_plans.gd")
|
|
const PREFIX := "supply_purchase.quote.v2:"
|
|
const MIMI := &"heroine_mimi"
|
|
const MULTIPLIER_BP := 8000
|
|
const MAX_INT_TEXT := "9223372036854775807"
|
|
const MAX_INT := 9223372036854775807
|
|
const KEYS: Array[StringName] = [&"version", &"item_id", &"quantity", &"base_unit_price",
|
|
&"base_total", &"multiplier_bp", &"effective_total", &"applied_unique_id"]
|
|
|
|
|
|
static func eligible(runtime: Variant) -> bool:
|
|
var mimi: StaffRuntime = runtime.staff_service.get_staff(MIMI)
|
|
if mimi == null or mimi.employment_kind != StaffRuntime.EmploymentKind.UNIQUE: return false
|
|
var assignment: Dictionary = runtime.staff_service.get_work_assignment(MIMI)
|
|
var facility_id := mimi.assigned_facility_id
|
|
if not assignment.is_empty(): facility_id = StringName(assignment.get(&"facility_id", &""))
|
|
return not facility_id.is_empty() and runtime.facility_service.get_instance(facility_id) != null
|
|
|
|
|
|
static func make(item_id: StringName, quantity: int, base_unit_price: int) -> Dictionary:
|
|
@warning_ignore("integer_division")
|
|
var maximum_base := (MAX_INT - 5000) / MULTIPLIER_BP
|
|
if String(item_id).strip_edges().is_empty() or quantity <= 0 or quantity > 10000 or base_unit_price <= 0:
|
|
return {}
|
|
@warning_ignore("integer_division")
|
|
var maximum_unit := maximum_base / quantity
|
|
if base_unit_price > maximum_unit: return {}
|
|
var base_total := quantity * base_unit_price
|
|
return {&"version": 2, &"item_id": String(item_id), &"quantity": quantity,
|
|
&"base_unit_price": base_unit_price, &"base_total": base_total,
|
|
&"multiplier_bp": MULTIPLIER_BP, &"effective_total": Pricing.apply_multiplier(base_total, MULTIPLIER_BP),
|
|
&"applied_unique_id": String(MIMI)}
|
|
|
|
|
|
static func valid(quote: Dictionary) -> bool:
|
|
if not keys_match(quote, KEYS): return false
|
|
for key: StringName in [&"version", &"quantity", &"base_unit_price", &"base_total", &"multiplier_bp", &"effective_total"]:
|
|
if typeof(quote[key]) != TYPE_INT: return false
|
|
if typeof(quote[&"item_id"]) != TYPE_STRING or typeof(quote[&"applied_unique_id"]) != TYPE_STRING:
|
|
return false
|
|
if quote[&"version"] != 2 or quote[&"multiplier_bp"] != MULTIPLIER_BP \
|
|
or quote[&"applied_unique_id"] != String(MIMI): return false
|
|
var canonical := make(StringName(quote[&"item_id"]), quote[&"quantity"], quote[&"base_unit_price"])
|
|
# This also preserves canonical key/container/type order without normalizing
|
|
# corrupt saved fields or consulting today's catalog/personnel state.
|
|
return not canonical.is_empty() and var_to_bytes(canonical) == var_to_bytes(quote)
|
|
|
|
|
|
static func affordable_quantity(cash: int, space: int, base_unit_price: int) -> int:
|
|
var lower := 0
|
|
var upper := mini(maxi(space, 0), 10000)
|
|
while lower < upper:
|
|
@warning_ignore("integer_division")
|
|
var middle := (lower + upper + 1) / 2
|
|
var quote := make(&"affordability", middle, base_unit_price)
|
|
if not quote.is_empty() and quote[&"effective_total"] <= cash:
|
|
lower = middle
|
|
else:
|
|
upper = middle - 1
|
|
return lower
|
|
|
|
|
|
static func receipt_memo(quote: Dictionary) -> StringName:
|
|
if not valid(quote): return &""
|
|
var item: String = quote[&"item_id"]
|
|
var actor: String = quote[&"applied_unique_id"]
|
|
return StringName(PREFIX + "%d:%s:%d:%s:%d:%d:%d:%d:%d" % [item.length(), item,
|
|
actor.length(), actor, quote[&"quantity"], quote[&"base_unit_price"], quote[&"base_total"],
|
|
quote[&"multiplier_bp"], quote[&"effective_total"]])
|
|
|
|
|
|
static func decode_receipt_memo(memo: StringName) -> Dictionary:
|
|
var text := String(memo)
|
|
if not text.begins_with(PREFIX): return {}
|
|
var item := _take_text(text.substr(PREFIX.length()))
|
|
if item.is_empty(): return {}
|
|
var actor := _take_text(item[&"rest"])
|
|
if actor.is_empty(): return {}
|
|
var fields: PackedStringArray = String(actor[&"rest"]).split(":", true)
|
|
if fields.size() != 5: return {}
|
|
var quote := {&"version": 2, &"item_id": item[&"text"], &"quantity": _positive(fields[0]),
|
|
&"base_unit_price": _positive(fields[1]), &"base_total": _positive(fields[2]),
|
|
&"multiplier_bp": _positive(fields[3]), &"effective_total": _positive(fields[4]),
|
|
&"applied_unique_id": actor[&"text"]}
|
|
return quote if valid(quote) and receipt_memo(quote) == memo else {}
|
|
|
|
|
|
static func receipt_from_ledger(economy: EconomyService, warehouse_id: StringName,
|
|
item_id: StringName, minute: int, transaction_id: StringName,
|
|
automatic := true, command_id := 0) -> Dictionary:
|
|
var key := V1Plans.order_key(warehouse_id, item_id, minute) if automatic \
|
|
else StringName("supply.purchase.%d" % command_id)
|
|
if economy == null or transaction_id.is_empty() or (not automatic and command_id <= 0) \
|
|
or economy.get_transaction_id_for_idempotency_key(key) != transaction_id:
|
|
return {}
|
|
var entries := economy.get_transaction_entries(transaction_id)
|
|
if entries.size() != 1: return {}
|
|
var entry: LedgerEntry = entries[0]
|
|
var quote := decode_receipt_memo(entry.memo_key)
|
|
if quote.is_empty() or quote[&"item_id"] != String(item_id) \
|
|
or entry.amount != quote[&"effective_total"] or entry.absolute_minute != minute \
|
|
or entry.source_entity_id != warehouse_id or entry.category != &"OPERATING_EXPENSE" \
|
|
or entry.credit_account != &"PLAYER_CASH" or entry.debit_account != &"OPERATING_EXPENSE" \
|
|
or not entry.related_transaction_id.is_empty(): return {}
|
|
return quote
|
|
|
|
|
|
static func has_history(runtime_data: Dictionary) -> bool:
|
|
var economy: Variant = runtime_data.get(&"economy")
|
|
if economy is Dictionary and economy.get(&"entries") is Array:
|
|
for entry: Variant in economy[&"entries"]:
|
|
if entry is Dictionary and typeof(entry.get(&"memo_key")) in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
and String(entry[&"memo_key"]).begins_with(PREFIX): return true
|
|
var plans: Variant = runtime_data.get(&"auto_purchase_plans")
|
|
if plans is Dictionary:
|
|
if plans.get(&"version") == 2: return true
|
|
if plans.get(&"plans") is Array:
|
|
for row: Variant in plans[&"plans"]:
|
|
if row is Dictionary and row.has(&"last_quote"): return true
|
|
var queue: Variant = runtime_data.get(&"command_queue")
|
|
if queue is Dictionary and queue.get("processed") is Array:
|
|
for result: Variant in queue["processed"]:
|
|
if result is Dictionary and _result_has_quote(result): return true
|
|
var stream: Variant = runtime_data.get(&"domain_events")
|
|
if stream is Dictionary and stream.get("recent_events") is Array:
|
|
for event: Variant in stream["recent_events"]:
|
|
if event is Dictionary and event.get("event_type") in ["COMMAND_COMPLETED", "COMMAND_FAILED"] \
|
|
and event.get("payload") is Dictionary and _result_has_quote(event["payload"]): return true
|
|
# The uncapped processed queue also keeps history after recent-event eviction.
|
|
return false
|
|
|
|
|
|
static func valid_history(runtime_data: Dictionary, economy: EconomyService) -> bool:
|
|
if economy == null: return false
|
|
var raw_economy: Variant = runtime_data.get(&"economy")
|
|
if not raw_economy is Dictionary or not raw_economy.get(&"entries") is Array \
|
|
or not raw_economy.get(&"idempotency") is Array: return false
|
|
var entries := economy.get_entries()
|
|
if entries.size() != (raw_economy[&"entries"] as Array).size(): return false
|
|
var v2_transactions: Dictionary = {}
|
|
for index: int in entries.size():
|
|
var entry: LedgerEntry = entries[index]
|
|
if not String(entry.memo_key).begins_with(PREFIX): continue
|
|
# The old Economy parser permits legacy coercions. New v2 payment fields
|
|
# must retain the complete actual nine-field producer representation.
|
|
var raw: Variant = raw_economy[&"entries"][index]
|
|
if not raw is Dictionary or var_to_bytes(raw) != var_to_bytes(entry.to_dictionary()): return false
|
|
v2_transactions[String(entry.transaction_id)] = true
|
|
for value: Variant in raw_economy[&"idempotency"]:
|
|
if not value is Dictionary: return false
|
|
if not v2_transactions.has(value.get(&"transaction_id")): continue
|
|
if not keys_match(value, [&"key", &"transaction_id"]) \
|
|
or typeof(value[&"key"]) != TYPE_STRING or typeof(value[&"transaction_id"]) != TYPE_STRING \
|
|
or var_to_bytes(value) != var_to_bytes({&"key": value[&"key"], &"transaction_id": value[&"transaction_id"]}): return false
|
|
var required_manual: Dictionary = {}
|
|
for entry: LedgerEntry in economy.get_entries():
|
|
if not String(entry.memo_key).begins_with(PREFIX): continue
|
|
var quote := decode_receipt_memo(entry.memo_key)
|
|
if quote.is_empty() or entry.source_entity_id.is_empty(): return false
|
|
var item := StringName(quote[&"item_id"])
|
|
var automatic := receipt_from_ledger(economy, entry.source_entity_id, item, entry.absolute_minute, entry.transaction_id)
|
|
if not automatic.is_empty(): continue
|
|
var command_id := _manual_command_id_for_transaction(economy, entry.transaction_id)
|
|
if receipt_from_ledger(economy, entry.source_entity_id, item, entry.absolute_minute, entry.transaction_id, false, command_id).is_empty(): return false
|
|
required_manual[command_id] = quote
|
|
var queue: Variant = runtime_data.get(&"command_queue")
|
|
if not queue is Dictionary or not queue.get("processed") is Array: return false
|
|
var processed: Dictionary = {}
|
|
for result: Variant in queue["processed"]:
|
|
if not result is Dictionary or not _valid_result_quotes(result, economy): return false
|
|
if typeof(result.get("command_id")) != TYPE_INT: return false
|
|
processed[result["command_id"]] = result
|
|
# Manual payments have uncapped processed results; removing/replacing their
|
|
# frozen quote is invalid. Auto plans may legitimately be replaced/pruned,
|
|
# so their append-only ledger receipt remains sufficient historical proof.
|
|
for command_id: int in required_manual:
|
|
if not processed.has(command_id): return false
|
|
var result: Dictionary = processed[command_id]
|
|
if result.get("code") != "OK" or not result.get("details") is Dictionary \
|
|
or not result["details"].get(&"purchase_quote") is Dictionary \
|
|
or var_to_bytes(result["details"][&"purchase_quote"]) != var_to_bytes(required_manual[command_id]): return false
|
|
var stream: Variant = runtime_data.get(&"domain_events")
|
|
if not stream is Dictionary or not stream.get("recent_events") is Array: return false
|
|
for event: Variant in stream["recent_events"]:
|
|
if not event is Dictionary: return false
|
|
if event.get("event_type") not in ["COMMAND_COMPLETED", "COMMAND_FAILED"]: continue
|
|
var payload: Variant = event.get("payload")
|
|
if not payload is Dictionary or not _result_has_quote(payload): continue
|
|
var command_id: Variant = event.get("causation_command_id")
|
|
if typeof(command_id) != TYPE_INT or not processed.has(command_id) \
|
|
or typeof(event.get("source_service")) != TYPE_STRING or event["source_service"] != "simulation_runtime" \
|
|
or var_to_bytes(payload) != var_to_bytes(processed[command_id]): return false
|
|
return true
|
|
|
|
|
|
static func _result_has_quote(result: Dictionary) -> bool:
|
|
# Only the owned original_result chain is traversed; arbitrary dictionaries
|
|
# and schema-looking strings elsewhere are not version authorities.
|
|
var current: Dictionary = result
|
|
var seen: Array[Dictionary] = []
|
|
while true:
|
|
for visited: Dictionary in seen:
|
|
if is_same(visited, current): return false
|
|
seen.append(current)
|
|
var details: Variant = current.get("details")
|
|
if not details is Dictionary: return false
|
|
if details.has(&"purchase_quote"): return true
|
|
var original: Variant = details.get("original_result")
|
|
if not original is Dictionary: return false
|
|
current = original
|
|
return false
|
|
|
|
|
|
static func _valid_result_quotes(result: Dictionary, economy: EconomyService) -> bool:
|
|
var current: Dictionary = result
|
|
var seen: Array[Dictionary] = []
|
|
while true:
|
|
for visited: Dictionary in seen:
|
|
if is_same(visited, current): return false
|
|
seen.append(current)
|
|
var details: Variant = current.get("details")
|
|
if not details is Dictionary: return false
|
|
if details.has(&"purchase_quote"):
|
|
# Existing CommandQueue owns all seven result fields; quote results
|
|
# additionally retain their exact producer string/container types.
|
|
if not _canonical_result(current): return false
|
|
var quote: Variant = details[&"purchase_quote"]
|
|
if not quote is Dictionary or not valid(quote): return false
|
|
if current["code"] == "OK":
|
|
if typeof(details.get(&"quantity")) != TYPE_INT or details[&"quantity"] != quote[&"quantity"] \
|
|
or typeof(details.get(&"total_cost")) != TYPE_INT or details[&"total_cost"] != quote[&"effective_total"] \
|
|
or typeof(details.get(&"warehouse_id")) != TYPE_STRING_NAME or typeof(details.get(&"item_id")) != TYPE_STRING_NAME \
|
|
or typeof(details.get(&"transaction_id")) != TYPE_STRING_NAME: return false
|
|
var entries := economy.get_transaction_entries(details[&"transaction_id"])
|
|
if entries.size() != 1: return false
|
|
var receipt := receipt_from_ledger(economy, details[&"warehouse_id"], details[&"item_id"],
|
|
(entries[0] as LedgerEntry).absolute_minute, details[&"transaction_id"], false, current["command_id"])
|
|
if var_to_bytes(receipt) != var_to_bytes(quote): return false
|
|
var original: Variant = details.get("original_result")
|
|
if not original is Dictionary: return true
|
|
current = original
|
|
return false
|
|
|
|
|
|
static func _canonical_result(result: Dictionary) -> bool:
|
|
if result.size() != 7: return false
|
|
for key: Variant in result:
|
|
if typeof(key) != TYPE_STRING or key not in ["code", "command_id", "target_id", "message", "resolution_hints", "details", "is_replay"]: return false
|
|
if typeof(result["code"]) != TYPE_STRING or String(result["code"]) not in CommandResult.Code.keys() \
|
|
or typeof(result["command_id"]) != TYPE_INT or result["command_id"] <= 0 \
|
|
or typeof(result["target_id"]) != TYPE_STRING or typeof(result["message"]) != TYPE_STRING \
|
|
or not result["resolution_hints"] is Array or not result["details"] is Dictionary \
|
|
or typeof(result["is_replay"]) != TYPE_BOOL: return false
|
|
for hint: Variant in result["resolution_hints"]:
|
|
if typeof(hint) != TYPE_STRING: return false
|
|
return true
|
|
|
|
|
|
static func _manual_command_id_for_transaction(economy: EconomyService, transaction_id: StringName) -> int:
|
|
for row: Dictionary in economy.create_snapshot()[&"idempotency"]:
|
|
var key: String = row[&"key"]
|
|
if row[&"transaction_id"] != String(transaction_id) or not key.begins_with("supply.purchase."): continue
|
|
var number := _positive(key.substr("supply.purchase.".length()))
|
|
if key == "supply.purchase.%d" % number: return number
|
|
return 0
|
|
|
|
|
|
static func keys_match(value: Dictionary, expected: Array[StringName]) -> bool:
|
|
if value.size() != expected.size(): return false
|
|
for key: Variant in value:
|
|
if typeof(key) != TYPE_STRING_NAME or key not in expected: return false
|
|
return true
|
|
|
|
|
|
static func _take_text(text: String) -> Dictionary:
|
|
var delimiter := text.find(":")
|
|
if delimiter <= 0: return {}
|
|
var length := _positive(text.substr(0, delimiter))
|
|
var rest := text.substr(delimiter + 1)
|
|
if length <= 0 or length >= rest.length() or rest.substr(length, 1) != ":": return {}
|
|
return {&"text": rest.substr(0, length), &"rest": rest.substr(length + 1)}
|
|
|
|
|
|
static func _positive(text: String) -> int:
|
|
if text.is_empty() or text.length() > MAX_INT_TEXT.length() or text.substr(0, 1) == "0": return 0
|
|
for index: int in text.length():
|
|
var code := text.unicode_at(index)
|
|
if code < 48 or code > 57: return 0
|
|
if text.length() == MAX_INT_TEXT.length() and text > MAX_INT_TEXT: return 0
|
|
return text.to_int()
|