Add supply auto-purchase and preserve save recovery state
This commit is contained in:
+140
-19
@@ -14,6 +14,10 @@ const AUTOSAVE_SUFFIX := ".autosave."
|
||||
const MIGRATION_BACKUP_SUFFIX := ".pre_migration.bak"
|
||||
const SAVE_TRANSACTION_SUFFIX := ".save_transaction"
|
||||
const SAVE_TRANSACTION_MARKER := "THE_TREE_FORMAL_SAVE_V1\n"
|
||||
# Technical resource limits, independent of gameplay/content values. Check
|
||||
# authoritative file lengths and advertised expansion before allocating reads.
|
||||
const MAX_WORLD_BYTES := 256 * 1024 * 1024
|
||||
const MAX_MANIFEST_BYTES := 4 * 1024 * 1024
|
||||
|
||||
static var _performance_budget := SimulationPerformanceBudget.new()
|
||||
|
||||
@@ -48,6 +52,19 @@ static func load_snapshot(save_path: String) -> Dictionary:
|
||||
return _complete_timed_operation(result, &"load", started_at_us)
|
||||
|
||||
|
||||
## Reads only the named formal package. Unlike load_snapshot, this never
|
||||
## reconciles, promotes, removes or migrates interrupted-save artifacts. A
|
||||
## missing formal package stays missing even when a .tmp/.bak could recover it.
|
||||
static func inspect_snapshot(save_path: String) -> Dictionary:
|
||||
var started_at_us := Time.get_ticks_usec()
|
||||
var resolved := _resolve_safe_save_path(save_path)
|
||||
var result: Dictionary = resolved
|
||||
if resolved[&"ok"]:
|
||||
result = _load_snapshot_absolute(resolved[&"path"], FORMAT_VERSION)
|
||||
result[&"read_only"] = true
|
||||
return _complete_timed_operation(result, &"load", started_at_us)
|
||||
|
||||
|
||||
static func get_performance_report(metric_id: StringName) -> Dictionary:
|
||||
return _performance_budget.get_report(metric_id)
|
||||
|
||||
@@ -314,9 +331,11 @@ static func _load_recovery_candidate_impl(
|
||||
if not resolved[&"ok"]:
|
||||
return resolved
|
||||
var target_path: String = resolved[&"path"]
|
||||
var formal_reconcile := _reconcile_interrupted_formal_save(target_path)
|
||||
if not formal_reconcile[&"ok"]:
|
||||
return formal_reconcile
|
||||
# Explicit candidate selection is a read-only operation. Normal load and
|
||||
# load_with_recovery retain transaction reconciliation at their own entry.
|
||||
var formal_guard := _guard_identified_formal_format(target_path)
|
||||
if not formal_guard[&"ok"]:
|
||||
return formal_guard
|
||||
var candidate_path := ""
|
||||
match candidate_id:
|
||||
&"formal":
|
||||
@@ -393,12 +412,29 @@ static func _migrate_save_impl(
|
||||
if not resolved[&"ok"]:
|
||||
return resolved
|
||||
var target_path: String = resolved[&"path"]
|
||||
var formal_reconcile := _reconcile_interrupted_formal_save(target_path)
|
||||
if not formal_reconcile[&"ok"]:
|
||||
return formal_reconcile
|
||||
var reconcile := _reconcile_interrupted_migration(target_path)
|
||||
if not reconcile[&"ok"]:
|
||||
return reconcile
|
||||
var formal_guard := _guard_identified_formal_format(target_path)
|
||||
var migrating_registered_legacy := false
|
||||
if not formal_guard[&"ok"]:
|
||||
if formal_guard[&"code"] != &"UNSUPPORTED_FORMAT": return formal_guard
|
||||
var identified_version := int(formal_guard[&"identified_format_version"])
|
||||
if identified_version > target_version:
|
||||
return _failure(&"MIGRATION_DOWNGRADE_UNSUPPORTED", "%d -> %d" % [identified_version, target_version])
|
||||
if registry == null or not replacement_snapshot.is_empty():
|
||||
return formal_guard
|
||||
if target_version != FORMAT_VERSION or identified_version >= target_version \
|
||||
or formal_guard[&"identified_format_versions"].size() != 1:
|
||||
return formal_guard
|
||||
for version in range(identified_version, target_version):
|
||||
if not registry.has_step(version):
|
||||
return _failure(&"MIGRATION_STEP_MISSING", "missing adjacent migration %d -> %d" % [version, version + 1])
|
||||
# A registered old formal source is decoded/migrated directly. Do not
|
||||
# reconcile stale normal/migration artifacts before source validation.
|
||||
migrating_registered_legacy = true
|
||||
if not migrating_registered_legacy:
|
||||
var formal_reconcile := _reconcile_interrupted_formal_save(target_path)
|
||||
if not formal_reconcile[&"ok"]: return formal_reconcile
|
||||
var reconcile := _reconcile_interrupted_migration(target_path)
|
||||
if not reconcile[&"ok"]: return reconcile
|
||||
var operation_paths: Array[String] = [
|
||||
target_path,
|
||||
target_path + ".migration.tmp",
|
||||
@@ -734,10 +770,16 @@ static func _write_package_directory(
|
||||
if make_error != OK:
|
||||
return _failure(&"CREATE_DIRECTORY_FAILED", error_string(make_error))
|
||||
var raw_world := var_to_bytes(snapshot)
|
||||
if not _world_size_is_supported(raw_world.size()):
|
||||
_remove_tree(package_path)
|
||||
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "uncompressed world exceeds technical resource limit")
|
||||
var compressed_world := raw_world.compress(FileAccess.COMPRESSION_ZSTD)
|
||||
if compressed_world.is_empty() and not raw_world.is_empty():
|
||||
_remove_tree(package_path)
|
||||
return _failure(&"COMPRESSION_FAILED", WORLD_FILE)
|
||||
if not _world_size_is_supported(compressed_world.size()):
|
||||
_remove_tree(package_path)
|
||||
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "compressed world exceeds technical resource limit")
|
||||
var checksum := _sha256(compressed_world)
|
||||
var world_error := _write_world(
|
||||
package_path.path_join(WORLD_FILE),
|
||||
@@ -775,7 +817,11 @@ static func _write_package_directory(
|
||||
continue
|
||||
if not manifest.has(key):
|
||||
manifest[key] = metadata[key]
|
||||
var manifest_error := _write_text(package_path.path_join(MANIFEST_FILE), JSON.stringify(manifest, "\t"))
|
||||
var manifest_text := JSON.stringify(manifest, "\t")
|
||||
if manifest_text.to_utf8_buffer().size() > MAX_MANIFEST_BYTES:
|
||||
_remove_tree(package_path)
|
||||
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "manifest exceeds technical resource limit")
|
||||
var manifest_error := _write_text(package_path.path_join(MANIFEST_FILE), manifest_text)
|
||||
if manifest_error != OK:
|
||||
_remove_tree(package_path)
|
||||
return _failure(&"WRITE_MANIFEST_FAILED", error_string(manifest_error))
|
||||
@@ -813,15 +859,23 @@ static func _load_snapshot_absolute(
|
||||
var manifest_file := FileAccess.open(manifest_path, FileAccess.READ)
|
||||
if manifest_file == null:
|
||||
return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error()))
|
||||
if manifest_file.get_length() > MAX_MANIFEST_BYTES:
|
||||
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "manifest exceeds technical resource limit")
|
||||
var manifest_text := manifest_file.get_as_text()
|
||||
manifest_file = null
|
||||
var manifest_value: Variant = JSON.parse_string(manifest_text)
|
||||
var parser := JSON.new()
|
||||
if parser.parse(manifest_text) != OK:
|
||||
return _failure(&"INVALID_MANIFEST", manifest_path)
|
||||
var manifest_value: Variant = parser.data
|
||||
if not manifest_value is Dictionary:
|
||||
return _failure(&"INVALID_MANIFEST", manifest_path)
|
||||
var manifest: Dictionary = manifest_value
|
||||
var manifest_version := int(manifest.get("format_version", manifest.get(&"format_version", -1)))
|
||||
if manifest_version < 0:
|
||||
return _failure(&"INVALID_MANIFEST", "missing format_version")
|
||||
var version_value: Variant = manifest.get("format_version", -1)
|
||||
# Godot JSON decodes numbers as floats. Permit exact integral JSON numbers,
|
||||
# while rejecting strings, booleans and fractions instead of coercing them.
|
||||
if not _is_integral_json_number(version_value, 0, 0xffffffff):
|
||||
return _failure(&"INVALID_MANIFEST", "format_version must be an unsigned integer")
|
||||
var manifest_version := int(version_value)
|
||||
if expected_format_version >= 0 and manifest_version != expected_format_version:
|
||||
return _failure(&"UNSUPPORTED_FORMAT", str(manifest_version))
|
||||
|
||||
@@ -832,21 +886,33 @@ static func _load_snapshot_absolute(
|
||||
return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error()))
|
||||
if world_file.get_length() < 16:
|
||||
return _failure(&"INVALID_WORLD_HEADER", world_path)
|
||||
if world_file.get_length() - 16 > MAX_WORLD_BYTES:
|
||||
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "compressed world exceeds technical resource limit")
|
||||
var magic := world_file.get_buffer(4).get_string_from_ascii()
|
||||
var stored_version := world_file.get_32()
|
||||
var uncompressed_size := world_file.get_64()
|
||||
if not _world_size_is_supported(uncompressed_size):
|
||||
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "uncompressed world exceeds technical resource limit")
|
||||
var compressed_size := world_file.get_length() - world_file.get_position()
|
||||
if compressed_size < 0:
|
||||
return _failure(&"INVALID_WORLD_HEADER", world_path)
|
||||
var compressed_world := world_file.get_buffer(compressed_size)
|
||||
if magic != MAGIC or stored_version != manifest_version:
|
||||
return _failure(&"INVALID_WORLD_HEADER", world_path)
|
||||
var expected_checksum := str(manifest.get("checksum_world", manifest.get(&"checksum_world", "")))
|
||||
if expected_checksum.is_empty() or _sha256(compressed_world) != expected_checksum:
|
||||
var checksum_value: Variant = manifest.get("checksum_world", null)
|
||||
if not checksum_value is String or checksum_value.length() != 64 or not checksum_value.is_valid_hex_number(false):
|
||||
return _failure(&"INVALID_MANIFEST", "checksum_world must be a SHA-256 string")
|
||||
var expected_checksum: String = checksum_value
|
||||
if _sha256(compressed_world) != expected_checksum:
|
||||
return _failure(&"CHECKSUM_MISMATCH", world_path)
|
||||
var manifest_size := int(manifest.get("uncompressed_size", manifest.get(&"uncompressed_size", -1)))
|
||||
if manifest_size >= 0 and manifest_size != uncompressed_size:
|
||||
return _failure(&"SIZE_MISMATCH", world_path)
|
||||
# Older packages may omit this duplicate size field; the binary header still
|
||||
# supplies a required bounded size. If present it must be exact and agree.
|
||||
if manifest.has("uncompressed_size"):
|
||||
var size_value: Variant = manifest["uncompressed_size"]
|
||||
if not _is_integral_json_number(size_value, 1, MAX_WORLD_BYTES):
|
||||
return _failure(&"INVALID_MANIFEST", "uncompressed_size must be a supported positive integer")
|
||||
if int(size_value) != uncompressed_size:
|
||||
return _failure(&"SIZE_MISMATCH", world_path)
|
||||
var raw_world := compressed_world.decompress(uncompressed_size, FileAccess.COMPRESSION_ZSTD)
|
||||
if raw_world.size() != uncompressed_size:
|
||||
return _failure(&"DECOMPRESSION_FAILED", world_path)
|
||||
@@ -866,6 +932,58 @@ static func _load_snapshot_absolute(
|
||||
}
|
||||
|
||||
|
||||
static func _is_integral_json_number(value: Variant, minimum: int, maximum: int) -> bool:
|
||||
if typeof(value) == TYPE_INT:
|
||||
return value >= minimum and value <= maximum
|
||||
if typeof(value) != TYPE_FLOAT or not is_finite(value):
|
||||
return false
|
||||
return value >= minimum and value <= maximum and value == floor(value)
|
||||
|
||||
|
||||
static func _world_size_is_supported(size: int) -> bool:
|
||||
return size > 0 and size <= MAX_WORLD_BYTES
|
||||
|
||||
|
||||
## Probe only bounded metadata and the fixed binary prefix. An identifiable
|
||||
## unsupported formal format must be rejected before any transaction artifact
|
||||
## is reconciled, even when its compressed payload/checksum is damaged.
|
||||
static func _guard_identified_formal_format(target_path: String) -> Dictionary:
|
||||
var versions: Array[int] = []
|
||||
var manifest_path := target_path.path_join(MANIFEST_FILE)
|
||||
if FileAccess.file_exists(manifest_path):
|
||||
if not _package_file_is_safe_for_read(manifest_path):
|
||||
return _failure(&"UNSAFE_PATH", manifest_path)
|
||||
var manifest_file := FileAccess.open(manifest_path, FileAccess.READ)
|
||||
if manifest_file == null:
|
||||
return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error()))
|
||||
if manifest_file.get_length() <= MAX_MANIFEST_BYTES:
|
||||
var parser := JSON.new()
|
||||
if parser.parse(manifest_file.get_as_text()) == OK and parser.data is Dictionary:
|
||||
var value: Variant = parser.data.get("format_version", null)
|
||||
if _is_integral_json_number(value, 0, 0xffffffff):
|
||||
versions.append(int(value))
|
||||
manifest_file = null
|
||||
var world_path := target_path.path_join(WORLD_FILE)
|
||||
if FileAccess.file_exists(world_path):
|
||||
if not _package_file_is_safe_for_read(world_path):
|
||||
return _failure(&"UNSAFE_PATH", world_path)
|
||||
var world_file := FileAccess.open(world_path, FileAccess.READ)
|
||||
if world_file == null:
|
||||
return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error()))
|
||||
if world_file.get_length() >= 8 and world_file.get_buffer(4).get_string_from_ascii() == MAGIC:
|
||||
var version := world_file.get_32()
|
||||
if version not in versions: versions.append(version)
|
||||
world_file = null
|
||||
for version: int in versions:
|
||||
if version != FORMAT_VERSION:
|
||||
var failure := _failure(&"UNSUPPORTED_FORMAT", str(version))
|
||||
failure[&"identified_format_version"] = version
|
||||
failure[&"identified_format_versions"] = versions
|
||||
failure[&"formal_and_artifacts_preserved"] = true
|
||||
return failure
|
||||
return {&"ok": true, &"code": &"OK"}
|
||||
|
||||
|
||||
## Resolves a normal-save promotion interrupted after the old formal package
|
||||
## moved to .bak. Only checksum/schema-verified packages may become formal.
|
||||
## The marker is durable, but verified .tmp/.bak artifacts also recover saves
|
||||
@@ -876,6 +994,9 @@ static func _reconcile_interrupted_formal_save(target_path: String) -> Dictionar
|
||||
var marker_path := target_path + SAVE_TRANSACTION_SUFFIX
|
||||
if not _operation_paths_are_safe([target_path, temporary_path, backup_path, marker_path]):
|
||||
return _failure(&"UNSAFE_PATH", "formal save recovery path contains a symbolic link")
|
||||
var formal_guard := _guard_identified_formal_format(target_path)
|
||||
if not formal_guard[&"ok"]:
|
||||
return formal_guard
|
||||
var marker_exists := FileAccess.file_exists(marker_path) or DirAccess.dir_exists_absolute(marker_path)
|
||||
if marker_exists and not _formal_transaction_marker_is_valid(marker_path):
|
||||
return _failure(&"TRANSACTION_MARKER_CONFLICT", marker_path)
|
||||
|
||||
@@ -15,16 +15,24 @@ static func capture(clock: SimulationKernel, state: Node, extra := {}) -> Dictio
|
||||
|
||||
static func validate(snapshot: Dictionary) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
if int(snapshot.get(&"schema_version", -1)) != SCHEMA_VERSION:
|
||||
var schema_value: Variant = snapshot.get(&"schema_version", null)
|
||||
if not _is_nonnegative_integer(schema_value) or int(schema_value) != SCHEMA_VERSION:
|
||||
errors.append("unsupported_schema_version")
|
||||
if not snapshot.get(&"clock", null) is Dictionary:
|
||||
errors.append("missing_clock")
|
||||
else:
|
||||
var clock_data: Dictionary = snapshot[&"clock"]
|
||||
if int(clock_data.get(&"absolute_minute", -1)) < 0:
|
||||
if not _is_nonnegative_integer(clock_data.get(&"absolute_minute", null)):
|
||||
errors.append("invalid_absolute_minute")
|
||||
if int(clock_data.get(&"speed", 0)) not in [1, 2, 3, 4]:
|
||||
var speed_value: Variant = clock_data.get(&"speed", null)
|
||||
if not _is_nonnegative_integer(speed_value) or int(speed_value) not in [1, 2, 3, 4]:
|
||||
errors.append("invalid_speed")
|
||||
# Legacy envelopes may omit these fields and retain restore_clock's
|
||||
# paused=true/version=0 defaults. Present fields cannot be coerced.
|
||||
if typeof(clock_data.get(&"paused", true)) != TYPE_BOOL:
|
||||
errors.append("invalid_paused")
|
||||
if not _is_nonnegative_integer(clock_data.get(&"snapshot_version", 0)):
|
||||
errors.append("invalid_snapshot_version")
|
||||
if not snapshot.get(&"game_state", null) is Dictionary:
|
||||
errors.append("missing_game_state")
|
||||
return {
|
||||
@@ -32,6 +40,15 @@ static func validate(snapshot: Dictionary) -> Dictionary:
|
||||
&"errors": errors,
|
||||
}
|
||||
|
||||
static func _is_nonnegative_integer(value: Variant) -> bool:
|
||||
if typeof(value) == TYPE_INT:
|
||||
return value >= 0
|
||||
# JSON numbers are floats, but exact integral values remain compatible.
|
||||
# Avoid an overflowing float-to-int conversion at the signed int64 limit.
|
||||
if typeof(value) != TYPE_FLOAT or not is_finite(value):
|
||||
return false
|
||||
return value >= 0 and value < 9223372036854775808.0 and value == floor(value)
|
||||
|
||||
static func restore(snapshot: Dictionary, clock: SimulationKernel, state: Node) -> bool:
|
||||
var validation := validate(snapshot)
|
||||
if not validation[&"ok"]:
|
||||
|
||||
@@ -1542,6 +1542,8 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary:
|
||||
var state_index := RaidState.keys().find(String(_value(raw, &"state", &"")))
|
||||
if raid_id.is_empty() or state_index < 0:
|
||||
return {&"ok": false, &"error": "Raid snapshot ID or state is invalid."}
|
||||
# Normal creation owns Array[Dictionary]; restores keep the same container type.
|
||||
var parsed_waves: Array[Dictionary] = []
|
||||
var raid := {
|
||||
&"raid_id": raid_id,
|
||||
&"source_id": StringName(_value(raw, &"source_id", &"")),
|
||||
@@ -1564,7 +1566,7 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary:
|
||||
&"multiple_entry_penalty": int(_value(raw, &"multiple_entry_penalty", -1)),
|
||||
&"entry_points": _sorted_unique_names(_value(raw, &"entry_points", [])),
|
||||
&"objective_ids": _sorted_unique_names(_value(raw, &"objective_ids", [])),
|
||||
&"waves": [],
|
||||
&"waves": parsed_waves,
|
||||
&"combatants": {},
|
||||
&"assets": {},
|
||||
&"queued_skills": {},
|
||||
@@ -1588,7 +1590,7 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary:
|
||||
wave[&"objective_ids"] = _sorted_unique_names(_value(wave, &"objective_ids", []))
|
||||
wave[&"state"] = StringName(_value(wave, &"state", &""))
|
||||
wave[&"result_reason"] = StringName(_value(wave, &"result_reason", &""))
|
||||
(raid[&"waves"] as Array).append(wave)
|
||||
parsed_waves.append(wave)
|
||||
for raw_combatant: Variant in _value(raw, &"combatants", []):
|
||||
if not raw_combatant is Dictionary:
|
||||
return {&"ok": false, &"error": "Malformed defense combatant snapshot."}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
class_name SupplyAutoPurchasePlans
|
||||
extends RefCounted
|
||||
|
||||
## Owned player intent and the last actual attempt. No plan is inferred on load.
|
||||
## Current catalogue prices/storage are reused; target stock is player input.
|
||||
const Catalog := preload("res://scripts/supply/supply_catalog.gd")
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const RECEIPT_PREFIX := "supply_purchase.auto.v1:"
|
||||
const MAX_SIGNED_INT_TEXT := "9223372036854775807"
|
||||
const RESULT_PURCHASED := &"PURCHASED"
|
||||
const FAILED_RESULTS: Array[StringName] = [
|
||||
&"INVENTORY_ERROR", &"PAYMENT_FAILED", &"INSUFFICIENT_FUNDS", &"STORAGE_FULL",
|
||||
&"FACILITY_CLOSED", &"INVALID_QUANTITY", &"ITEM_NOT_PURCHASABLE", &"NOT_WAREHOUSE",
|
||||
&"REPLAY_MISMATCH",
|
||||
]
|
||||
|
||||
var last_error := ""
|
||||
var _plans: Dictionary = {}
|
||||
|
||||
|
||||
func get_plan(warehouse_id: StringName, item_id: StringName) -> Dictionary:
|
||||
if _plans.has(warehouse_id) and (_plans[warehouse_id] as Dictionary).has(item_id):
|
||||
return (_plans[warehouse_id][item_id] as Dictionary).duplicate(true)
|
||||
return _empty_plan(warehouse_id, item_id)
|
||||
|
||||
|
||||
func get_plans() -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
for warehouse_id: StringName in _sorted_names(_plans):
|
||||
for item_id: StringName in _sorted_names(_plans[warehouse_id]):
|
||||
rows.append(get_plan(warehouse_id, item_id))
|
||||
return rows
|
||||
|
||||
|
||||
func set_plan(warehouse_id: StringName, item_id: StringName, enabled: bool,
|
||||
target_stock: int, facilities: FacilityService) -> Dictionary:
|
||||
var validation := validate_intent(warehouse_id, item_id, enabled, target_stock, facilities)
|
||||
if not bool(validation[&"ok"]):
|
||||
return validation
|
||||
var plan := get_plan(warehouse_id, item_id)
|
||||
plan[&"enabled"] = enabled
|
||||
plan[&"target_stock"] = target_stock
|
||||
if not _plans.has(warehouse_id):
|
||||
_plans[warehouse_id] = {}
|
||||
_plans[warehouse_id][item_id] = plan
|
||||
last_error = ""
|
||||
return {&"ok": true, &"plan": plan.duplicate(true)}
|
||||
|
||||
|
||||
func record_attempt(warehouse_id: StringName, item_id: StringName, minute: int,
|
||||
result: CommandResult, economy: EconomyService) -> bool:
|
||||
if not _plans.has(warehouse_id) or not (_plans[warehouse_id] as Dictionary).has(item_id):
|
||||
return false
|
||||
var plan: Dictionary = _plans[warehouse_id][item_id]
|
||||
if minute < 0 or minute <= int(plan[&"last_attempt_minute"]):
|
||||
return false
|
||||
var reason: StringName = RESULT_PURCHASED if result.is_success() \
|
||||
else StringName(result.details.get(&"reason", &"PAYMENT_FAILED"))
|
||||
if reason != RESULT_PURCHASED and reason not in FAILED_RESULTS:
|
||||
return false
|
||||
var transaction_id := StringName(result.details.get(&"transaction_id", &"")) if result.is_success() else &""
|
||||
var quantity: int = int(result.details.get(&"quantity", 0)) if result.is_success() else 0
|
||||
var unit_price: int = int(result.details.get(&"unit_price", 0)) if result.is_success() else 0
|
||||
var total_cost: int = int(result.details.get(&"total_cost", 0)) if result.is_success() else 0
|
||||
if result.is_success() and (not valid_charge(quantity, unit_price, total_cost) or not valid_receipt(economy, warehouse_id, item_id, minute, transaction_id, total_cost, quantity, unit_price)):
|
||||
return false
|
||||
plan[&"last_attempt_minute"] = minute
|
||||
plan[&"last_result"] = reason
|
||||
plan[&"last_transaction_id"] = String(transaction_id)
|
||||
plan[&"last_quantity"] = quantity
|
||||
plan[&"last_unit_price"] = unit_price
|
||||
plan[&"last_total_cost"] = total_cost
|
||||
return true
|
||||
|
||||
|
||||
func remove_warehouse(warehouse_id: StringName) -> void:
|
||||
_plans.erase(warehouse_id)
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {&"version": SNAPSHOT_VERSION, &"plans": get_plans()}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary, facilities: FacilityService,
|
||||
economy: EconomyService, minute: int) -> bool:
|
||||
last_error = ""
|
||||
if not _keys_match(snapshot, [&"version", &"plans"]) \
|
||||
or typeof(snapshot[&"version"]) != TYPE_INT or snapshot[&"version"] != SNAPSHOT_VERSION \
|
||||
or not snapshot[&"plans"] is Array or facilities == null or economy == null or minute < 0:
|
||||
return _restore_failure("Invalid automatic purchase snapshot.")
|
||||
# At most one plan per real warehouse/catalog item; input cannot broaden it.
|
||||
var maximum_rows := facilities.get_instances().size() * Catalog.get_purchase_items().size()
|
||||
if (snapshot[&"plans"] as Array).size() > maximum_rows:
|
||||
return _restore_failure("Too many automatic purchase plans.")
|
||||
var candidate: Dictionary = {}
|
||||
for value: Variant in snapshot[&"plans"]:
|
||||
if not value is Dictionary:
|
||||
return _restore_failure("Automatic purchase row is not a Dictionary.")
|
||||
var row: Dictionary = value
|
||||
if not _keys_match(row, [&"warehouse_id", &"item_id", &"enabled", &"target_stock",
|
||||
&"last_attempt_minute", &"last_result", &"last_transaction_id", &"last_quantity", &"last_unit_price", &"last_total_cost"]) \
|
||||
or typeof(row[&"warehouse_id"]) != TYPE_STRING or typeof(row[&"item_id"]) != TYPE_STRING \
|
||||
or not _text(row[&"warehouse_id"]) or not _text(row[&"item_id"]) \
|
||||
or typeof(row[&"enabled"]) != TYPE_BOOL or typeof(row[&"target_stock"]) != TYPE_INT \
|
||||
or typeof(row[&"last_attempt_minute"]) != TYPE_INT \
|
||||
or typeof(row[&"last_result"]) != TYPE_STRING_NAME \
|
||||
or typeof(row[&"last_transaction_id"]) != TYPE_STRING \
|
||||
or typeof(row[&"last_quantity"]) != TYPE_INT or typeof(row[&"last_unit_price"]) != TYPE_INT or typeof(row[&"last_total_cost"]) != TYPE_INT:
|
||||
return _restore_failure("Invalid automatic purchase row fields/types.")
|
||||
var warehouse_id := StringName(row[&"warehouse_id"])
|
||||
var item_id := StringName(row[&"item_id"])
|
||||
if not bool(validate_intent(warehouse_id, item_id, row[&"enabled"], row[&"target_stock"], facilities)[&"ok"]):
|
||||
return _restore_failure("Automatic purchase plan does not match its warehouse/catalog.")
|
||||
if candidate.has(warehouse_id) and (candidate[warehouse_id] as Dictionary).has(item_id):
|
||||
return _restore_failure("Duplicate automatic purchase plan.")
|
||||
var attempted := int(row[&"last_attempt_minute"])
|
||||
var reason: StringName = row[&"last_result"]
|
||||
var transaction_id := StringName(row[&"last_transaction_id"])
|
||||
var quantity: int = row[&"last_quantity"]
|
||||
var unit_price: int = row[&"last_unit_price"]
|
||||
var total_cost: int = row[&"last_total_cost"]
|
||||
if reason == RESULT_PURCHASED and not valid_charge(quantity, unit_price, total_cost):
|
||||
return _restore_failure("Saved automatic purchase quote is invalid.")
|
||||
if reason != RESULT_PURCHASED and (quantity != 0 or unit_price != 0 or total_cost != 0):
|
||||
return _restore_failure("Unpurchased automatic intent retained a charge.")
|
||||
if attempted < -1 or attempted > minute \
|
||||
or (attempted == -1 and (not reason.is_empty() or not transaction_id.is_empty())) \
|
||||
or (attempted >= 0 and reason != RESULT_PURCHASED and reason not in FAILED_RESULTS):
|
||||
return _restore_failure("Invalid automatic purchase attempt state.")
|
||||
if attempted >= 0:
|
||||
var paid_id := economy.get_transaction_id_for_idempotency_key(order_key(warehouse_id, item_id, attempted))
|
||||
if reason == RESULT_PURCHASED:
|
||||
if not valid_receipt(economy, warehouse_id, item_id, attempted, transaction_id, total_cost, quantity, unit_price):
|
||||
return _restore_failure("Automatic purchase receipt differs from its unique ledger transaction.")
|
||||
elif not transaction_id.is_empty() or (not paid_id.is_empty() and (reason != &"REPLAY_MISMATCH" or valid_receipt(economy, warehouse_id, item_id, attempted, paid_id))):
|
||||
return _restore_failure("Failed automatic purchase retained its own payment.")
|
||||
if not candidate.has(warehouse_id):
|
||||
candidate[warehouse_id] = {}
|
||||
candidate[warehouse_id][item_id] = row.duplicate(true)
|
||||
_plans = candidate
|
||||
return true
|
||||
|
||||
|
||||
static func receipt_memo(item_id: StringName, quantity: int, unit_price: int, total_cost: int) -> StringName:
|
||||
if String(item_id).strip_edges().is_empty() or not valid_charge(quantity, unit_price, total_cost):
|
||||
return &""
|
||||
# Character length, not delimiter escaping, keeps arbitrary item punctuation
|
||||
# unambiguous. Only automatic payments use this immutable historical quote.
|
||||
return StringName(RECEIPT_PREFIX + "%d:%s:%d:%d:%d" % [
|
||||
String(item_id).length(), item_id, quantity, unit_price, total_cost])
|
||||
|
||||
|
||||
static func decode_receipt_memo(memo: StringName) -> Dictionary:
|
||||
var text := String(memo)
|
||||
if not text.begins_with(RECEIPT_PREFIX): return {}
|
||||
var body := text.substr(RECEIPT_PREFIX.length())
|
||||
var delimiter := body.find(":")
|
||||
if delimiter <= 0: return {}
|
||||
var item_length := _positive_decimal(body.substr(0, delimiter))
|
||||
var remainder := body.substr(delimiter + 1)
|
||||
if item_length <= 0 or item_length >= remainder.length() or remainder.substr(item_length, 1) != ":":
|
||||
return {}
|
||||
var item_text := remainder.substr(0, item_length)
|
||||
if item_text.strip_edges().is_empty(): return {}
|
||||
var amounts := remainder.substr(item_length + 1).split(":", true)
|
||||
if amounts.size() != 3: return {}
|
||||
var quantity := _positive_decimal(amounts[0])
|
||||
var unit_price := _positive_decimal(amounts[1])
|
||||
var total_cost := _positive_decimal(amounts[2])
|
||||
if not valid_charge(quantity, unit_price, total_cost) \
|
||||
or receipt_memo(StringName(item_text), quantity, unit_price, total_cost) != memo:
|
||||
return {}
|
||||
return {&"item_id": item_text, &"quantity": quantity, &"unit_price": unit_price, &"total_cost": total_cost}
|
||||
|
||||
|
||||
static func receipt_from_ledger(economy: EconomyService, warehouse_id: StringName,
|
||||
item_id: StringName, minute: int, transaction_id: StringName) -> Dictionary:
|
||||
if economy == null or transaction_id.is_empty() \
|
||||
or economy.get_transaction_id_for_idempotency_key(order_key(warehouse_id, item_id, minute)) != transaction_id:
|
||||
return {}
|
||||
var entries: Array = economy.get_transaction_entries(transaction_id)
|
||||
if entries.size() != 1: return {}
|
||||
var entry: LedgerEntry = entries[0]
|
||||
var receipt := decode_receipt_memo(entry.memo_key)
|
||||
if receipt.is_empty() or receipt[&"item_id"] != String(item_id) \
|
||||
or entry.absolute_minute != minute or entry.source_entity_id != warehouse_id \
|
||||
or entry.category != &"OPERATING_EXPENSE" or not entry.related_transaction_id.is_empty() \
|
||||
or entry.credit_account != &"PLAYER_CASH" or entry.debit_account != &"OPERATING_EXPENSE" \
|
||||
or entry.amount != receipt[&"total_cost"]:
|
||||
return {}
|
||||
return receipt
|
||||
|
||||
|
||||
static func valid_receipt(economy: EconomyService, warehouse_id: StringName,
|
||||
item_id: StringName, minute: int, transaction_id: StringName, total_cost: int = -1,
|
||||
quantity: int = -1, unit_price: int = -1) -> bool:
|
||||
var receipt := receipt_from_ledger(economy, warehouse_id, item_id, minute, transaction_id)
|
||||
return not receipt.is_empty() and (total_cost < 0 or receipt[&"total_cost"] == total_cost) \
|
||||
and (quantity < 0 or receipt[&"quantity"] == quantity) \
|
||||
and (unit_price < 0 or receipt[&"unit_price"] == unit_price)
|
||||
|
||||
|
||||
static func _positive_decimal(text: String) -> int:
|
||||
# Validate textual range before to_int: no float conversion or saturating
|
||||
# overflow can reinterpret a corrupt receipt as another positive amount.
|
||||
if text.is_empty() or text.length() > MAX_SIGNED_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_SIGNED_INT_TEXT.length() and text > MAX_SIGNED_INT_TEXT: return 0
|
||||
return text.to_int()
|
||||
|
||||
|
||||
static func valid_charge(quantity: int, unit_price: int, total_cost: int) -> bool:
|
||||
# Existing purchase input ceiling; division avoids multiplication overflow.
|
||||
@warning_ignore("integer_division")
|
||||
return quantity > 0 and quantity <= 10000 and unit_price > 0 and total_cost > 0 and total_cost / quantity == unit_price and total_cost % quantity == 0
|
||||
|
||||
|
||||
static func validate_intent(warehouse_id: StringName, item_id: StringName, enabled: bool,
|
||||
target_stock: int, facilities: FacilityService) -> Dictionary:
|
||||
var facility: FacilityInstance = facilities.get_instance(warehouse_id) if facilities != null else null
|
||||
if facility == null or facility.definition_id != &"warehouse":
|
||||
return {&"ok": false, &"reason": &"NOT_WAREHOUSE"}
|
||||
var item := Catalog.get_item(item_id)
|
||||
if item.is_empty() or not bool(item.get(&"purchasable", false)):
|
||||
return {&"ok": false, &"reason": &"ITEM_NOT_PURCHASABLE"}
|
||||
var maximum := maximum_target(warehouse_id, item_id, facilities)
|
||||
if target_stock < 0 or target_stock > maximum or (enabled and target_stock == 0):
|
||||
return {&"ok": false, &"reason": &"INVALID_TARGET", &"maximum_target": maximum}
|
||||
return {&"ok": true, &"maximum_target": maximum}
|
||||
|
||||
|
||||
static func maximum_target(warehouse_id: StringName, item_id: StringName, facilities: FacilityService) -> int:
|
||||
var facility: FacilityInstance = facilities.get_instance(warehouse_id) if facilities != null else null
|
||||
var item := Catalog.get_item(item_id)
|
||||
if facility == null or facility.definition_id != &"warehouse" or item.is_empty() \
|
||||
or not bool(item.get(&"purchasable", false)) or int(item.get(&"unit_weight", 0)) <= 0:
|
||||
return 0
|
||||
@warning_ignore("integer_division")
|
||||
return Catalog.storage_capacity(facility.definition_id) / int(item[&"unit_weight"])
|
||||
|
||||
|
||||
static func order_key(warehouse_id: StringName, item_id: StringName, minute: int) -> StringName:
|
||||
# Length encoding avoids punctuation collisions. This is not a command ID.
|
||||
return StringName("supply.auto.%d:%s.%d:%s.%d" % [
|
||||
String(warehouse_id).length(), warehouse_id, String(item_id).length(), item_id, minute])
|
||||
|
||||
|
||||
static func _empty_plan(warehouse_id: StringName, item_id: StringName) -> Dictionary:
|
||||
return {&"warehouse_id": String(warehouse_id), &"item_id": String(item_id), &"enabled": false,
|
||||
&"target_stock": 0, &"last_attempt_minute": -1, &"last_result": &"", &"last_transaction_id": "", &"last_quantity": 0, &"last_unit_price": 0, &"last_total_cost": 0}
|
||||
|
||||
|
||||
static func _sorted_names(values: Dictionary) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for value: Variant in values: ids.append(StringName(value))
|
||||
ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return ids
|
||||
|
||||
|
||||
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 _text(value: Variant) -> bool:
|
||||
return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and not String(value).strip_edges().is_empty()
|
||||
|
||||
|
||||
func _restore_failure(message: String) -> bool:
|
||||
last_error = message
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxvu3dy4bl6sj
|
||||
@@ -41,28 +41,149 @@ func purchase(runtime: Variant, command: SimulationCommand) -> CommandResult:
|
||||
var warehouse_id := StringName(payload[&"warehouse_id"])
|
||||
var item_id := StringName(payload[&"item_id"])
|
||||
var quantity := int(payload[&"quantity"])
|
||||
return _purchase_paid(runtime, warehouse_id, item_id, quantity,
|
||||
runtime._runtime_event_minute(), StringName("supply.purchase.%d" % command.command_id), command.command_id)
|
||||
|
||||
|
||||
func _purchase_paid(runtime: Variant, warehouse_id: StringName, item_id: StringName,
|
||||
quantity: int, minute: int, ledger_key: StringName, command_id: int = 0, record_auto_attempt: bool = false) -> CommandResult:
|
||||
# Shared transaction only; internal replenishment does not enqueue a command.
|
||||
var quote := preview_purchase(runtime, warehouse_id, item_id, quantity)
|
||||
if not bool(quote[&"ok"]):
|
||||
return _failure(command, StringName(quote[&"reason"]), quote)
|
||||
return _purchase_failure(command_id, StringName(quote[&"reason"]), quote)
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id)
|
||||
var item := SupplyCatalog.get_item(item_id)
|
||||
var minute: int = runtime._runtime_event_minute()
|
||||
var inventory_before: Dictionary = runtime.inventory_service.create_snapshot()
|
||||
var economy_before: Dictionary = runtime.economy_service.create_snapshot()
|
||||
var plans_before: Dictionary = runtime.auto_purchase_plans.create_snapshot() if record_auto_attempt else {}
|
||||
var stocked: Dictionary = runtime.inventory_service.add_stack(facility.local_inventory_id, item_id, quantity, 100, minute + int(item[&"shelf_life_minutes"]), minute, int(item[&"unit_weight"]))
|
||||
if not bool(stocked.get(&"ok", false)):
|
||||
return _failure(command, &"INVENTORY_ERROR")
|
||||
var paid: CommandResult = runtime.economy_service.transfer(&"PLAYER_CASH", runtime.OPERATING_EXPENSE_ACCOUNT, int(quote[&"total_cost"]), minute, &"OPERATING_EXPENSE", warehouse_id, StringName("supply.purchase.%d" % command.command_id), &"supply_purchase", &"", false)
|
||||
runtime.inventory_service.restore_snapshot(inventory_before)
|
||||
runtime.economy_service.restore_snapshot(economy_before)
|
||||
return _purchase_failure(command_id, &"INVENTORY_ERROR")
|
||||
var memo: StringName = SupplyAutoPurchasePlans.receipt_memo(item_id, quantity, int(item[&"unit_price"]), int(quote[&"total_cost"])) if record_auto_attempt else &"supply_purchase"
|
||||
var paid: CommandResult = runtime.economy_service.transfer(&"PLAYER_CASH", runtime.OPERATING_EXPENSE_ACCOUNT, int(quote[&"total_cost"]), minute, &"OPERATING_EXPENSE", warehouse_id, ledger_key, memo, &"", false)
|
||||
if not paid.is_success():
|
||||
runtime.inventory_service.restore_snapshot(inventory_before)
|
||||
runtime.economy_service.restore_snapshot(economy_before)
|
||||
return _failure(command, &"PAYMENT_FAILED")
|
||||
return _purchase_failure(command_id, &"PAYMENT_FAILED")
|
||||
if record_auto_attempt and not runtime.auto_purchase_plans.record_attempt(warehouse_id, item_id, minute, CommandResult.succeeded(0, {&"transaction_id": paid.details[&"transaction_id"], &"quantity": quantity, &"unit_price": item[&"unit_price"], &"total_cost": quote[&"total_cost"]}), runtime.economy_service):
|
||||
runtime.inventory_service.restore_snapshot(inventory_before)
|
||||
runtime.economy_service.restore_snapshot(economy_before)
|
||||
return _purchase_failure(command_id, &"PAYMENT_FAILED")
|
||||
if not runtime.economy_service.commit_transaction_notification(StringName(paid.details[&"transaction_id"])):
|
||||
runtime.inventory_service.restore_snapshot(inventory_before)
|
||||
runtime.economy_service.restore_snapshot(economy_before)
|
||||
return _failure(command, &"PAYMENT_FAILED")
|
||||
if record_auto_attempt:
|
||||
runtime.auto_purchase_plans.restore_snapshot(plans_before, runtime.facility_service, runtime.economy_service, minute)
|
||||
return _purchase_failure(command_id, &"PAYMENT_FAILED")
|
||||
quote.merge({&"warehouse_id": warehouse_id, &"item_id": item_id, &"quantity": quantity, &"transaction_id": paid.details[&"transaction_id"]})
|
||||
return CommandResult.succeeded(command.command_id, quote)
|
||||
return CommandResult.succeeded(command_id, quote)
|
||||
|
||||
|
||||
func set_auto_purchase(runtime: Variant, command: SimulationCommand) -> CommandResult:
|
||||
var payload := command.payload
|
||||
if not _exact_keys(payload, [&"warehouse_id", &"item_id", &"enabled", &"target_stock"]) \
|
||||
or not _text(payload[&"warehouse_id"]) or not _text(payload[&"item_id"]) \
|
||||
or typeof(payload[&"enabled"]) != TYPE_BOOL or typeof(payload[&"target_stock"]) != TYPE_INT:
|
||||
return _failure(command, &"INVALID_PAYLOAD")
|
||||
var changed: Dictionary = runtime.auto_purchase_plans.set_plan(
|
||||
StringName(payload[&"warehouse_id"]), StringName(payload[&"item_id"]),
|
||||
payload[&"enabled"], payload[&"target_stock"], runtime.facility_service)
|
||||
if not bool(changed.get(&"ok", false)):
|
||||
return _failure(command, StringName(changed.get(&"reason", &"INVALID_TARGET")), changed)
|
||||
return CommandResult.succeeded(command.command_id, changed[&"plan"])
|
||||
|
||||
|
||||
func auto_purchase_report(runtime: Variant, warehouse_id: StringName, item_id: StringName) -> Dictionary:
|
||||
var row: Dictionary = runtime.auto_purchase_plans.get_plan(warehouse_id, item_id)
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id)
|
||||
row[&"maximum_target"] = SupplyAutoPurchasePlans.maximum_target(warehouse_id, item_id, runtime.facility_service)
|
||||
row[&"physical_quantity"] = runtime.inventory_service.total_quantity(facility.local_inventory_id, item_id) if facility != null else 0
|
||||
row[&"incoming_quantity"] = _incoming_quantity(runtime, facility.local_inventory_id, item_id) if facility != null else 0
|
||||
row[&"deficit"] = maxi(int(row[&"target_stock"]) - int(row[&"physical_quantity"]) - int(row[&"incoming_quantity"]), 0)
|
||||
row[&"quote"] = preview_purchase(runtime, warehouse_id, item_id, int(row[&"deficit"])) if int(row[&"deficit"]) > 0 else {}
|
||||
row[&"reason"] = &""
|
||||
if not bool(row[&"enabled"]):
|
||||
row[&"state"] = &"DISABLED"
|
||||
elif bool(runtime.get_operations_report().get(&"tick_blocked", false)):
|
||||
row[&"state"] = &"BLOCKED"
|
||||
row[&"reason"] = &"OPERATIONS_BLOCKED"
|
||||
elif int(runtime._state.get_resource(&"mana")) <= 0:
|
||||
row[&"state"] = &"BLOCKED"
|
||||
row[&"reason"] = &"NO_POWER"
|
||||
elif _receiver_will_close(facility, runtime._clock.absolute_minute):
|
||||
row[&"state"] = &"BLOCKED"
|
||||
row[&"reason"] = &"FACILITY_CLOSED"
|
||||
elif int(row[&"last_attempt_minute"]) == runtime._clock.absolute_minute:
|
||||
row[&"state"] = &"ORDERED_THIS_MINUTE" if row[&"last_result"] == SupplyAutoPurchasePlans.RESULT_PURCHASED else &"BLOCKED"
|
||||
if row[&"state"] == &"BLOCKED": row[&"reason"] = row[&"last_result"]
|
||||
elif int(row[&"deficit"]) == 0:
|
||||
row[&"state"] = &"TARGET_REACHED"
|
||||
elif bool((row[&"quote"] as Dictionary).get(&"ok", false)):
|
||||
row[&"state"] = &"READY"
|
||||
else:
|
||||
row[&"state"] = &"BLOCKED"
|
||||
row[&"reason"] = (row[&"quote"] as Dictionary).get(&"reason", &"UNAVAILABLE")
|
||||
return row
|
||||
|
||||
|
||||
func advance_auto_purchases(runtime: Variant, minute: int) -> Array[Dictionary]:
|
||||
var results: Array[Dictionary] = []
|
||||
if bool(runtime.get_operations_report().get(&"tick_blocked", false)) or int(runtime._state.get_resource(&"mana")) <= 0:
|
||||
return results
|
||||
for plan: Dictionary in runtime.auto_purchase_plans.get_plans():
|
||||
if not bool(plan[&"enabled"]) or int(plan[&"last_attempt_minute"]) >= minute:
|
||||
continue
|
||||
var warehouse_id := StringName(plan[&"warehouse_id"])
|
||||
var item_id := StringName(plan[&"item_id"])
|
||||
var row := auto_purchase_report(runtime, warehouse_id, item_id)
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id)
|
||||
if int(row[&"deficit"]) <= 0 or facility == null or not facility.allows_logistics() or _receiver_will_close(facility, minute):
|
||||
continue
|
||||
var ledger_key := SupplyAutoPurchasePlans.order_key(warehouse_id, item_id, minute)
|
||||
var prior_id: StringName = runtime.economy_service.get_transaction_id_for_idempotency_key(ledger_key)
|
||||
var result: CommandResult
|
||||
if prior_id.is_empty():
|
||||
if row[&"state"] != &"READY" or not bool((row[&"quote"] as Dictionary).get(&"ok", false)):
|
||||
continue
|
||||
result = _purchase_paid(runtime, warehouse_id, item_id, int(row[&"deficit"]), minute, ledger_key, 0, true)
|
||||
else:
|
||||
# A committed key replays its original item/quantity/price, even if
|
||||
# today's quote is unaffordable. It never stocks, charges or notifies.
|
||||
var receipt := SupplyAutoPurchasePlans.receipt_from_ledger(runtime.economy_service, warehouse_id, item_id, minute, prior_id)
|
||||
if receipt.is_empty():
|
||||
result = _purchase_failure(0, &"REPLAY_MISMATCH")
|
||||
else:
|
||||
result = CommandResult.succeeded(0, {&"transaction_id": prior_id, &"quantity": receipt[&"quantity"], &"unit_price": receipt[&"unit_price"], &"total_cost": receipt[&"total_cost"], &"replayed": true})
|
||||
if (not result.is_success() or not prior_id.is_empty()) and not runtime.auto_purchase_plans.record_attempt(warehouse_id, item_id, minute, result, runtime.economy_service):
|
||||
push_error("Automatic purchase could not record its committed attempt.")
|
||||
return results
|
||||
results.append({&"warehouse_id": warehouse_id, &"item_id": item_id,
|
||||
&"minute": minute, &"result": result.to_dictionary()})
|
||||
return results
|
||||
|
||||
|
||||
func _receiver_will_close(facility: FacilityInstance, minute: int) -> bool:
|
||||
# LOGISTICS precedes FACILITY_COMPLETION. Honor an already scheduled close
|
||||
# without applying policy early or changing manual purchase/phase ordering.
|
||||
return facility != null and facility.policy_pending_effective_at >= 0 \
|
||||
and facility.policy_pending_effective_at <= minute \
|
||||
and facility.policy_pending.has(&"business_open") \
|
||||
and not bool(facility.policy_pending[&"business_open"])
|
||||
|
||||
|
||||
func _purchase_failure(command_id: int, reason: StringName, details: Dictionary = {}) -> CommandResult:
|
||||
var result := details.duplicate(true)
|
||||
result[&"reason"] = reason
|
||||
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command_id, "Supply command rejected: %s" % reason, result)
|
||||
|
||||
|
||||
func _exact_keys(value: Dictionary, expected: Array[StringName]) -> bool:
|
||||
if value.size() != expected.size(): return false
|
||||
for key: Variant in value:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(key) not in expected: return false
|
||||
return true
|
||||
|
||||
|
||||
func set_production(runtime: Variant, command: SimulationCommand) -> CommandResult:
|
||||
@@ -90,6 +211,9 @@ func get_report(runtime: Variant) -> Dictionary:
|
||||
var quote := preview_purchase(runtime, facility.instance_id, SupplyCatalog.RAW_FOOD, 1)
|
||||
row[&"can_receive"] = bool(quote[&"ok"])
|
||||
row[&"reason_codes"] = [] if bool(quote[&"ok"]) else [quote[&"reason"]]
|
||||
row[&"auto_purchase_plans"] = []
|
||||
for item: Dictionary in SupplyCatalog.get_purchase_items():
|
||||
(row[&"auto_purchase_plans"] as Array).append(auto_purchase_report(runtime, facility.instance_id, StringName(item[&"item_id"])))
|
||||
warehouses.append(row)
|
||||
&"kitchen":
|
||||
row.merge(runtime.kitchen_production.get_report(facility.instance_id), true)
|
||||
|
||||
@@ -11,6 +11,7 @@ signal action_requested(action: StringName)
|
||||
const KEY_PREFIX := "ui.supply."
|
||||
const PURCHASE: StringName = &"purchase_supplies"
|
||||
const PRODUCTION: StringName = &"set_kitchen_production"
|
||||
const AUTO_PURCHASE: StringName = &"set_supply_auto_purchase"
|
||||
const ITEM_NAMES := {&"ingredient/raw_food": "食材", &"ingredient/basic_meal": "完成料理"}
|
||||
const STATE_NAMES := {
|
||||
&"SUSPENDED": "破壊・摘発による休止",
|
||||
@@ -56,6 +57,8 @@ const REASONS := {
|
||||
&"NO_POWER": "施設へ魔力が届いていません。", &"CLOSED": "施設が閉鎖されています。",
|
||||
&"NOT_KITCHEN": "厨房を選んでください。", &"INVALID_TARGET": "目標在庫の上限を確認してください。",
|
||||
&"BANKRUPT": "運営終了後は変更できません。", &"UNAVAILABLE": "現在は操作できません。",
|
||||
&"OPERATIONS_BLOCKED": "運営の停止理由を確認し、再開してください。",
|
||||
&"REPLAY_MISMATCH": "仕入れ履歴と現在の設定を確認してください。",
|
||||
}
|
||||
|
||||
var summary_label: Label
|
||||
@@ -65,6 +68,10 @@ var quantity_spin: SpinBox
|
||||
var purchase_quote_label: Label
|
||||
var purchase_button: Button
|
||||
var warehouse_stock_label: Label
|
||||
var auto_purchase_enabled: CheckButton
|
||||
var auto_purchase_target: SpinBox
|
||||
var auto_purchase_button: Button
|
||||
var auto_purchase_status: Label
|
||||
var kitchen_option: OptionButton
|
||||
var production_enabled: CheckButton
|
||||
var target_spin: SpinBox
|
||||
@@ -84,6 +91,7 @@ var _report: Dictionary = {}
|
||||
var _quote: Dictionary = {}
|
||||
var _pages: Dictionary = {}
|
||||
var _kitchen_drafts: Dictionary = {}
|
||||
var _auto_purchase_drafts: Dictionary = {}
|
||||
var _pending_commands: Dictionary = {}
|
||||
var _queue_instance_id := 0
|
||||
var _sequence := 1
|
||||
@@ -152,6 +160,23 @@ func _build_interface() -> void:
|
||||
purchase_page.add_child(purchase_quote_label)
|
||||
purchase_button = _button("購入", "PurchaseSuppliesButton", submit_purchase)
|
||||
purchase_page.add_child(purchase_button)
|
||||
auto_purchase_enabled = CheckButton.new()
|
||||
auto_purchase_enabled.name = "SupplyAutoPurchaseEnabled"
|
||||
auto_purchase_enabled.text = "自動仕入れ(不足分を有料購入)"
|
||||
for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]:
|
||||
auto_purchase_enabled.add_theme_color_override(color_name, Color("55371e"))
|
||||
auto_purchase_enabled.add_theme_color_override("font_disabled_color", Color("766650"))
|
||||
LayerSkin.register_toggle_text(auto_purchase_enabled, LayerSkin.active_layer(self))
|
||||
auto_purchase_enabled.toggled.connect(func(_value: bool) -> void: _store_auto_purchase_draft())
|
||||
purchase_page.add_child(auto_purchase_enabled)
|
||||
purchase_page.add_child(_label("倉庫の目標在庫 · 選択した品目"))
|
||||
auto_purchase_target = _spin("SupplyAutoPurchaseTarget", 0, 0)
|
||||
auto_purchase_target.value_changed.connect(func(_value: float) -> void: _store_auto_purchase_draft())
|
||||
purchase_page.add_child(auto_purchase_target)
|
||||
auto_purchase_button = _button("自動仕入れ設定を保存", "SaveSupplyAutoPurchaseButton", submit_auto_purchase)
|
||||
purchase_page.add_child(auto_purchase_button)
|
||||
auto_purchase_status = _label("", "SupplyAutoPurchaseStatus")
|
||||
purchase_page.add_child(auto_purchase_status)
|
||||
var kitchen_page: VBoxContainer = _pages[&"kitchen"]
|
||||
kitchen_page.add_child(_label("設定する厨房"))
|
||||
kitchen_option = _option("SupplyKitchenOption")
|
||||
@@ -211,6 +236,7 @@ func refresh() -> void:
|
||||
if queue != null and queue.get_instance_id() != _queue_instance_id:
|
||||
_queue_instance_id = queue.get_instance_id()
|
||||
_kitchen_drafts.clear()
|
||||
_auto_purchase_drafts.clear()
|
||||
for picker: OptionButton in [warehouse_option, item_option, kitchen_option]:
|
||||
picker.get_popup().hide()
|
||||
_synchronize_commands(queue)
|
||||
@@ -246,11 +272,13 @@ func _refresh_purchase() -> void:
|
||||
# can see why it cannot be bought instead of silently reducing its quantity.
|
||||
quantity_spin.max_value = maxi(int(quantity_spin.value), maxi(1, int(_quote.get(&"max_quantity", 1))))
|
||||
var pending := _has_pending(PURCHASE)
|
||||
var setting_pending := _has_pending(AUTO_PURCHASE)
|
||||
quantity_spin.editable = not pending and not catalog.is_empty() and not warehouse.is_empty()
|
||||
warehouse_option.disabled = pending or _rows(&"warehouses").is_empty()
|
||||
item_option.disabled = pending or _rows(&"catalog").is_empty()
|
||||
warehouse_option.disabled = pending or setting_pending or _rows(&"warehouses").is_empty()
|
||||
item_option.disabled = pending or setting_pending or _rows(&"catalog").is_empty()
|
||||
purchase_button.disabled = pending or not bool(_quote.get(&"ok", false))
|
||||
purchase_button.text = "購入を受理済み…" if pending else "購入"
|
||||
_refresh_auto_purchase(warehouse)
|
||||
if catalog.is_empty() or warehouse.is_empty():
|
||||
purchase_quote_label.text = "受入倉庫と品目を選んでください。"
|
||||
return
|
||||
@@ -259,6 +287,66 @@ func _refresh_purchase() -> void:
|
||||
purchase_quote_label.text += "\n" + _reason_text(_quote.get(&"reason", &"UNAVAILABLE"))
|
||||
|
||||
|
||||
func _refresh_auto_purchase(warehouse: Dictionary) -> void:
|
||||
var row: Dictionary = {}
|
||||
for value: Variant in warehouse.get(&"auto_purchase_plans", []):
|
||||
if value is Dictionary and StringName(value.get(&"item_id", &"")) == selected_item_id:
|
||||
row = value
|
||||
break
|
||||
var draft: Dictionary = _auto_purchase_drafts.get(_auto_purchase_key(), row)
|
||||
var pending := _has_pending(AUTO_PURCHASE)
|
||||
var target := int(draft.get(&"target_stock", 0))
|
||||
var maximum := int(row.get(&"maximum_target", 0))
|
||||
var enabled := bool(draft.get(&"enabled", false))
|
||||
auto_purchase_enabled.disabled = pending or row.is_empty()
|
||||
auto_purchase_enabled.set_pressed_no_signal(enabled)
|
||||
auto_purchase_target.min_value = 0
|
||||
auto_purchase_target.max_value = maxi(maximum, target)
|
||||
auto_purchase_target.set_value_no_signal(target)
|
||||
auto_purchase_target.editable = not pending and not row.is_empty()
|
||||
auto_purchase_button.disabled = pending or row.is_empty() or target > maximum or (enabled and target <= 0)
|
||||
auto_purchase_button.text = "設定を受理済み…" if pending else "自動仕入れ設定を保存"
|
||||
if row.is_empty():
|
||||
auto_purchase_status.text = "完成した倉庫と購入できる品目を選んでください。"
|
||||
return
|
||||
var state := StringName(row.get(&"state", &"DISABLED"))
|
||||
var state_text := "停止中"
|
||||
match state:
|
||||
&"READY": state_text = "不足分の入荷待ち"
|
||||
&"TARGET_REACHED": state_text = "目標在庫に到達"
|
||||
&"ORDERED_THIS_MINUTE": state_text = "入庫済み · 次の不足判定を待機"
|
||||
&"BLOCKED": state_text = _reason_text(row.get(&"reason", &"UNAVAILABLE"))
|
||||
auto_purchase_status.text = "現在の設定:%s · 目標 %d / 上限 %d\n%s\n倉庫の現物 %d · 入荷予定 %d" % [
|
||||
"ON" if bool(row.get(&"enabled", false)) else "OFF", row.get(&"target_stock", 0), maximum,
|
||||
state_text, row.get(&"physical_quantity", 0), row.get(&"incoming_quantity", 0)]
|
||||
var quote: Dictionary = row.get(&"quote", {})
|
||||
if int(row.get(&"deficit", 0)) > 0 and not quote.is_empty():
|
||||
auto_purchase_status.text += "\n不足 %d · 今の購入見積 %dG" % [row[&"deficit"], quote.get(&"total_cost", 0)]
|
||||
if row.get(&"last_result", &"") == &"PURCHASED":
|
||||
auto_purchase_status.text += "\n前回入庫 %d個 · 代金 %dG(単価 %dG)" % [row[&"last_quantity"], row[&"last_total_cost"], row[&"last_unit_price"]]
|
||||
if _auto_purchase_drafts.has(_auto_purchase_key()):
|
||||
auto_purchase_status.text += "\n未保存の変更です。設定を保存すると反映します。"
|
||||
auto_purchase_status.text += "\n在庫が減ると目標まで不足分を購入します。料金は手動購入と同じです。"
|
||||
|
||||
|
||||
func _auto_purchase_key() -> String:
|
||||
return "%d:%s%d:%s" % [String(selected_warehouse_id).length(), selected_warehouse_id,
|
||||
String(selected_item_id).length(), selected_item_id]
|
||||
|
||||
|
||||
func _store_auto_purchase_draft() -> void:
|
||||
if _refreshing or selected_warehouse_id.is_empty() or selected_item_id.is_empty(): return
|
||||
_auto_purchase_drafts[_auto_purchase_key()] = {&"enabled": auto_purchase_enabled.button_pressed,
|
||||
&"target_stock": int(auto_purchase_target.value)}
|
||||
refresh()
|
||||
|
||||
|
||||
func submit_auto_purchase() -> void:
|
||||
if selected_warehouse_id.is_empty() or selected_item_id.is_empty() or _has_pending(AUTO_PURCHASE): return
|
||||
_submit(AUTO_PURCHASE, {&"warehouse_id": selected_warehouse_id, &"item_id": selected_item_id,
|
||||
&"enabled": auto_purchase_enabled.button_pressed, &"target_stock": int(auto_purchase_target.value)})
|
||||
|
||||
|
||||
func _refresh_kitchen() -> void:
|
||||
selected_kitchen_id = _fill_option(kitchen_option, _rows(&"kitchens"), &"facility_id", selected_kitchen_id, "完成した厨房がありません")
|
||||
var row := _row(&"kitchens", &"facility_id", selected_kitchen_id)
|
||||
@@ -365,7 +453,13 @@ func _show_result(result: Variant, context: Dictionary) -> void:
|
||||
_set_result(_reason_text(details.get(&"reason", &"UNAVAILABLE")), false)
|
||||
return
|
||||
var payload: Dictionary = context[&"payload"]
|
||||
if context[&"type"] == PRODUCTION:
|
||||
if context[&"type"] == AUTO_PURCHASE:
|
||||
var warehouse_id := StringName(payload.get(&"warehouse_id", &""))
|
||||
var item_id := StringName(payload.get(&"item_id", &""))
|
||||
_auto_purchase_drafts.erase("%d:%s%d:%s" % [String(warehouse_id).length(), warehouse_id, String(item_id).length(), item_id])
|
||||
_set_result("%s:自動仕入れ%s、目標在庫 %dに変更しました。" % [
|
||||
_facility_by_id(warehouse_id), "ON" if bool(details.get(&"enabled", false)) else "OFF", details.get(&"target_stock", 0)], true)
|
||||
elif context[&"type"] == PRODUCTION:
|
||||
var id := StringName(payload.get(&"facility_id", &""))
|
||||
_kitchen_drafts.erase(id)
|
||||
_set_result("%s:自動生産%s、目標在庫 %dに変更しました。" % [_facility_by_id(id), "ON" if bool(details.get(&"enabled", payload.get(&"enabled", false))) else "OFF", details.get(&"target_stock", payload.get(&"target_stock", 0))], true)
|
||||
@@ -382,13 +476,13 @@ func _synchronize_commands(queue: Variant) -> void:
|
||||
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
var type := StringName(command.get("command_type", &""))
|
||||
if type in [PURCHASE, PRODUCTION]:
|
||||
if type in [PURCHASE, PRODUCTION, AUTO_PURCHASE]:
|
||||
_pending_commands[int(command.get("command_id", 0))] = {&"type": type, &"payload": command.get("payload", {}).duplicate(true)}
|
||||
|
||||
|
||||
func _has_pending(type: StringName, facility_id: StringName = &"") -> bool:
|
||||
for context: Dictionary in _pending_commands.values():
|
||||
if context[&"type"] == type and (facility_id.is_empty() or StringName(context[&"payload"].get(&"facility_id", &"")) == facility_id):
|
||||
if context[&"type"] == type and (facility_id.is_empty() or StringName(context[&"payload"].get(&"facility_id", context[&"payload"].get(&"warehouse_id", &""))) == facility_id):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
@@ -1306,6 +1306,12 @@ func find_nearest_walkable_recovery(
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
# A nested sorted-name read can otherwise abort to an empty typed array
|
||||
# while this outer capture still returns a seemingly valid Dictionary.
|
||||
for transit_actor_id: Variant in _transit_actor_ids:
|
||||
if typeof(transit_actor_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return {}
|
||||
|
||||
var connector_ids: Array = _connectors.keys()
|
||||
connector_ids.sort_custom(Callable(self, "_string_name_less"))
|
||||
var saved_connectors: Array = []
|
||||
|
||||
Reference in New Issue
Block a user