rebase
This commit is contained in:
148
app/core/time_utils.py
Normal file
148
app/core/time_utils.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""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)"
|
||||
Reference in New Issue
Block a user