332 lines
9 KiB
GDScript
332 lines
9 KiB
GDScript
class_name PlayerBottleMessageState
|
|
extends Node
|
|
|
|
signal changed
|
|
|
|
const BottleMessageCatalogType = preload(
|
|
"res://messages/bottle_message_catalog.gd"
|
|
)
|
|
const BottleMessageDataType = preload(
|
|
"res://messages/bottle_message_data.gd"
|
|
)
|
|
|
|
const SAVE_SCHEMA_VERSION: int = 1
|
|
const MINIMUM_COOLDOWN_SECONDS: int = 3 * 24 * 60 * 60
|
|
const MAXIMUM_COOLDOWN_SECONDS: int = 7 * 24 * 60 * 60
|
|
const BACKWARD_CLOCK_TOLERANCE_SECONDS: int = 5 * 60
|
|
const CLOCK_OBSERVATION_CHECKPOINT_SECONDS: int = 15 * 60
|
|
const MAX_MESSAGE_ID_LENGTH: int = 96
|
|
const MAX_RECEIVED_MESSAGE_IDS: int = 4096
|
|
const MAX_SUPPORTED_UNIX_TIME: int = 32503680000
|
|
|
|
var _received_message_ids: Array[StringName] = []
|
|
var _pending_message_id: StringName
|
|
var _pending_bottle_picked_up: bool = false
|
|
var _next_bottle_at_unix: int = 0
|
|
var _last_observed_unix: int = 0
|
|
|
|
|
|
func get_received_message_ids() -> Array[StringName]:
|
|
return _received_message_ids.duplicate()
|
|
|
|
|
|
func has_received_message(message_id: StringName) -> bool:
|
|
return _received_message_ids.has(message_id)
|
|
|
|
|
|
func has_pending_message() -> bool:
|
|
return not _pending_message_id.is_empty()
|
|
|
|
|
|
func get_pending_message_id() -> StringName:
|
|
return _pending_message_id
|
|
|
|
|
|
func is_pending_bottle_picked_up() -> bool:
|
|
return has_pending_message() and _pending_bottle_picked_up
|
|
|
|
|
|
func get_next_bottle_at_unix() -> int:
|
|
return _next_bottle_at_unix
|
|
|
|
|
|
func get_last_observed_unix() -> int:
|
|
return _last_observed_unix
|
|
|
|
|
|
func observe_wall_clock(now_unix: int) -> bool:
|
|
if not _valid_unix_time(now_unix):
|
|
return false
|
|
var changed_state: bool = false
|
|
if _last_observed_unix > 0 and now_unix < _last_observed_unix:
|
|
var rollback_seconds: int = _last_observed_unix - now_unix
|
|
if rollback_seconds <= BACKWARD_CLOCK_TOLERANCE_SECONDS:
|
|
return false
|
|
if _next_bottle_at_unix > 0:
|
|
_next_bottle_at_unix = maxi(
|
|
now_unix,
|
|
_next_bottle_at_unix - rollback_seconds,
|
|
)
|
|
_last_observed_unix = now_unix
|
|
changed_state = true
|
|
elif (
|
|
_last_observed_unix == 0
|
|
or now_unix - _last_observed_unix
|
|
>= CLOCK_OBSERVATION_CHECKPOINT_SECONDS
|
|
):
|
|
_last_observed_unix = now_unix
|
|
changed_state = true
|
|
if changed_state:
|
|
changed.emit()
|
|
return changed_state
|
|
|
|
|
|
func ensure_cooldown_scheduled(
|
|
now_unix: int,
|
|
rng: RandomNumberGenerator,
|
|
) -> bool:
|
|
if rng == null or not _valid_unix_time(now_unix):
|
|
return false
|
|
observe_wall_clock(now_unix)
|
|
if has_pending_message():
|
|
return _pending_bottle_picked_up and _next_bottle_at_unix > 0
|
|
if _next_bottle_at_unix > 0:
|
|
return true
|
|
_next_bottle_at_unix = now_unix + _random_cooldown_seconds(rng)
|
|
changed.emit()
|
|
return true
|
|
|
|
|
|
func is_bottle_due(now_unix: int) -> bool:
|
|
return (
|
|
_valid_unix_time(now_unix)
|
|
and not has_pending_message()
|
|
and _next_bottle_at_unix > 0
|
|
and now_unix >= _next_bottle_at_unix
|
|
)
|
|
|
|
|
|
func try_assign_due_message(
|
|
catalog: BottleMessageCatalogType,
|
|
rng: RandomNumberGenerator,
|
|
now_unix: int,
|
|
) -> BottleMessageDataType:
|
|
if (
|
|
catalog == null
|
|
or rng == null
|
|
or has_pending_message()
|
|
or not _valid_unix_time(now_unix)
|
|
):
|
|
return null
|
|
observe_wall_clock(now_unix)
|
|
if _next_bottle_at_unix == 0:
|
|
ensure_cooldown_scheduled(now_unix, rng)
|
|
return null
|
|
if not is_bottle_due(now_unix):
|
|
return null
|
|
return _assign_random_unseen_message(catalog, rng)
|
|
|
|
|
|
func assign_unseen_message_for_testing(
|
|
catalog: BottleMessageCatalogType,
|
|
rng: RandomNumberGenerator,
|
|
) -> BottleMessageDataType:
|
|
if catalog == null or rng == null or has_pending_message():
|
|
return null
|
|
return _assign_random_unseen_message(catalog, rng)
|
|
|
|
|
|
func _assign_random_unseen_message(
|
|
catalog: BottleMessageCatalogType,
|
|
rng: RandomNumberGenerator,
|
|
) -> BottleMessageDataType:
|
|
var message: BottleMessageDataType = catalog.choose_random_unseen_message(
|
|
_received_message_ids,
|
|
rng,
|
|
)
|
|
if message == null:
|
|
return null
|
|
_pending_message_id = message.message_id
|
|
_pending_bottle_picked_up = false
|
|
_received_message_ids.append(message.message_id)
|
|
_next_bottle_at_unix = 0
|
|
changed.emit()
|
|
return message
|
|
|
|
|
|
func record_bottle_picked_up(
|
|
now_unix: int,
|
|
rng: RandomNumberGenerator,
|
|
) -> bool:
|
|
if (
|
|
rng == null
|
|
or not has_pending_message()
|
|
or _pending_bottle_picked_up
|
|
or _next_bottle_at_unix != 0
|
|
or not _valid_unix_time(now_unix)
|
|
):
|
|
return false
|
|
observe_wall_clock(now_unix)
|
|
_pending_bottle_picked_up = true
|
|
_next_bottle_at_unix = now_unix + _random_cooldown_seconds(rng)
|
|
changed.emit()
|
|
return true
|
|
|
|
|
|
func consume_pending_message() -> StringName:
|
|
var consumed_id: StringName = _pending_message_id
|
|
if consumed_id.is_empty():
|
|
return &""
|
|
_pending_message_id = &""
|
|
_pending_bottle_picked_up = false
|
|
changed.emit()
|
|
return consumed_id
|
|
|
|
|
|
func reset_to_defaults() -> void:
|
|
_received_message_ids.clear()
|
|
_pending_message_id = &""
|
|
_pending_bottle_picked_up = false
|
|
_next_bottle_at_unix = 0
|
|
_last_observed_unix = 0
|
|
changed.emit()
|
|
|
|
|
|
func to_save_data() -> Dictionary:
|
|
var received: Array[String] = []
|
|
for message_id: StringName in _received_message_ids:
|
|
received.append(String(message_id))
|
|
received.sort()
|
|
return {
|
|
"schema_version": SAVE_SCHEMA_VERSION,
|
|
"received_message_ids": received,
|
|
"pending_message_id": String(_pending_message_id),
|
|
"pending_bottle_picked_up": _pending_bottle_picked_up,
|
|
"next_bottle_at_unix": _next_bottle_at_unix,
|
|
"last_observed_unix": _last_observed_unix,
|
|
}
|
|
|
|
|
|
func restore_from_save_data(data: Dictionary) -> bool:
|
|
var sanitized: Dictionary = sanitize_save_data(data)
|
|
if sanitized.is_empty():
|
|
return false
|
|
_received_message_ids.clear()
|
|
for value: Variant in sanitized["received_message_ids"] as Array:
|
|
_received_message_ids.append(StringName(str(value)))
|
|
_pending_message_id = StringName(str(sanitized["pending_message_id"]))
|
|
_pending_bottle_picked_up = bool(sanitized["pending_bottle_picked_up"])
|
|
_next_bottle_at_unix = int(sanitized["next_bottle_at_unix"])
|
|
_last_observed_unix = int(sanitized["last_observed_unix"])
|
|
changed.emit()
|
|
return true
|
|
|
|
|
|
static func default_save_data() -> Dictionary:
|
|
return {
|
|
"schema_version": SAVE_SCHEMA_VERSION,
|
|
"received_message_ids": [],
|
|
"pending_message_id": "",
|
|
"pending_bottle_picked_up": false,
|
|
"next_bottle_at_unix": 0,
|
|
"last_observed_unix": 0,
|
|
}
|
|
|
|
|
|
static func sanitize_save_data(data: Dictionary) -> Dictionary:
|
|
if (
|
|
not _is_integer_value(data.get("schema_version"))
|
|
or int(data.get("schema_version", -1)) != SAVE_SCHEMA_VERSION
|
|
or typeof(data.get("received_message_ids")) != TYPE_ARRAY
|
|
or typeof(data.get("pending_message_id")) not in [
|
|
TYPE_STRING, TYPE_STRING_NAME,
|
|
]
|
|
or typeof(data.get("pending_bottle_picked_up")) != TYPE_BOOL
|
|
or not _is_integer_value(data.get("next_bottle_at_unix"))
|
|
or not _is_integer_value(data.get("last_observed_unix"))
|
|
):
|
|
return {}
|
|
var values: Array = data["received_message_ids"]
|
|
if values.size() > MAX_RECEIVED_MESSAGE_IDS:
|
|
return {}
|
|
var received: Array[String] = []
|
|
var seen: Dictionary[String, bool] = {}
|
|
for value: Variant in values:
|
|
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return {}
|
|
var message_id: String = str(value)
|
|
if not _valid_message_id(message_id):
|
|
return {}
|
|
if seen.has(message_id):
|
|
continue
|
|
seen[message_id] = true
|
|
received.append(message_id)
|
|
var pending_message_id: String = str(data["pending_message_id"])
|
|
var pending_bottle_picked_up: bool = bool(
|
|
data["pending_bottle_picked_up"]
|
|
)
|
|
if (
|
|
not pending_message_id.is_empty()
|
|
and not _valid_message_id(pending_message_id)
|
|
):
|
|
return {}
|
|
if not pending_message_id.is_empty() and not seen.has(pending_message_id):
|
|
if received.size() >= MAX_RECEIVED_MESSAGE_IDS:
|
|
return {}
|
|
received.append(pending_message_id)
|
|
if pending_message_id.is_empty() and pending_bottle_picked_up:
|
|
return {}
|
|
var next_bottle_at_unix: int = int(data["next_bottle_at_unix"])
|
|
var last_observed_unix: int = int(data["last_observed_unix"])
|
|
if (
|
|
not _valid_persisted_unix_time(next_bottle_at_unix)
|
|
or not _valid_persisted_unix_time(last_observed_unix)
|
|
or (
|
|
not pending_message_id.is_empty()
|
|
and pending_bottle_picked_up
|
|
and next_bottle_at_unix == 0
|
|
)
|
|
or (
|
|
not pending_message_id.is_empty()
|
|
and not pending_bottle_picked_up
|
|
and next_bottle_at_unix != 0
|
|
)
|
|
):
|
|
return {}
|
|
return {
|
|
"schema_version": SAVE_SCHEMA_VERSION,
|
|
"received_message_ids": received,
|
|
"pending_message_id": pending_message_id,
|
|
"pending_bottle_picked_up": pending_bottle_picked_up,
|
|
"next_bottle_at_unix": next_bottle_at_unix,
|
|
"last_observed_unix": last_observed_unix,
|
|
}
|
|
|
|
|
|
static func _valid_message_id(message_id: String) -> bool:
|
|
return (
|
|
not message_id.is_empty()
|
|
and message_id == message_id.strip_edges()
|
|
and message_id.length() <= MAX_MESSAGE_ID_LENGTH
|
|
)
|
|
|
|
|
|
static func _valid_unix_time(value: int) -> bool:
|
|
return value > 0 and value <= MAX_SUPPORTED_UNIX_TIME
|
|
|
|
|
|
static func _valid_persisted_unix_time(value: int) -> bool:
|
|
return value == 0 or _valid_unix_time(value)
|
|
|
|
|
|
static func _is_integer_value(value: Variant) -> bool:
|
|
return typeof(value) == TYPE_INT or (
|
|
typeof(value) == TYPE_FLOAT and is_equal_approx(value, roundf(value))
|
|
)
|
|
|
|
|
|
static func _random_cooldown_seconds(rng: RandomNumberGenerator) -> int:
|
|
return rng.randi_range(
|
|
MINIMUM_COOLDOWN_SECONDS,
|
|
MAXIMUM_COOLDOWN_SECONDS,
|
|
)
|