Files
TheTree_mock/scripts/special/peeping_room_service.gd
T

272 lines
15 KiB
GDScript

class_name PeepingRoomService
extends RefCounted
## Non-graphic state/reference machinery; production definitions remain pending.
const SECTION_VERSION := 1
const ALLOWED_TARGETS := [&"public_bath", &"adult_private_room"]
const OCCUPANCY_KIND := &"special_peeping_room"
const ID_PREFIX := &"peeping_room"
const ROOM_KEYS := [&"room_id", &"origin", &"footprint", &"cells", &"target_instance_id", &"target_definition_id", &"target_history", &"link_state", &"stopped", &"stop_reason"]
const EVIDENCE_KEYS := [&"evidence_id", &"room_id", &"target_instance_id", &"source_event_key", &"minute"]
var _rooms: Dictionary = {}
var _evidence: Dictionary = {}
var _world: WorldService
var _facilities: FacilityService
var _construction: ConstructionService
var _path: PathService
var _ids: StableIdGenerator
func configure(world: WorldService, facilities: FacilityService, construction: ConstructionService, path: PathService, ids: StableIdGenerator) -> void:
_world = world
_facilities = facilities
_construction = construction
_path = path
_ids = ids
static func result(reason: StringName = &"OK") -> Dictionary:
return {&"ok": reason == &"OK", &"reason": reason}
func is_empty() -> bool:
return _rooms.is_empty() and _evidence.is_empty()
func get_room(room_id: StringName) -> Dictionary:
return (_rooms.get(room_id, {}) as Dictionary).duplicate(true)
## Pure validation. own_room is supplied internally only for explicit relink/restore.
func validate_placement(origin: Vector3i, footprint: Vector2i = Vector2i(2, 1), target_id: StringName = &"", own_room: StringName = &"") -> Dictionary:
if origin.z != WorldService.LAYER_BACK: return result(&"WRONG_LAYER")
if footprint != Vector2i(2, 1): return result(&"WRONG_FOOTPRINT")
if target_id == &"vip_suite": return result(&"PENDING_P10_01")
var cells := _world.cells_for_footprint(origin, footprint)
var targets: Array[StringName] = []
var missing := false
for cell: Vector3i in cells:
if not _world.is_in_bounds(cell): return result(&"OUT_OF_BOUNDS")
if cell.y < 0 and not _world.is_excavated(cell): return result(&"NOT_EXCAVATED")
var front := Vector3i(cell.x, cell.y, WorldService.LAYER_FRONT)
if _world.get_occupant_kind(front) == &"construction": return result(&"TARGET_INCOMPLETE")
var occupant := _world.get_occupant(front)
if occupant.is_empty(): missing = true
elif occupant not in targets: targets.append(occupant)
if targets.size() > 1: return result(&"MULTIPLE_TARGETS")
if missing: return result(&"TARGET_MISSING" if targets.is_empty() else &"PARTIAL_TARGET")
var resolved_id := targets[0]
if not target_id.is_empty() and target_id != resolved_id: return result(&"TARGET_MISMATCH")
var target := _facilities.get_instance(resolved_id)
if target == null: return result(&"TARGET_UNKNOWN")
if target.definition_id not in ALLOWED_TARGETS: return result(&"TARGET_NOT_ALLOWED")
if target.origin.z != WorldService.LAYER_FRONT: return result(&"TARGET_WRONG_LAYER")
var built := _construction.get_facility(resolved_id)
if built.is_empty(): return result(&"TARGET_INCOMPLETE")
if built.get(&"definition_id") != target.definition_id: return result(&"TARGET_MISMATCH")
for cell: Vector3i in cells:
var front := Vector3i(cell.x, cell.y, WorldService.LAYER_FRONT)
if front not in target.occupied_cells or front not in built[&"occupied_cells"]: return result(&"PARTIAL_TARGET")
# Reject cutting an existing corridor; never construct an implicit bypass.
if _world.is_walkable(cell) or _path.has_cell(cell): return result(&"CORRIDOR_CONFLICT")
var occupancy := _world.validate_occupancy(cells)
if not occupancy[&"ok"]:
if own_room.is_empty() or _world.get_entity_cells(own_room) != cells or _world.get_entity_kind(own_room) != OCCUPANCY_KIND:
return result(&"BACK_OCCUPIED")
var report := result()
report.merge({&"cells": cells, &"target_instance_id": resolved_id, &"target_definition_id": target.definition_id})
return report
## CONTROLLED_FIXTURE: tests only. No Runtime executor or palette calls this.
func controlled_fixture_place(origin: Vector3i, target_id: StringName = &"") -> Dictionary:
var report := validate_placement(origin, Vector2i(2, 1), target_id)
if not report[&"ok"]: return report
var room_id := _ids.next_id(ID_PREFIX)
if room_id.is_empty(): return result(&"ID_EXHAUSTED")
if not _world.occupy(room_id, report[&"cells"], OCCUPANCY_KIND): return result(&"BACK_OCCUPIED")
_rooms[room_id] = {&"room_id": room_id, &"origin": origin, &"footprint": Vector2i(2, 1),
&"cells": report[&"cells"].duplicate(), &"target_instance_id": report[&"target_instance_id"],
&"target_definition_id": report[&"target_definition_id"],
&"target_history": [report[&"target_instance_id"]], &"link_state": &"LINKED",
&"stopped": true, &"stop_reason": &"CONTROLLED_FIXTURE"}
report[&"room_id"] = room_id
return report
func target_removed(target_id: StringName) -> void:
for room: Dictionary in _rooms.values():
if room[&"target_instance_id"] == target_id:
room[&"link_state"] = &"TARGET_LOST"
room[&"stopped"] = true
room[&"stop_reason"] = &"TARGET_LOST"
func detect_target_loss() -> void:
for room: Dictionary in _rooms.values():
if room[&"link_state"] == &"TARGET_LOST": continue
if not _target_still_present(room): target_removed(room[&"target_instance_id"])
func _target_still_present(room: Dictionary) -> bool:
var target := _facilities.get_instance(room[&"target_instance_id"])
if target == null or target.definition_id != room[&"target_definition_id"]: return false
for cell: Vector3i in room[&"cells"]:
if _world.get_occupant(Vector3i(cell.x, cell.y, WorldService.LAYER_FRONT)) != target.instance_id: return false
return true
func relink(room_id: StringName, target_id: StringName) -> Dictionary:
if not _rooms.has(room_id): return result(&"ROOM_MISSING")
if target_id.is_empty(): return result(&"TARGET_MISSING")
var room: Dictionary = _rooms[room_id]
var report := validate_placement(room[&"origin"], room[&"footprint"], target_id, room_id)
if not report[&"ok"]: return report
room[&"target_instance_id"] = target_id
room[&"target_definition_id"] = report[&"target_definition_id"]
if target_id not in room[&"target_history"]: room[&"target_history"].append(target_id)
room[&"link_state"] = &"LINKED"
room[&"stopped"] = true
room[&"stop_reason"] = &"RELINKED_PENDING_RESUME"
return result()
func _collection_precondition(room_id: StringName, source_event_key: StringName, minute: int) -> Dictionary:
if String(source_event_key).strip_edges().is_empty(): return result(&"SOURCE_EVENT_KEY_REQUIRED")
if minute < 0: return result(&"INVALID_MINUTE")
if not _rooms.has(room_id): return result(&"ROOM_MISSING")
detect_target_loss()
var room: Dictionary = _rooms[room_id]
# Required precedence: stopped, power, closed, construction, lost target.
if room[&"stopped"]: return result(&"STOPPED")
var target := _facilities.get_instance(room[&"target_instance_id"])
if target != null:
if not target.power_available: return result(&"NO_POWER")
if not target.operator_open or not target.is_within_opening_hours(minute): return result(&"CLOSED")
if _construction.get_facility(target.instance_id).is_empty(): return result(&"UNDER_CONSTRUCTION")
if room[&"link_state"] == &"TARGET_LOST" or target == null: return result(&"TARGET_LOST")
return result()
func record_evidence(room_id: StringName, source_event_key: StringName, minute: int) -> Dictionary:
var report := _collection_precondition(room_id, source_event_key, minute)
return report if not report[&"ok"] else result(&"RECORDER_ELIGIBILITY_PENDING_D04_D08_P16")
## CONTROLLED_FIXTURE: explicit test state, never a production resume command.
func controlled_fixture_set_stopped(room_id: StringName, stopped: bool) -> bool:
if not _rooms.has(room_id): return false
detect_target_loss()
var room: Dictionary = _rooms[room_id]
if not stopped and room[&"link_state"] == &"TARGET_LOST": return false
room[&"stopped"] = stopped
room[&"stop_reason"] = &"CONTROLLED_FIXTURE" if stopped else &""
return true
## CONTROLLED_FIXTURE: sole ledger write entry point, opaque event keys only.
func controlled_fixture_record_evidence(room_id: StringName, source_event_key: StringName, minute: int) -> Dictionary:
var report := _collection_precondition(room_id, source_event_key, minute)
if not report[&"ok"]: return report
var target_id: StringName = _rooms[room_id][&"target_instance_id"]
var evidence_id := _evidence_id(room_id, target_id, source_event_key)
var duplicated := _evidence.has(evidence_id)
if not duplicated:
_evidence[evidence_id] = {&"evidence_id": evidence_id, &"room_id": room_id,
&"target_instance_id": target_id, &"source_event_key": source_event_key, &"minute": minute}
return {&"ok": true, &"reason": &"OK", &"evidence_id": evidence_id, &"duplicated": duplicated}
static func _evidence_id(room_id: StringName, target_id: StringName, event_key: StringName) -> StringName:
# Length-prefixed UTF-8 hex is injective; delimiters in input cannot alias.
var pieces: Array[String] = []
for value: StringName in [room_id, target_id, event_key]:
var bytes := String(value).to_utf8_buffer()
pieces.append("%d:%s" % [bytes.size(), bytes.hex_encode()])
return StringName("peeping_evidence:" + ":".join(pieces))
func create_snapshot() -> Dictionary:
var rooms: Array[Dictionary] = []
var evidence: Array[Dictionary] = []
var keys := _rooms.keys()
keys.sort()
for key: StringName in keys: rooms.append(_rooms[key].duplicate(true))
keys = _evidence.keys()
keys.sort()
for key: StringName in keys: evidence.append(_evidence[key].duplicate(true))
return {&"version": SECTION_VERSION, &"rooms": rooms, &"evidence": evidence}
static func _exact(value: Variant, keys: Array) -> bool:
if not value is Dictionary or value.size() != keys.size(): return false
for key: Variant in value:
if typeof(key) != TYPE_STRING_NAME or key not in keys: return false
return true
## Save format 15 has a fixed purchase-key allowlist outside this task's scope.
## Validate this independent section before passing only the original keys to it.
static func valid_runtime_section(data: Dictionary, minute: int) -> bool:
if not data.get(&"special_peeping_rooms") is Dictionary: return false
for key: Variant in data:
if key == &"special_peeping_rooms" and typeof(key) != TYPE_STRING_NAME: return false
for key: StringName in [&"world", &"path", &"facilities", &"construction", &"id_generator"]:
if not data.get(key) is Dictionary: return false
var world := WorldService.new()
var path := PathService.new()
var facilities := FacilityService.new()
var ids := StableIdGenerator.new()
if not world.restore_snapshot(data[&"world"]) or not path.restore_snapshot(data[&"path"]) or not facilities.restore_snapshot(data[&"facilities"]) or not ids.restore_snapshot(data[&"id_generator"]): return false
var construction := ConstructionService.new(world)
if not construction.restore_snapshot(data[&"construction"]): return false
var service := PeepingRoomService.new()
service.configure(world, facilities, construction, path, ids)
return service.restore_section(data[&"special_peeping_rooms"], minute)
## Staged restore validates both directions of world ownership before committing.
func restore_section(section: Variant, minute: int) -> bool:
var rooms: Dictionary = {}
var evidence: Dictionary = {}
if section != null:
if not _exact(section, [&"version", &"rooms", &"evidence"]): return false
if typeof(section[&"version"]) != TYPE_INT or section[&"version"] != SECTION_VERSION: return false
if not section[&"rooms"] is Array or not section[&"evidence"] is Array: return false
if section[&"rooms"].is_empty(): return false # absence represents empty state
var known: Array = _facilities.create_snapshot()[&"known_instance_ids"]
for raw: Variant in section[&"rooms"]:
if not _exact(raw, ROOM_KEYS): return false
for key: StringName in [&"room_id", &"target_instance_id", &"target_definition_id", &"link_state", &"stop_reason"]:
if typeof(raw[key]) != TYPE_STRING_NAME: return false
var room_id: StringName = raw[&"room_id"]
var suffix := String(room_id).trim_prefix("peeping_room.")
if suffix.length() != 12 or not suffix.is_valid_int() or int(suffix) <= 0 or String(room_id) != "peeping_room.%012d" % int(suffix): return false
if int(suffix) > _ids.get_counter(ID_PREFIX) or rooms.has(room_id): return false
if typeof(raw[&"origin"]) != TYPE_VECTOR3I or typeof(raw[&"footprint"]) != TYPE_VECTOR2I: return false
if raw[&"origin"].z != WorldService.LAYER_BACK or raw[&"footprint"] != Vector2i(2, 1): return false
var cells := _world.cells_for_footprint(raw[&"origin"], raw[&"footprint"])
if not raw[&"cells"] is Array or raw[&"cells"] != cells: return false
for cell: Variant in raw[&"cells"]:
if typeof(cell) != TYPE_VECTOR3I: return false
if _world.get_entity_cells(room_id) != cells or _world.get_entity_kind(room_id) != OCCUPANCY_KIND: return false
for cell: Vector3i in cells:
if _world.is_walkable(cell) or _path.has_cell(cell): return false
if raw[&"target_definition_id"] not in ALLOWED_TARGETS: return false
if typeof(raw[&"stopped"]) != TYPE_BOOL or not raw[&"target_history"] is Array: return false
var history := {}
for target: Variant in raw[&"target_history"]:
if typeof(target) != TYPE_STRING_NAME or String(target) not in known or history.has(target): return false
history[target] = true
if not history.has(raw[&"target_instance_id"]): return false
var current_target := _facilities.get_instance(raw[&"target_instance_id"])
if current_target != null and current_target.definition_id != raw[&"target_definition_id"]: return false
if raw[&"link_state"] == &"TARGET_LOST":
if not raw[&"stopped"] or raw[&"stop_reason"] != &"TARGET_LOST" or _target_still_present(raw): return false
elif raw[&"link_state"] == &"LINKED":
if not validate_placement(raw[&"origin"], raw[&"footprint"], raw[&"target_instance_id"], room_id)[&"ok"]: return false
if _facilities.get_instance(raw[&"target_instance_id"]).definition_id != raw[&"target_definition_id"]: return false
if raw[&"stopped"]:
if raw[&"stop_reason"] not in [&"CONTROLLED_FIXTURE", &"RELINKED_PENDING_RESUME"]: return false
elif raw[&"stop_reason"] != &"": return false
else: return false
rooms[room_id] = raw.duplicate(true)
for raw: Variant in section[&"evidence"]:
if not _exact(raw, EVIDENCE_KEYS): return false
for key: StringName in [&"evidence_id", &"room_id", &"target_instance_id", &"source_event_key"]:
if typeof(raw[key]) != TYPE_STRING_NAME or raw[key].is_empty(): return false
if String(raw[&"source_event_key"]).strip_edges().is_empty(): return false
if typeof(raw[&"minute"]) != TYPE_INT or raw[&"minute"] < 0 or raw[&"minute"] > minute: return false
if not rooms.has(raw[&"room_id"]) or raw[&"target_instance_id"] not in rooms[raw[&"room_id"]][&"target_history"]: return false
var expected := _evidence_id(raw[&"room_id"], raw[&"target_instance_id"], raw[&"source_event_key"])
if expected != raw[&"evidence_id"] or evidence.has(expected): return false
evidence[expected] = raw.duplicate(true)
for occupied: Dictionary in _world.create_snapshot()[&"occupancies"]:
if occupied[&"kind"] == OCCUPANCY_KIND or String(occupied[&"entity_id"]).begins_with("peeping_room."):
if not rooms.has(occupied[&"entity_id"]): return false
_rooms = rooms
_evidence = evidence
return true