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>
289 lines
9.3 KiB
Python
289 lines
9.3 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
from devplacepy.cache import TTLCache
|
|
from devplacepy.utils import generate_uid
|
|
|
|
from .. import economy
|
|
from .common import GameError, _farms, _iso, _lvl, _now, _plots, conditional_update_farm
|
|
|
|
|
|
_leaderboard_cache = TTLCache(ttl=15, max_size=8)
|
|
|
|
|
|
def get_farm(user_uid: str) -> dict | None:
|
|
return _farms().find_one(user_uid=user_uid)
|
|
|
|
|
|
def ensure_farm(user_uid: str) -> dict:
|
|
farm = get_farm(user_uid)
|
|
if farm:
|
|
return farm
|
|
now = _iso(_now())
|
|
uid = generate_uid()
|
|
_farms().insert(
|
|
{
|
|
"uid": uid,
|
|
"user_uid": user_uid,
|
|
"coins": economy.STARTING_COINS,
|
|
"xp": 0,
|
|
"level": 1,
|
|
"ci_tier": 1,
|
|
"plot_count": economy.STARTING_PLOTS,
|
|
"total_harvests": 0,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
)
|
|
for slot_index in range(economy.STARTING_PLOTS):
|
|
_create_plot(uid, user_uid, slot_index, now)
|
|
return get_farm(user_uid)
|
|
|
|
|
|
def _create_plot(farm_uid: str, user_uid: str, slot_index: int, now: str) -> None:
|
|
_plots().insert(
|
|
{
|
|
"uid": generate_uid(),
|
|
"farm_uid": farm_uid,
|
|
"user_uid": user_uid,
|
|
"slot_index": slot_index,
|
|
"crop_key": "",
|
|
"planted_at": "",
|
|
"ready_at": "",
|
|
"watered_by": "[]",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
)
|
|
|
|
|
|
def get_plots(farm_uid: str) -> list[dict]:
|
|
rows = list(_plots().find(farm_uid=farm_uid))
|
|
rows.sort(key=lambda row: row.get("slot_index", 0))
|
|
return rows
|
|
|
|
|
|
def _plot_at(farm_uid: str, slot_index: int) -> dict | None:
|
|
return _plots().find_one(farm_uid=farm_uid, slot_index=slot_index)
|
|
|
|
|
|
def buy_plot(user: dict) -> dict:
|
|
farm = ensure_farm(user["uid"])
|
|
plot_count = int(farm.get("plot_count", economy.STARTING_PLOTS))
|
|
if plot_count >= economy.MAX_PLOTS:
|
|
raise GameError("All plots are already unlocked.")
|
|
cost = economy.plot_cost(plot_count)
|
|
rows = conditional_update_farm(
|
|
farm["uid"],
|
|
set_clause="coins = coins - :cost, plot_count = :new_count",
|
|
where_clause="plot_count = :current_count AND coins >= :cost",
|
|
params={"cost": cost, "new_count": plot_count + 1, "current_count": plot_count},
|
|
)
|
|
if rows == 0:
|
|
farm = get_farm(user["uid"])
|
|
if int(farm.get("plot_count", economy.STARTING_PLOTS)) != plot_count:
|
|
raise GameError("Plot count already changed - refresh and try again.")
|
|
raise GameError("Not enough coins for a new plot.")
|
|
_create_plot(farm["uid"], user["uid"], plot_count, _iso(_now()))
|
|
return {"plot_count": plot_count + 1, "spent": cost}
|
|
|
|
|
|
def upgrade_ci(user: dict) -> dict:
|
|
farm = ensure_farm(user["uid"])
|
|
ci_tier = int(farm.get("ci_tier", 1))
|
|
next_tier = economy.next_ci_tier(ci_tier)
|
|
if not next_tier:
|
|
raise GameError("CI is already at the top tier.")
|
|
rows = conditional_update_farm(
|
|
farm["uid"],
|
|
set_clause="coins = coins - :cost, ci_tier = :new_tier",
|
|
where_clause="ci_tier = :current_tier AND coins >= :cost",
|
|
params={"cost": next_tier.upgrade_cost, "new_tier": next_tier.tier, "current_tier": ci_tier},
|
|
)
|
|
if rows == 0:
|
|
farm = get_farm(user["uid"])
|
|
if int(farm.get("ci_tier", 1)) != ci_tier:
|
|
raise GameError("CI tier already changed - refresh and try again.")
|
|
raise GameError("Not enough coins to upgrade CI.")
|
|
return {"ci_tier": next_tier.tier, "spent": next_tier.upgrade_cost}
|
|
|
|
|
|
def leaderboard(limit: int = 25) -> list[dict]:
|
|
cached = _leaderboard_cache.get(f"top:{limit}")
|
|
if cached is not None:
|
|
return cached
|
|
farms = sorted(
|
|
_farms().find(),
|
|
key=economy.farm_score,
|
|
reverse=True,
|
|
)[:limit]
|
|
if not farms:
|
|
return []
|
|
from devplacepy.database import get_users_by_uids
|
|
|
|
users = get_users_by_uids([farm["user_uid"] for farm in farms])
|
|
entries = []
|
|
for rank, farm in enumerate(farms, start=1):
|
|
owner = users.get(farm["user_uid"])
|
|
if not owner:
|
|
continue
|
|
entries.append(
|
|
{
|
|
"rank": rank,
|
|
"username": owner.get("username", ""),
|
|
"level": int(farm.get("level", 1)),
|
|
"xp": int(farm.get("xp", 0)),
|
|
"coins": int(farm.get("coins", 0)),
|
|
"total_harvests": int(farm.get("total_harvests", 0)),
|
|
"prestige": int(farm.get("prestige") or 0),
|
|
"score": economy.farm_score(farm),
|
|
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
|
|
}
|
|
)
|
|
_leaderboard_cache.set(f"top:{limit}", entries)
|
|
return entries
|
|
|
|
|
|
def _entry(rank: int, farm: dict, owner: dict, score: int) -> dict:
|
|
return {
|
|
"rank": rank,
|
|
"username": owner.get("username", ""),
|
|
"level": int(farm.get("level", 1)),
|
|
"xp": int(farm.get("xp", 0)),
|
|
"coins": int(farm.get("coins", 0)),
|
|
"total_harvests": int(farm.get("total_harvests", 0)),
|
|
"prestige": int(farm.get("prestige") or 0),
|
|
"score": score,
|
|
"title": economy.cosmetic_title_name(farm.get("active_title") or ""),
|
|
}
|
|
|
|
|
|
def _ranked_entries(farms: list[dict], score_fn, limit: int) -> list[dict]:
|
|
ranked = sorted(farms, key=score_fn, reverse=True)[:limit]
|
|
if not ranked:
|
|
return []
|
|
from devplacepy.database import get_users_by_uids
|
|
|
|
users = get_users_by_uids([farm["user_uid"] for farm in ranked])
|
|
entries = []
|
|
for rank, farm in enumerate(ranked, start=1):
|
|
owner = users.get(farm["user_uid"])
|
|
if not owner:
|
|
continue
|
|
entries.append(_entry(rank, farm, owner, score_fn(farm)))
|
|
return entries
|
|
|
|
|
|
def leaderboard_prestige(limit: int = 25) -> list[dict]:
|
|
return _ranked_entries(
|
|
list(_farms().find()),
|
|
lambda f: _lvl(f, "prestige") * 1_000_000 + _lvl(f, "stars"),
|
|
limit,
|
|
)
|
|
|
|
|
|
def leaderboard_harvests_week(limit: int = 25) -> list[dict]:
|
|
return _ranked_entries(list(_farms().find()), lambda f: _lvl(f, "harvests_week"), limit)
|
|
|
|
|
|
def leaderboard_fair_play(limit: int = 25) -> list[dict]:
|
|
return _ranked_entries(
|
|
list(_farms().find()),
|
|
lambda f: economy.fair_play_score(_lvl(f, "harvests_week"), int(f.get("coins", 0))),
|
|
limit,
|
|
)
|
|
|
|
|
|
def leaderboard_time_to_kernel(limit: int = 25) -> list[dict]:
|
|
farms = [
|
|
f
|
|
for f in _farms().find()
|
|
if _lvl(f, "prestige") > 0
|
|
and _lvl(f, "last_kernel_harvest_prestige") == _lvl(f, "prestige")
|
|
]
|
|
ranked = sorted(farms, key=lambda f: _lvl(f, "time_to_kernel_seconds"))[:limit]
|
|
if not ranked:
|
|
return []
|
|
from devplacepy.database import get_users_by_uids
|
|
|
|
users = get_users_by_uids([farm["user_uid"] for farm in ranked])
|
|
entries = []
|
|
for rank, farm in enumerate(ranked, start=1):
|
|
owner = users.get(farm["user_uid"])
|
|
if not owner:
|
|
continue
|
|
entry = _entry(rank, farm, owner, _lvl(farm, "time_to_kernel_seconds"))
|
|
entry["time_to_kernel_seconds"] = _lvl(farm, "time_to_kernel_seconds")
|
|
entries.append(entry)
|
|
return entries
|
|
|
|
|
|
RAID_EFFICIENCY_WINDOW_DAYS = 30
|
|
|
|
|
|
def leaderboard_raid_efficiency(limit: int = 25) -> list[dict]:
|
|
from datetime import timedelta
|
|
|
|
from devplacepy.database import db, get_users_by_uids
|
|
|
|
cutoff = _iso(_now() - timedelta(days=RAID_EFFICIENCY_WINDOW_DAYS))
|
|
rows = db.query(
|
|
"SELECT thief_uid, COUNT(*) AS raids, SUM(coins) AS total_coins "
|
|
"FROM game_steals WHERE stolen_at >= :cutoff "
|
|
"GROUP BY thief_uid HAVING COUNT(*) >= :min_raids "
|
|
"ORDER BY (SUM(coins) * 1.0 / COUNT(*)) DESC LIMIT :limit",
|
|
cutoff=cutoff,
|
|
min_raids=economy.MIN_RAIDS_FOR_EFFICIENCY_BOARD,
|
|
limit=limit,
|
|
)
|
|
ranked = [
|
|
(row["thief_uid"], int(row["raids"]), int(row["total_coins"] or 0)) for row in rows
|
|
]
|
|
if not ranked:
|
|
return []
|
|
uids = [uid for uid, _, _ in ranked]
|
|
users = get_users_by_uids(uids)
|
|
farms_table = _farms()
|
|
farms_by_uid = {
|
|
f["user_uid"]: f for f in farms_table.find(farms_table.table.columns.user_uid.in_(uids))
|
|
}
|
|
entries = []
|
|
for rank, (uid, raids, total_coins) in enumerate(ranked, start=1):
|
|
owner = users.get(uid)
|
|
if not owner:
|
|
continue
|
|
farm = farms_by_uid.get(uid, {})
|
|
avg = total_coins / raids
|
|
entry = _entry(rank, farm, owner, round(avg))
|
|
entry["raid_avg"] = round(avg, 1)
|
|
entries.append(entry)
|
|
return entries
|
|
|
|
|
|
LEADERBOARD_BOARDS = {
|
|
"score": leaderboard,
|
|
"prestige": leaderboard_prestige,
|
|
"harvests": leaderboard_harvests_week,
|
|
"raids": leaderboard_raid_efficiency,
|
|
"time_to_kernel": leaderboard_time_to_kernel,
|
|
"fair_play": leaderboard_fair_play,
|
|
}
|
|
|
|
_board_cache = TTLCache(ttl=15, max_size=32)
|
|
|
|
|
|
def leaderboard_for(board: str, limit: int = 25) -> list[dict]:
|
|
cache_key = f"{board}:{limit}"
|
|
cached = _board_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
if board == "era":
|
|
from .era import leaderboard_era
|
|
|
|
entries = leaderboard_era(limit)
|
|
else:
|
|
entries = LEADERBOARD_BOARDS.get(board, leaderboard)(limit)
|
|
_board_cache.set(cache_key, entries)
|
|
return entries
|