149 lines
5.1 KiB
Python
149 lines
5.1 KiB
Python
"""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 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)"
|