|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
|
|
from devplacepy.cache import TTLCache
|
|
from devplacepy.database import db, get_table
|
|
from devplacepy.utils import generate_uid
|
|
|
|
from .. import economy
|
|
from .common import _iso
|
|
|
|
|
|
SATURATION_TTL = int(os.environ.get("DEVPLACE_MARKET_SATURATION_TTL", "30"))
|
|
|
|
|
|
_saturation_cache = TTLCache(ttl=SATURATION_TTL, max_size=32)
|
|
|
|
|
|
def _ticks():
|
|
return get_table("game_market_ticks")
|
|
|
|
|
|
def _hour_bucket(now: datetime) -> str:
|
|
return now.strftime("%Y-%m-%dT%H")
|
|
|
|
|
|
def record_harvest_tick(crop_key: str, now: datetime) -> None:
|
|
with db:
|
|
db.query(
|
|
"INSERT INTO game_market_ticks (uid, crop_key, hour_bucket, harvests, updated_at) "
|
|
"VALUES (:uid, :crop_key, :hour_bucket, 1, :now) "
|
|
"ON CONFLICT(crop_key, hour_bucket) DO UPDATE SET "
|
|
"harvests = harvests + 1, updated_at = :now",
|
|
uid=generate_uid(),
|
|
crop_key=crop_key,
|
|
hour_bucket=_hour_bucket(now),
|
|
now=_iso(now),
|
|
)
|
|
_saturation_cache.pop(f"{crop_key}:{economy.MARKET_WINDOW_HOURS}")
|
|
|
|
|
|
def recent_harvests(crop_key: str, window_hours: int) -> int:
|
|
cache_key = f"{crop_key}:{window_hours}"
|
|
cached = _saturation_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
from .common import _now
|
|
|
|
cutoff = _hour_bucket(_now() - timedelta(hours=window_hours))
|
|
row = db.query(
|
|
"SELECT COALESCE(SUM(harvests), 0) AS total FROM game_market_ticks "
|
|
"WHERE crop_key = :crop_key AND hour_bucket >= :cutoff",
|
|
crop_key=crop_key,
|
|
cutoff=cutoff,
|
|
)
|
|
total = 0
|
|
for result in row:
|
|
total = int(result["total"] or 0)
|
|
break
|
|
_saturation_cache.set(cache_key, total)
|
|
return total
|
|
|
|
|
|
def active_farms() -> int:
|
|
cached = _saturation_cache.get("active_farms")
|
|
if cached is not None:
|
|
return cached
|
|
rows = db.query(
|
|
"SELECT COUNT(*) AS active FROM game_farms WHERE COALESCE(harvests_week, 0) > 0"
|
|
)
|
|
total = 1
|
|
for row in rows:
|
|
total = max(1, int(row.get("active") or 0))
|
|
break
|
|
_saturation_cache.set("active_farms", total)
|
|
return total
|
|
|
|
|
|
def _saturation_for(crop_key: str) -> float:
|
|
crop = economy.crop_for(crop_key)
|
|
if crop is None:
|
|
return 1.0
|
|
recent = recent_harvests(crop_key, economy.MARKET_WINDOW_HOURS)
|
|
return economy.market_saturation_factor(
|
|
economy.supply_days(crop, recent, active_farms())
|
|
)
|
|
|
|
|
|
def market_pressure() -> float:
|
|
worst = 1.0
|
|
for key in economy.MARKET_PRESSURE_CROPS:
|
|
worst = min(worst, _saturation_for(key))
|
|
return 1.0 - worst
|
|
|
|
|
|
def market_factor_for(crop_key: str) -> float:
|
|
if economy.crop_for(crop_key) is None:
|
|
return 1.0
|
|
saturation = _saturation_for(crop_key)
|
|
if saturation < 1.0:
|
|
return saturation
|
|
return economy.market_buff_factor(crop_key, market_pressure())
|
|
|
|
|
|
def prune_ticks(older_than_hours: int = 96) -> int:
|
|
from sqlalchemy import text
|
|
|
|
from .common import _now
|
|
|
|
cutoff = _hour_bucket(_now() - timedelta(hours=older_than_hours))
|
|
with db:
|
|
result = db.executable.execute(
|
|
text("DELETE FROM game_market_ticks WHERE hour_bucket < :cutoff"),
|
|
{"cutoff": cutoff},
|
|
)
|
|
return result.rowcount
|
|
|
|
|
|
def prune_steals(older_than_days: int = 60) -> int:
|
|
from datetime import timedelta as _timedelta
|
|
|
|
from sqlalchemy import text
|
|
|
|
from .common import _iso, _now
|
|
|
|
cutoff = _iso(_now() - _timedelta(days=older_than_days))
|
|
with db:
|
|
result = db.executable.execute(
|
|
text("DELETE FROM game_steals WHERE stolen_at < :cutoff"), {"cutoff": cutoff}
|
|
)
|
|
return result.rowcount
|