Every purchase/upgrade path (new and pre-existing) is now race-safe against concurrent requests via atomic conditional SQL updates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
133 lines
3.2 KiB
Python
133 lines
3.2 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from devplacepy.database import get_table
|
|
|
|
from .. import economy
|
|
|
|
|
|
class GameError(Exception):
|
|
pass
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _iso(value: datetime) -> str:
|
|
return value.isoformat()
|
|
|
|
|
|
def _parse(value: str) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _today() -> str:
|
|
return _now().date().isoformat()
|
|
|
|
|
|
def _iso_week(value: datetime | None = None) -> str:
|
|
value = value or _now()
|
|
year, week, _ = value.isocalendar()
|
|
return f"{year}-W{week:02d}"
|
|
|
|
|
|
def _parse_date(value: str):
|
|
parsed = _parse(value)
|
|
return parsed.date() if parsed else None
|
|
|
|
|
|
def _farms():
|
|
return get_table("game_farms")
|
|
|
|
|
|
def _plots():
|
|
return get_table("game_plots")
|
|
|
|
|
|
def _quests():
|
|
return get_table("game_quests")
|
|
|
|
|
|
def _steals():
|
|
return get_table("game_steals")
|
|
|
|
|
|
def last_steal_at(thief_uid: str, owner_uid: str) -> datetime | None:
|
|
latest = None
|
|
for row in _steals().find(thief_uid=thief_uid, owner_uid=owner_uid):
|
|
stamp = _parse(row.get("stolen_at", ""))
|
|
if stamp and (latest is None or stamp > latest):
|
|
latest = stamp
|
|
return latest
|
|
|
|
|
|
def steal_cooldown_remaining(thief_uid: str, owner_uid: str, now: datetime) -> int:
|
|
latest = last_steal_at(thief_uid, owner_uid)
|
|
if not latest:
|
|
return 0
|
|
elapsed = (now - latest).total_seconds()
|
|
return max(0, int(economy.STEAL_COOLDOWN_SECONDS - elapsed))
|
|
|
|
|
|
def _lvl(farm: dict, key: str) -> int:
|
|
return int(farm.get(key) or 0)
|
|
|
|
|
|
PERK_COLUMN = {perk.key: f"perk_{perk.key}" for perk in economy.PERKS}
|
|
|
|
|
|
def _update_farm(farm_uid: str, fields: dict) -> None:
|
|
fields = {**fields, "uid": farm_uid, "updated_at": _iso(_now())}
|
|
_farms().update(fields, ["uid"])
|
|
|
|
|
|
def conditional_update_farm(farm_uid: str, set_clause: str, where_clause: str, params: dict) -> int:
|
|
from sqlalchemy import text
|
|
|
|
from devplacepy.database import db
|
|
|
|
sql = (
|
|
f"UPDATE game_farms SET {set_clause}, updated_at = :updated_at "
|
|
f"WHERE uid = :farm_uid AND ({where_clause})"
|
|
)
|
|
bind = {**params, "updated_at": _iso(_now()), "farm_uid": farm_uid}
|
|
with db:
|
|
result = db.executable.execute(text(sql), bind)
|
|
return result.rowcount
|
|
|
|
|
|
def credit_farm(
|
|
farm: dict,
|
|
*,
|
|
coins: int = 0,
|
|
xp: int = 0,
|
|
harvests: int = 0,
|
|
era_active: bool = False,
|
|
extra: dict | None = None,
|
|
) -> dict:
|
|
new_xp = int(farm.get("xp", 0)) + xp
|
|
fields = {
|
|
"coins": max(0, int(farm.get("coins", 0)) + coins),
|
|
"xp": new_xp,
|
|
"level": economy.level_for_xp(new_xp),
|
|
"total_harvests": int(farm.get("total_harvests", 0)) + harvests,
|
|
"lifetime_coins_earned": max(0, _lvl(farm, "lifetime_coins_earned") + max(0, coins)),
|
|
"lifetime_harvests": _lvl(farm, "lifetime_harvests") + harvests,
|
|
}
|
|
if era_active:
|
|
fields["era_coins"] = _lvl(farm, "era_coins") + max(0, coins)
|
|
fields["era_harvests"] = _lvl(farm, "era_harvests") + harvests
|
|
if extra:
|
|
fields.update(extra)
|
|
_update_farm(farm["uid"], fields)
|
|
return {**farm, **fields}
|