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