Files
TheTree_mock/scripts/environment/weather_timeline.gd
T

260 lines
11 KiB
GDScript

extends RefCounted
## Weather state and scheduled transitions. The caller owns generation policy;
## this model invents neither probabilities nor rain/fog balance multipliers.
## Version 2 also keeps the natural-weather bookkeeping (last daily draw, the
## current day's draw) and the once-per-guest rain holds; version 1 snapshots
## migrate with no draw for the past and no holds (P07-01 SPEC section 5).
const VERSION := 2
const LEGACY_VERSION := 1
const KINDS := [&"clear", &"rain", &"storm", &"mana_fog"]
const MAX_MINUTE := 2147483647
const MAX_PENDING := 4096
const MAX_RAIN_HOLDS := 100000
const DAY_MINUTES := 1440
const DRAW_MINUTE_OF_DAY := 360
const RAIN_HOLD_MAX_MINUTES := 240
var _minute := -1
var _kind: StringName = &""
var _since := -1
var _pending: Array[Dictionary] = []
var _last_draw_day := -1
var _today_draw: Dictionary = {}
var _rain_holds: Dictionary = {}
func initialize(kind: Variant, minute: Variant) -> bool:
if _minute >= 0 or not _valid_kind(kind) or not _valid_minute(minute):
return false
_minute = minute
_since = minute
_kind = StringName(kind)
_last_draw_day = _days_before(minute)
return true
func get_kind() -> StringName:
return _kind
func get_minute() -> int:
return _minute
func get_since() -> int:
return _since
func permits_new_arrivals() -> bool:
return _minute >= 0 and _kind != &"storm"
func schedule(kind: Variant, minute: Variant) -> Dictionary:
if _minute < 0 or not _valid_kind(kind) or not _valid_minute(minute) or minute <= _minute:
return {&"ok": false, &"code": &"INVALID_TRANSITION"}
for row: Dictionary in _pending:
if row[&"minute"] == minute:
return {&"ok": row[&"kind"] == StringName(kind), &"replayed": row[&"kind"] == StringName(kind)}
if _pending.size() >= MAX_PENDING:
return {&"ok": false, &"code": &"SCHEDULE_FULL"}
_pending.append({&"kind": StringName(kind), &"minute": minute})
_pending.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"minute"] < b[&"minute"])
return {&"ok": true, &"replayed": false}
func advance_to(minute: Variant) -> Dictionary:
if _minute < 0 or not _valid_minute(minute) or minute < _minute:
return {&"ok": false, &"code": &"INVALID_TIME"}
var changes: Array[Dictionary] = []
var replayed: bool = minute == _minute
while not _pending.is_empty() and int(_pending[0][&"minute"]) <= minute:
var transition: Dictionary = _pending.pop_front()
if transition[&"kind"] == _kind:
continue
changes.append({&"from": _kind, &"to": transition[&"kind"], &"minute": transition[&"minute"]})
_kind = transition[&"kind"]
_since = transition[&"minute"]
_minute = minute
return {&"ok": true, &"replayed": replayed, &"changes": changes}
## Natural weather (A13): the caller draws once per day at 06:00 and applies
## the result here. A start at the current minute switches now; later starts
## and the return to clear use the ordinary schedule rules, applied together.
func get_last_draw_day() -> int:
return _last_draw_day
func get_today_draw() -> Dictionary:
return _today_draw.duplicate(true)
func apply_draw(draw: Dictionary) -> Dictionary:
if _minute < 0 or not _valid_draw(draw):
return {&"ok": false, &"code": &"INVALID_DRAW"}
var day: int = draw[&"day"]
if day <= _last_draw_day or _minute != day * DAY_MINUTES + DRAW_MINUTE_OF_DAY:
return {&"ok": false, &"code": &"DRAW_NOT_DUE"}
var kind := StringName(draw[&"kind"])
var start: int = draw[&"start"]
var end: int = draw[&"end"]
var changes: Array[Dictionary] = []
if kind != &"clear":
for row: Dictionary in _pending:
if int(row[&"minute"]) == start or int(row[&"minute"]) == end:
return {&"ok": false, &"code": &"SCHEDULE_CONFLICT"}
if _pending.size() + 2 > MAX_PENDING:
return {&"ok": false, &"code": &"SCHEDULE_FULL"}
if start == _minute:
if kind != _kind:
changes.append({&"from": _kind, &"to": kind, &"minute": start})
_kind = kind
_since = start
else:
_pending.append({&"kind": kind, &"minute": start})
_pending.append({&"kind": &"clear", &"minute": end})
_pending.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"minute"] < b[&"minute"])
_last_draw_day = day
_today_draw = _copy_draw(draw)
return {&"ok": true, &"changes": changes}
## The next scheduled change away from the current kind, for the forecast.
func next_change() -> Dictionary:
for row: Dictionary in _pending:
if row[&"kind"] != _kind:
return {&"kind": row[&"kind"], &"minute": row[&"minute"]}
return {}
## When the current rain ends (-1 when it is not raining or no end is known).
func rain_end_minute() -> int:
if _kind != &"rain":
return -1
for row: Dictionary in _pending:
if row[&"kind"] != &"rain":
return int(row[&"minute"])
return -1
func get_rain_hold(guest_id: StringName) -> Dictionary:
var row: Variant = _rain_holds.get(String(guest_id), null)
return row.duplicate(true) if row is Dictionary else {}
func get_rain_holds() -> Dictionary:
return _rain_holds.duplicate(true)
func add_rain_hold(guest_id: StringName, original_exit: int, extended_to: int) -> bool:
var key := String(guest_id)
if key.is_empty() or _rain_holds.has(key) or _rain_holds.size() >= MAX_RAIN_HOLDS \
or not _valid_hold({"original_exit": original_exit, "extended_to": extended_to}):
return false
_rain_holds[key] = {"original_exit": original_exit, "extended_to": extended_to}
return true
## Holds of guests that have left are history; the caller passes who remains.
func prune_rain_holds(present_guest_ids: Dictionary) -> void:
for key: String in _rain_holds.keys():
if not present_guest_ids.has(StringName(key)):
_rain_holds.erase(key)
## The hold of a guest that has just left; a save in that same minute then
## holds only present guests' holds (P07-01 SPEC section 5).
func release_rain_hold(guest_id: StringName) -> void:
_rain_holds.erase(String(guest_id))
func create_snapshot() -> Dictionary:
return {&"version": VERSION, &"minute": _minute, &"kind": _kind,
&"since": _since, &"pending": _pending.duplicate(true),
&"natural": {&"last_draw_day": _last_draw_day, &"today": _today_draw.duplicate(true)},
&"rain_holds": _rain_holds.duplicate(true)}
func restore_snapshot(source: Dictionary) -> bool:
var version: Variant = source.get("version", source.get(&"version", null))
if typeof(version) != TYPE_INT or version not in [LEGACY_VERSION, VERSION]:
return false
var expected := ["version", "minute", "kind", "since", "pending"]
if version == VERSION:
expected = expected + ["natural", "rain_holds"]
if not _keys(source, expected) \
or not _valid_minute(source["minute"]) or not _valid_minute(source["since"]) \
or source["since"] > source["minute"] or not _valid_kind(source["kind"]) \
or not source["pending"] is Array or source["pending"].size() > MAX_PENDING:
return false
var previous: int = source["minute"]
var pending: Array[Dictionary] = []
for value: Variant in source["pending"]:
if not value is Dictionary or not _keys(value, ["kind", "minute"]) \
or not _valid_kind(value["kind"]) or not _valid_minute(value["minute"]) \
or value["minute"] <= previous:
return false
previous = value["minute"]
pending.append({&"kind": StringName(value["kind"]), &"minute": value["minute"]})
var last_draw_day := _days_before(source["minute"])
var today: Dictionary = {}
var holds: Dictionary = {}
if version == VERSION:
var natural: Variant = source["natural"]
if not natural is Dictionary or not _keys(natural, ["last_draw_day", "today"]) \
or typeof(natural["last_draw_day"]) != TYPE_INT or natural["last_draw_day"] < -1 \
or natural["last_draw_day"] > _day_of(source["minute"]) or not natural["today"] is Dictionary:
return false
last_draw_day = natural["last_draw_day"]
if not natural["today"].is_empty():
if not _valid_draw(natural["today"]) or int(natural["today"]["day"]) != last_draw_day:
return false
today = _copy_draw(natural["today"])
var raw_holds: Variant = source["rain_holds"]
if not raw_holds is Dictionary or raw_holds.size() > MAX_RAIN_HOLDS:
return false
for key: Variant in raw_holds:
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key).is_empty() \
or not raw_holds[key] is Dictionary or not _keys(raw_holds[key], ["original_exit", "extended_to"]) \
or not _valid_hold(raw_holds[key]):
return false
holds[String(key)] = {"original_exit": int(raw_holds[key]["original_exit"]),
"extended_to": int(raw_holds[key]["extended_to"])}
_minute = source["minute"]
_kind = StringName(source["kind"])
_since = source["since"]
_pending = pending
_last_draw_day = last_draw_day
_today_draw = today
_rain_holds = holds
return true
## A day whose 06:00 is before the minute counts as drawn, so a new or migrated
## timeline never draws for the past (SPEC section 5.2). At exactly 06:00 the
## day is still due, which lets a new game draw its opening day.
static func _days_before(minute: int) -> int:
var day := _day_of(minute)
return day if posmod(minute, DAY_MINUTES) > DRAW_MINUTE_OF_DAY else day - 1
static func _day_of(minute: int) -> int:
return int(floor(float(minute) / DAY_MINUTES))
static func _valid_draw(draw: Variant) -> bool:
if not draw is Dictionary or not _keys(draw, ["day", "kind", "roll", "slot", "start", "end"]):
return false
for key: String in ["day", "roll", "slot", "start", "end"]:
if typeof(draw[key]) != TYPE_INT:
return false
if not _valid_kind(draw["kind"]) or draw["day"] < 0 or draw["roll"] < 0 or draw["roll"] > 99:
return false
if StringName(draw["kind"]) == &"clear":
return draw["slot"] == -1 and draw["start"] == -1 and draw["end"] == -1
var day_start: int = draw["day"] * DAY_MINUTES + DRAW_MINUTE_OF_DAY
return draw["slot"] >= 0 and draw["start"] >= day_start and draw["start"] <= day_start + 14 * 60 \
and draw["end"] > draw["start"] and draw["end"] < day_start + DAY_MINUTES
static func _copy_draw(draw: Dictionary) -> Dictionary:
var result := {}
for key: String in ["day", "kind", "roll", "slot", "start", "end"]:
result[key] = draw[key] if key != "kind" else String(draw[key])
return result
static func _valid_hold(row: Dictionary) -> bool:
var original: Variant = row.get("original_exit", null)
var extended: Variant = row.get("extended_to", null)
return typeof(original) == TYPE_INT and typeof(extended) == TYPE_INT and original >= 0 \
and extended > original and extended <= original + RAIN_HOLD_MAX_MINUTES
static func _valid_kind(value: Variant) -> bool:
return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and StringName(value) in KINDS
static func _valid_minute(value: Variant) -> bool:
return typeof(value) == TYPE_INT and value >= 0 and value <= MAX_MINUTE
static func _keys(source: Dictionary, expected: Array) -> bool:
if source.size() != expected.size(): return false
for key: Variant in source:
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected: return false
return true