Files
ai-rpg/app/core/time_utils.py
Mikan e98559a587 fix
2026-06-21 06:12:28 +03:00

184 lines
6.2 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 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)"