extends Node ## In-game clock: sim-tick → day, hour, minute, darkness factor. ## ## Design (docs/architecture.md "Time / tick model"): ## - 1 in-game day = TICKS_PER_DAY sim ticks ## 4800 ticks/day = 240 s real at 1× = 48 s at Fast (5×) = 20 s at Ultra (12×) ## - 24 in-game hours per day → TICKS_PER_HOUR = 200 ## - TICKS_PER_MINUTE = 200 / 60 = 3 (integer floor; ~3.33 ticks per in-game min) ## ## Time-of-day phases (docs/design.md "Day/Night cycle"): ## Night: 22:00–05:00 ## Dawn: 05:00–07:00 (2-hour smooth ramp, darkness 1.0 → 0.0) ## Day: 07:00–19:00 ## Dusk: 19:00–22:00 (3-hour smooth ramp, darkness 0.0 → 1.0) ## ## darkness_factor() returns 0.0 (full daylight) to 1.0 (deepest night). ## ## Game starts at Day 1, 06:00 (mid-dawn — atmospheric, not full dark). ## ## Save seam: save_dict() / apply_dict() persist _start_offset_ticks so ## the time-of-day survives a session reload. const TICKS_PER_DAY: int = 4800 const TICKS_PER_HOUR: int = 200 ## Integer floor of 200/60 ≈ 3.33. Minute display rounds down — fine for a clock. const TICKS_PER_MINUTE: int = TICKS_PER_HOUR / 60 const START_HOUR: int = 6 const HOURS_PER_DAY: int = 24 # Phase boundaries (in-game hours, inclusive lower bound). const DAWN_START_HOUR: int = 5 const DAWN_END_HOUR: int = 7 const DUSK_START_HOUR: int = 19 const DUSK_END_HOUR: int = 22 ## Internal: added to Sim.tick so we begin at START_HOUR rather than midnight. var _start_offset_ticks: int = START_HOUR * TICKS_PER_HOUR ## Fired when the time-phase transitions (Night → Dawn, Dawn → Day, etc.). signal phase_changed(phase: StringName) const PHASE_NIGHT: StringName = &"night" const PHASE_DAWN: StringName = &"dawn" const PHASE_DAY: StringName = &"day" const PHASE_DUSK: StringName = &"dusk" # Phase 12 — Season contracts (Agent A fills in current_season() + day_of_season()). # Names locked here so Agent B + C can reference them without race conditions. const SEASON_SPRING: StringName = &"spring" const SEASON_SUMMER: StringName = &"summer" const SEASON_AUTUMN: StringName = &"autumn" const SEASON_WINTER: StringName = &"winter" const SEASONS: Array[StringName] = [SEASON_SPRING, SEASON_SUMMER, SEASON_AUTUMN, SEASON_WINTER] const DAYS_PER_SEASON: int = 12 const SEASONS_PER_YEAR: int = 4 const DAYS_PER_YEAR: int = DAYS_PER_SEASON * SEASONS_PER_YEAR # 48 var _last_emitted_phase: StringName = &"" ## Mirrors _last_emitted_phase — guards the season_changed emit against repeat fires. var _last_emitted_season: StringName = &"" func _ready() -> void: EventBus.sim_tick.connect(_on_sim_tick) # ── public API ─────────────────────────────────────────────────────────────── ## Current in-game day (1-indexed). Day 1 starts at boot. func current_day() -> int: return 1 + (_offset_ticks() / TICKS_PER_DAY) ## Current hour 0..23. func current_hour() -> int: return (_offset_ticks() / TICKS_PER_HOUR) % HOURS_PER_DAY ## Current minute 0..59. func current_minute() -> int: var ticks_into_hour: int = _offset_ticks() % TICKS_PER_HOUR return (ticks_into_hour * 60) / TICKS_PER_HOUR ## Time of day as a 0..1 fraction (0.0 = midnight, 0.5 = noon). func time_of_day_fraction() -> float: return float(_offset_ticks() % TICKS_PER_DAY) / float(TICKS_PER_DAY) ## Returns 0.0 (full daylight) to 1.0 (deepest night). ## ## Ramp shapes: ## Day [07:00–19:00] → 0.0 flat ## Night [22:00–05:00] → 1.0 flat ## Dawn [05:00–07:00] → 1.0 ramps down to 0.0 linearly over 2 hours ## Dusk [19:00–22:00] → 0.0 ramps up to 1.0 linearly over 3 hours func darkness_factor() -> float: var h: float = float(current_hour()) + float(current_minute()) / 60.0 # Full daylight band. if h >= DAWN_END_HOUR and h < DUSK_START_HOUR: return 0.0 # Full night band — handles the wrap (h >= 22 OR h < 5). if h >= DUSK_END_HOUR or h < DAWN_START_HOUR: return 1.0 # Dawn ramp: 1.0 at 05:00 → 0.0 at 07:00 if h < DAWN_END_HOUR: return 1.0 - (h - DAWN_START_HOUR) / float(DAWN_END_HOUR - DAWN_START_HOUR) # Dusk ramp: 0.0 at 19:00 → 1.0 at 22:00 return (h - DUSK_START_HOUR) / float(DUSK_END_HOUR - DUSK_START_HOUR) ## Current phase as a StringName constant. Phase transitions emit phase_changed. func current_phase() -> StringName: var h: int = current_hour() if h < DAWN_START_HOUR or h >= DUSK_END_HOUR: return PHASE_NIGHT if h < DAWN_END_HOUR: return PHASE_DAWN if h < DUSK_START_HOUR: return PHASE_DAY return PHASE_DUSK ## "HH:MM" formatted 24-hour time string. func time_string() -> String: return "%02d:%02d" % [current_hour(), current_minute()] # Phase 12 — Season public API (stub; Agent A wires emit + season_changed). ## Day index since game start (0-indexed, ignores 1-indexed display). func day_index_from_start() -> int: return _offset_ticks() / TICKS_PER_DAY ## Current season index 0..3 (spring/summer/autumn/winter). func current_season_index() -> int: return (day_index_from_start() / DAYS_PER_SEASON) % SEASONS_PER_YEAR ## Current season as a locked StringName constant. func current_season() -> StringName: return SEASONS[current_season_index()] ## Day within the current season, 0..DAYS_PER_SEASON-1. func day_of_season() -> int: return day_index_from_start() % DAYS_PER_SEASON ## Current year (1-indexed; Year 1 starts at boot). func current_year() -> int: return 1 + day_index_from_start() / DAYS_PER_YEAR # ── internal ───────────────────────────────────────────────────────────────── func _offset_ticks() -> int: return _start_offset_ticks + Sim.tick func _on_sim_tick(_n: int) -> void: var phase: StringName = current_phase() if phase != _last_emitted_phase: var prev_phase: StringName = _last_emitted_phase _last_emitted_phase = phase emit_signal("phase_changed", phase) Audit.log("clock", "phase → %s (day %d, %s)" % [phase, current_day(), time_string()]) # Phase 17 — emit day_ended summary at the dusk→night boundary. # "Night" begins at DUSK_END_HOUR (22:00). This fires once per in-game day. # Listeners can use this for end-of-day recap UI, tension logging, etc. # Defensive: use get() for Storyteller/Weather/World fields that may not be # fully wired in all test contexts. if prev_phase == PHASE_DUSK and phase == PHASE_NIGHT: var summary: Dictionary = { "day": current_day(), "weather": Weather.get("current_weather") if Weather != null else &"unknown", "season": current_season(), "pawns_alive": World.pawns.size() if World != null else 0, "tension": Storyteller.get("tension") if Storyteller != null else 0.0, "wolves_alive": World.wolves.size() if World != null else 0, } EventBus.day_ended.emit(summary) Audit.log("clock", "day_ended: day=%d season=%s pawns=%d tension=%.1f wolves=%d" % [ summary["day"], summary["season"], summary["pawns_alive"], summary["tension"], summary["wolves_alive"] ]) var season: StringName = current_season() if season != _last_emitted_season: _last_emitted_season = season EventBus.season_changed.emit(season) Audit.log("clock", "season → %s (day %d/%d of season, year %d)" % [ season, day_of_season() + 1, DAYS_PER_SEASON, current_year() ]) # ── save / load ────────────────────────────────────────────────────────────── func save_dict() -> Dictionary: return {"start_offset_ticks": _start_offset_ticks} func apply_dict(d: Dictionary) -> void: _start_offset_ticks = int(d.get("start_offset_ticks", START_HOUR * TICKS_PER_HOUR))