255 lines
10 KiB
GDScript
255 lines
10 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)
|
|
|
|
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
|