straywild/messages/player_bottle_message_state.gd

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,
)