"""Helpers for parsing/advancing in-game time strings. Time format: `day_D_hour_H[_min_M]` (optionally with `year_Y_` prefix). Examples: - `day_1_hour_8` -> (1, 8, 0) - `day_3_hour_14_min_30` -> (3, 14, 30) - `year_2_day_5_hour_12` -> (2, 5, 12, 0) Delta format: `[year_Y][days_D][hours_H][min_M]` Examples: `hours_2_min_30`, `days_1`, `min_15`, `days_3_hours_2` """ from __future__ import annotations import re from dataclasses import dataclass from typing import Iterable _TIME_RE = re.compile( r"^(?:year_(\d+)_)?day_(\d+)_hour_(\d+)(?:_min_(\d+))?$" ) _DELTA_RE = re.compile( r"^(?:(?:year_(\d+)_)?(?:days_(\d+)_)?(?:hours_(\d+)_)?(?:min_(\d+))?)$" ) @dataclass(frozen=True) class GameTime: year: int = 1 day: int = 1 hour: int = 0 minute: int = 0 def __post_init__(self): if self.year < 1 or self.day < 1 or self.hour < 0 or self.minute < 0: raise ValueError(f"Invalid GameTime: {self}") if self.hour > 23: raise ValueError(f"Hour out of range: {self.hour}") if self.minute > 59: raise ValueError(f"Minute out of range: {self.minute}") @classmethod def parse(cls, s: str) -> "GameTime": m = _TIME_RE.match(s.strip()) if not m: raise ValueError(f"Invalid time string: {s!r}") year = int(m.group(1)) if m.group(1) else 1 day = int(m.group(2)) hour = int(m.group(3)) minute = int(m.group(4)) if m.group(4) else 0 return cls(year=year, day=day, hour=hour, minute=minute) def to_string(self) -> str: parts = [] if self.year != 1: parts.append(f"year_{self.year}") parts.append(f"day_{self.day}") parts.append(f"hour_{self.hour}") if self.minute: parts.append(f"min_{self.minute}") return "_".join(parts) def total_minutes(self, hours_in_day: int = 24) -> int: """Total minutes since the start of year 1, day 1, hour 0.""" return ( (self.year - 1) * 365 * hours_in_day * 60 + (self.day - 1) * hours_in_day * 60 + self.hour * 60 + self.minute ) @classmethod def from_total_minutes(cls, total: int, hours_in_day: int = 24) -> "GameTime": year_len = 365 * hours_in_day * 60 day_len = hours_in_day * 60 year = total // year_len + 1 rem = total % year_len day = rem // day_len + 1 rem = rem % day_len hour = rem // 60 minute = rem % 60 return cls(year=year, day=day, hour=hour, minute=minute) def parse_delta(delta: str) -> tuple[int, int, int, int]: """Parse a delta string, return (years, days, hours, minutes). Accepted formats: - `hours_2`, `min_30`, `days_1`, `year_2` - `hours_2_min_30`, `days_3_hours_4`, `year_1_days_5_hours_2_min_15` - `hours_2min_30` (no separator between components — also accepted) """ s = delta.strip() if not s: raise ValueError("Empty delta string") parts: dict[str, int] = {"year": 0, "days": 0, "hours": 0, "min": 0} # Use finditer to walk the string and ensure full coverage pos = 0 matches = list(re.finditer(r"(year|days|hours|min)_(\d+)", s)) if not matches: raise ValueError(f"Invalid delta string: {delta!r}") for m in matches: # Between matches, only underscores are allowed gap = s[pos:m.start()] if any(c != "_" for c in gap): raise ValueError(f"Invalid delta string: {delta!r}") parts[m.group(1)] += int(m.group(2)) pos = m.end() # Trailing chars must also be underscores only trailing = s[pos:] if any(c != "_" for c in trailing): raise ValueError(f"Invalid delta string: {delta!r}") return (parts["year"], parts["days"], parts["hours"], parts["min"]) def advance_time(current: str, delta: str, time_schema: dict | None = None) -> str: """Advance `current` time string by `delta`. Returns new time string.""" schema = time_schema or {"hours_in_day": 24} hours_in_day = int(schema.get("hours_in_day", 24)) gt = GameTime.parse(current) y, d, h, mn = parse_delta(delta) total = gt.total_minutes(hours_in_day) + ( y * 365 * hours_in_day * 60 + d * hours_in_day * 60 + h * 60 + mn ) new_gt = GameTime.from_total_minutes(total, hours_in_day) return new_gt.to_string() def time_le(a: str, b: str) -> bool: """Return True if time `a` <= time `b`.""" ga, gb = GameTime.parse(a), GameTime.parse(b) return ga.total_minutes() <= gb.total_minutes() def format_time_human(time_str: str, language: str = "en") -> str: """Format a game time string as a human-readable localized string. Examples: - "day_1_hour_8" → "Day 1, 08:00" (en) / "День 1, 08:00" (ru) - "day_3_hour_14_min_30" → "Day 3, 14:30" (en) / "День 3, 14:30" (ru) - "year_2_day_5_hour_12" → "Year 2, Day 5, 12:00" (en) / "Год 2, День 5, 12:00" (ru) """ try: gt = GameTime.parse(time_str) except ValueError: return time_str # return as-is if unparseable if language == "ru": parts = [] if gt.year != 1: parts.append(f"Год {gt.year}") parts.append(f"День {gt.day}") if gt.minute: parts.append(f"{gt.hour:02d}:{gt.minute:02d}") else: parts.append(f"{gt.hour:02d}:00") return ", ".join(parts) else: # en parts = [] if gt.year != 1: parts.append(f"Year {gt.year}") parts.append(f"Day {gt.day}") if gt.minute: parts.append(f"{gt.hour:02d}:{gt.minute:02d}") else: parts.append(f"{gt.hour:02d}:00") return ", ".join(parts) def summarize_schemas(schemas: Iterable[dict]) -> str: """Render a compact human-readable summary of entity schemas for LLM prompts.""" lines: list[str] = [] for s in schemas: type_name = s.get("type", "?") verbose = s.get("verbose", type_name) props = s.get("properties", []) prop_str = ", ".join( f"{p.get('name')}:{p.get('type')}" + ("*" if p.get("required") else "") for p in props ) lines.append(f"- {verbose} ({type_name}): {prop_str}") return "\n".join(lines) if lines else "(no schemas)"