|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from devplacepy import config
|
|
from devplacepy.database import (
|
|
_in_clause,
|
|
build_pagination,
|
|
conditional_update_row,
|
|
db,
|
|
get_blocked_uids,
|
|
get_table,
|
|
get_users_by_uids,
|
|
resolve_object_url,
|
|
text_search_clause,
|
|
)
|
|
from devplacepy.services.audit import record as audit
|
|
from devplacepy.services.background import background
|
|
from devplacepy.services.game.store import (
|
|
conditional_update_farm,
|
|
ensure_farm,
|
|
refund_farm,
|
|
)
|
|
from devplacepy.utils import create_notification, generate_uid, track_action
|
|
from devplacepy.utils.rewards import (
|
|
XP_BATTLE_PART,
|
|
XP_BATTLE_TOP,
|
|
XP_BATTLE_WIN,
|
|
award_rewards,
|
|
)
|
|
|
|
from . import rules
|
|
|
|
FILTERS = ("active", "ended", "mine")
|
|
|
|
|
|
class WarError(Exception):
|
|
pass
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _iso(value: datetime | None = None) -> str:
|
|
return (value or _now()).isoformat()
|
|
|
|
|
|
def _wars():
|
|
return get_table("opinion_wars")
|
|
|
|
|
|
def _fighters():
|
|
return get_table("opinion_war_fighters")
|
|
|
|
|
|
def _events():
|
|
return get_table("opinion_war_events")
|
|
|
|
|
|
def born_live(fields: dict) -> dict:
|
|
stamp = _iso()
|
|
return {
|
|
**fields,
|
|
"created_at": fields.get("created_at") or stamp,
|
|
"updated_at": stamp,
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
}
|
|
|
|
|
|
def faction_label(war: dict, faction: str) -> str:
|
|
return war.get("faction_a") if faction == "a" else war.get("faction_b")
|
|
|
|
|
|
def get_war(war_uid: str) -> dict | None:
|
|
if not war_uid:
|
|
return None
|
|
return _wars().find_one(uid=war_uid, deleted_at=None)
|
|
|
|
|
|
def get_war_for_post(post_uid: str) -> dict | None:
|
|
if not post_uid:
|
|
return None
|
|
return _wars().find_one(post_uid=post_uid, deleted_at=None)
|
|
|
|
|
|
def create_war(
|
|
post_uid: str, user: dict, faction_a: str, faction_b: str, request=None
|
|
) -> str | None:
|
|
faction_a = (faction_a or "").strip()
|
|
faction_b = (faction_b or "").strip()
|
|
if not faction_a or not faction_b:
|
|
return None
|
|
if len(faction_a) > rules.FACTION_NAME_MAX or len(faction_b) > rules.FACTION_NAME_MAX:
|
|
return None
|
|
if faction_a.casefold() == faction_b.casefold():
|
|
return None
|
|
if get_war_for_post(post_uid):
|
|
return None
|
|
uid = generate_uid()
|
|
created_at = _iso()
|
|
_wars().insert(
|
|
born_live(
|
|
{
|
|
"uid": uid,
|
|
"post_uid": post_uid,
|
|
"user_uid": user["uid"],
|
|
"faction_a": faction_a,
|
|
"faction_b": faction_b,
|
|
"hp_a": 0,
|
|
"hp_b": 0,
|
|
"leader": "",
|
|
"status": "active",
|
|
"winner": "",
|
|
"created_at": created_at,
|
|
"ends_at": rules.ends_at_for(created_at),
|
|
"resolved_at": "",
|
|
}
|
|
)
|
|
)
|
|
label = f"{faction_a} vs {faction_b}"
|
|
audit.record(
|
|
request,
|
|
"battle.create",
|
|
user=user,
|
|
target_type="battle",
|
|
target_uid=uid,
|
|
target_label=label,
|
|
metadata={"faction_a": faction_a, "faction_b": faction_b},
|
|
summary=f"{user['username']} started Opinion War {label}",
|
|
links=[audit.target("battle", uid, label), audit.parent("post", post_uid)],
|
|
)
|
|
track_action(user["uid"], "battle_create")
|
|
return uid
|
|
|
|
|
|
def join_war(war: dict, user: dict, faction: str, request=None) -> dict:
|
|
war = resolve_if_due(war)
|
|
if not war or war.get("status") != "active":
|
|
raise WarError("This battle has ended.")
|
|
if faction not in ("a", "b"):
|
|
raise WarError("Pick faction a or b.")
|
|
label = faction_label(war, faction)
|
|
fighter = _fighters().find_one(war_uid=war["uid"], user_uid=user["uid"])
|
|
if fighter and fighter.get("deleted_at"):
|
|
_fighters().update(
|
|
{
|
|
"id": fighter["id"],
|
|
"faction": faction,
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"updated_at": _iso(),
|
|
},
|
|
["id"],
|
|
)
|
|
kind = "join"
|
|
elif fighter:
|
|
rows = conditional_update_row(
|
|
"opinion_war_fighters",
|
|
fighter["uid"],
|
|
"faction = :faction",
|
|
"faction != :faction AND deleted_at IS NULL",
|
|
{"faction": faction},
|
|
)
|
|
if not rows:
|
|
return war
|
|
kind = "switch"
|
|
else:
|
|
try:
|
|
_fighters().insert(
|
|
born_live(
|
|
{
|
|
"uid": generate_uid(),
|
|
"war_uid": war["uid"],
|
|
"user_uid": user["uid"],
|
|
"faction": faction,
|
|
"hp_a": 0,
|
|
"hp_b": 0,
|
|
"fight_count": 0,
|
|
"last_fight_at": "",
|
|
"cooldown_notified_at": "",
|
|
}
|
|
)
|
|
)
|
|
except IntegrityError:
|
|
return join_war(get_war(war["uid"]) or war, user, faction, request)
|
|
kind = "join"
|
|
verb = "defected to" if kind == "switch" else "joined"
|
|
allocate_event(
|
|
war["uid"],
|
|
kind,
|
|
f"{user['username']} {verb} {label}",
|
|
{"faction": faction},
|
|
user["uid"],
|
|
)
|
|
audit.record(
|
|
request,
|
|
f"battle.{kind}",
|
|
user=user,
|
|
target_type="battle",
|
|
target_uid=war["uid"],
|
|
target_label=label,
|
|
metadata={"faction": faction},
|
|
summary=f"{user['username']} {verb} {label}",
|
|
links=[audit.target("battle", war["uid"], label), audit.parent("post", war["post_uid"])],
|
|
)
|
|
return get_war(war["uid"]) or war
|
|
|
|
|
|
def fight(war: dict, user: dict, request=None) -> tuple[dict, int]:
|
|
war = resolve_if_due(war)
|
|
if not war or war.get("status") != "active":
|
|
raise WarError("This battle has ended.")
|
|
fighter = _fighters().find_one(
|
|
war_uid=war["uid"], user_uid=user["uid"], deleted_at=None
|
|
)
|
|
if not fighter:
|
|
raise WarError("Join a faction first.")
|
|
now = _iso()
|
|
previous = fighter.get("last_fight_at") or ""
|
|
claimed = conditional_update_row(
|
|
"opinion_war_fighters",
|
|
fighter["uid"],
|
|
"last_fight_at = :now",
|
|
"(COALESCE(last_fight_at, '') = '' OR last_fight_at <= :cutoff) AND deleted_at IS NULL",
|
|
{"now": now, "cutoff": rules.cooldown_cutoff()},
|
|
)
|
|
if not claimed:
|
|
raise WarError("You can fight once every 24 hours.")
|
|
farm = ensure_farm(user["uid"])
|
|
spent = conditional_update_farm(
|
|
farm["uid"],
|
|
"coins = COALESCE(coins, 0) - :cost",
|
|
"COALESCE(coins, 0) >= :cost",
|
|
{"cost": rules.FIGHT_COST_COINS},
|
|
)
|
|
if not spent:
|
|
_restore_cooldown(fighter["uid"], previous, now)
|
|
raise WarError(
|
|
f"A fight costs {rules.FIGHT_COST_COINS} Code Farm coins - earn some on your farm first."
|
|
)
|
|
faction = "a" if fighter.get("faction") != "b" else "b"
|
|
side = "hp_a" if faction == "a" else "hp_b"
|
|
damage = rules.damage_for(user.get("level"))
|
|
landed = conditional_update_row(
|
|
"opinion_wars",
|
|
war["uid"],
|
|
f"{side} = COALESCE({side}, 0) + :damage",
|
|
"status = 'active' AND ends_at > :now AND deleted_at IS NULL",
|
|
{"damage": damage, "now": now},
|
|
)
|
|
if not landed:
|
|
refund_farm(farm["uid"], rules.FIGHT_COST_COINS)
|
|
_restore_cooldown(fighter["uid"], previous, now)
|
|
resolve_if_due(get_war(war["uid"]))
|
|
raise WarError("The battle ended before your attack landed.")
|
|
conditional_update_row(
|
|
"opinion_war_fighters",
|
|
fighter["uid"],
|
|
f"{side} = COALESCE({side}, 0) + :damage, fight_count = COALESCE(fight_count, 0) + 1",
|
|
"1 = 1",
|
|
{"damage": damage},
|
|
)
|
|
label = faction_label(war, faction)
|
|
fresh = get_war(war["uid"]) or war
|
|
allocate_event(
|
|
war["uid"],
|
|
"fight",
|
|
f"{user['username']} dealt {damage} HP for {label}",
|
|
{
|
|
"faction": faction,
|
|
"damage": damage,
|
|
"hp_a": int(fresh.get("hp_a") or 0),
|
|
"hp_b": int(fresh.get("hp_b") or 0),
|
|
},
|
|
user["uid"],
|
|
)
|
|
_claim_lead(fresh, user)
|
|
track_action(user["uid"], "battle_fight")
|
|
audit.record(
|
|
request,
|
|
"battle.fight",
|
|
user=user,
|
|
target_type="battle",
|
|
target_uid=war["uid"],
|
|
target_label=label,
|
|
metadata={"faction": faction, "damage": damage, "cost": rules.FIGHT_COST_COINS},
|
|
summary=f"{user['username']} dealt {damage} HP for {label}",
|
|
links=[audit.target("battle", war["uid"], label), audit.parent("post", war["post_uid"])],
|
|
)
|
|
return get_war(war["uid"]) or fresh, damage
|
|
|
|
|
|
def _restore_cooldown(fighter_uid: str, previous: str, claimed: str) -> None:
|
|
conditional_update_row(
|
|
"opinion_war_fighters",
|
|
fighter_uid,
|
|
"last_fight_at = :previous",
|
|
"last_fight_at = :claimed",
|
|
{"previous": previous, "claimed": claimed},
|
|
)
|
|
|
|
|
|
def _claim_lead(war: dict, user: dict) -> None:
|
|
new_leader = rules.leader_of(int(war.get("hp_a") or 0), int(war.get("hp_b") or 0))
|
|
if not new_leader:
|
|
return
|
|
rows = conditional_update_row(
|
|
"opinion_wars",
|
|
war["uid"],
|
|
"leader = :leader",
|
|
"COALESCE(leader, '') != :leader AND status = 'active'",
|
|
{"leader": new_leader},
|
|
)
|
|
if not rows:
|
|
return
|
|
label = faction_label(war, new_leader)
|
|
allocate_event(
|
|
war["uid"],
|
|
"lead_change",
|
|
f"{label} took the lead!",
|
|
{
|
|
"faction": new_leader,
|
|
"hp_a": int(war.get("hp_a") or 0),
|
|
"hp_b": int(war.get("hp_b") or 0),
|
|
},
|
|
user["uid"],
|
|
)
|
|
background.submit(
|
|
_notify_fighters,
|
|
war["uid"],
|
|
f"{label} took the lead in an Opinion War you fight in",
|
|
user["uid"],
|
|
user["uid"],
|
|
)
|
|
|
|
|
|
def _notify_fighters(
|
|
war_uid: str, message: str, related_uid: str | None, exclude_uid: str | None
|
|
) -> None:
|
|
war = get_war(war_uid)
|
|
if not war:
|
|
return
|
|
url = resolve_object_url("post", war["post_uid"])
|
|
for row in _fighters().find(war_uid=war_uid, deleted_at=None):
|
|
recipient = row.get("user_uid") or ""
|
|
if not recipient or recipient == exclude_uid:
|
|
continue
|
|
create_notification(
|
|
recipient, "battle", message, related_uid or recipient, target_url=url
|
|
)
|
|
|
|
|
|
def allocate_event(
|
|
war_uid: str, kind: str, message: str, payload: dict | None = None, actor_uid: str = ""
|
|
) -> dict:
|
|
uid = generate_uid()
|
|
created_at = _iso()
|
|
with db:
|
|
db.query(
|
|
"INSERT INTO opinion_war_events "
|
|
"(uid, war_uid, seq, kind, message, payload, actor_uid, created_at, deleted_at, deleted_by) "
|
|
"SELECT :uid, :war_uid, COALESCE(MAX(seq), 0) + 1, :kind, :message, :payload, "
|
|
":actor_uid, :created_at, NULL, NULL "
|
|
"FROM opinion_war_events WHERE war_uid = :war_uid",
|
|
uid=uid,
|
|
war_uid=war_uid,
|
|
kind=kind,
|
|
message=message,
|
|
payload=json.dumps(payload or {}, ensure_ascii=False),
|
|
actor_uid=actor_uid or "",
|
|
created_at=created_at,
|
|
)
|
|
row = _events().find_one(uid=uid)
|
|
return _event_dict(row) if row else {
|
|
"seq": 0,
|
|
"kind": kind,
|
|
"message": message,
|
|
"faction": (payload or {}).get("faction", ""),
|
|
"created_at": created_at,
|
|
}
|
|
|
|
|
|
def _event_dict(row: dict) -> dict:
|
|
try:
|
|
payload = json.loads(row.get("payload") or "{}")
|
|
except (TypeError, ValueError):
|
|
payload = {}
|
|
if not isinstance(payload, dict):
|
|
payload = {}
|
|
event = {
|
|
"seq": int(row.get("seq") or 0),
|
|
"kind": row.get("kind") or "",
|
|
"message": row.get("message") or "",
|
|
"faction": payload.get("faction", ""),
|
|
"created_at": row.get("created_at") or "",
|
|
}
|
|
for key in ("hp_a", "hp_b", "damage", "winner"):
|
|
if key in payload:
|
|
event[key] = payload[key]
|
|
return event
|
|
|
|
|
|
def events_for(war_uid: str, after_seq: int = 0, limit: int = rules.EVENT_LIMIT_DEFAULT) -> list[dict]:
|
|
capped = max(1, min(int(limit or 1), rules.EVENT_LIMIT_DEFAULT))
|
|
rows = _events().find(
|
|
war_uid=war_uid,
|
|
seq={">": max(0, int(after_seq or 0))},
|
|
deleted_at=None,
|
|
order_by=["seq"],
|
|
_limit=capped,
|
|
)
|
|
return [_event_dict(dict(row)) for row in rows]
|
|
|
|
|
|
def resolve_if_due(war: dict | None) -> dict | None:
|
|
if not war or war.get("status") != "active":
|
|
return war
|
|
if not rules.is_ended(war.get("ends_at") or ""):
|
|
return war
|
|
return resolve_war(war)
|
|
|
|
|
|
def resolve_war(war: dict) -> dict:
|
|
rows = conditional_update_row(
|
|
"opinion_wars",
|
|
war["uid"],
|
|
"status = 'resolved', resolved_at = :now, "
|
|
"winner = CASE WHEN COALESCE(hp_a, 0) > COALESCE(hp_b, 0) THEN 'a' "
|
|
"WHEN COALESCE(hp_b, 0) > COALESCE(hp_a, 0) THEN 'b' ELSE 'draw' END",
|
|
"status = 'active' AND deleted_at IS NULL",
|
|
{"now": _iso()},
|
|
)
|
|
resolved = get_war(war["uid"]) or war
|
|
if rows:
|
|
_award_and_announce(resolved)
|
|
return resolved
|
|
|
|
|
|
def _award_and_announce(war: dict) -> None:
|
|
winner = war.get("winner") or "draw"
|
|
hp_a = int(war.get("hp_a") or 0)
|
|
hp_b = int(war.get("hp_b") or 0)
|
|
fighters = list(_fighters().find(war_uid=war["uid"], deleted_at=None))
|
|
active = [row for row in fighters if int(row.get("fight_count") or 0) > 0]
|
|
for row in active:
|
|
award_rewards(row["user_uid"], XP_BATTLE_PART)
|
|
if winner in ("a", "b") and row.get("faction") == winner:
|
|
award_rewards(row["user_uid"], XP_BATTLE_WIN)
|
|
track_action(row["user_uid"], "battle_win")
|
|
if winner in ("a", "b") and active:
|
|
top = min(
|
|
active,
|
|
key=lambda row: (
|
|
-(int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0)),
|
|
int(row.get("id") or 0),
|
|
),
|
|
)
|
|
award_rewards(top["user_uid"], XP_BATTLE_TOP)
|
|
if winner == "a":
|
|
message = f"Battle over: {war['faction_a']} wins {hp_a} to {hp_b}"
|
|
elif winner == "b":
|
|
message = f"Battle over: {war['faction_b']} wins {hp_b} to {hp_a}"
|
|
else:
|
|
message = f"Battle over: a draw at {hp_a} HP each"
|
|
allocate_event(
|
|
war["uid"], "result", message, {"winner": winner, "hp_a": hp_a, "hp_b": hp_b}, ""
|
|
)
|
|
background.submit(_notify_fighters, war["uid"], message, None, None)
|
|
audit.record_system(
|
|
"battle.resolve",
|
|
target_type="battle",
|
|
target_uid=war["uid"],
|
|
target_label=f"{war['faction_a']} vs {war['faction_b']}",
|
|
metadata={"winner": winner, "hp_a": hp_a, "hp_b": hp_b},
|
|
summary=message,
|
|
links=[
|
|
audit.target("battle", war["uid"], f"{war['faction_a']} vs {war['faction_b']}"),
|
|
audit.parent("post", war["post_uid"]),
|
|
],
|
|
)
|
|
|
|
|
|
def resolve_due_wars(limit: int = 25) -> int:
|
|
if "opinion_wars" not in db.tables:
|
|
return 0
|
|
due = list(
|
|
_wars().find(
|
|
status="active",
|
|
ends_at={"<=": _iso()},
|
|
deleted_at=None,
|
|
order_by=["ends_at"],
|
|
_limit=max(1, limit),
|
|
)
|
|
)
|
|
resolved = 0
|
|
for war in due:
|
|
if resolve_war(dict(war)).get("status") == "resolved":
|
|
resolved += 1
|
|
return resolved
|
|
|
|
|
|
def cooldown_ready_fighters(limit: int = 50) -> list[dict]:
|
|
if "opinion_war_fighters" not in db.tables:
|
|
return []
|
|
rows = db.query(
|
|
"SELECT f.* FROM opinion_war_fighters f "
|
|
"JOIN opinion_wars w ON w.uid = f.war_uid "
|
|
"WHERE f.deleted_at IS NULL AND w.deleted_at IS NULL AND w.status = 'active' "
|
|
"AND COALESCE(f.last_fight_at, '') != '' AND f.last_fight_at <= :cutoff "
|
|
"AND COALESCE(f.cooldown_notified_at, '') != f.last_fight_at "
|
|
"LIMIT :limit",
|
|
cutoff=rules.cooldown_cutoff(),
|
|
limit=max(1, limit),
|
|
)
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
def notify_cooldown_ready(fighter: dict) -> bool:
|
|
last = fighter.get("last_fight_at") or ""
|
|
rows = conditional_update_row(
|
|
"opinion_war_fighters",
|
|
fighter["uid"],
|
|
"cooldown_notified_at = :last",
|
|
"COALESCE(cooldown_notified_at, '') != :last AND last_fight_at = :last",
|
|
{"last": last},
|
|
)
|
|
if not rows:
|
|
return False
|
|
war = get_war(fighter.get("war_uid") or "")
|
|
if not war:
|
|
return True
|
|
create_notification(
|
|
fighter["user_uid"],
|
|
"battle",
|
|
"Your Fight is ready - return to the battle",
|
|
fighter["user_uid"],
|
|
target_url=resolve_object_url("post", war["post_uid"]),
|
|
)
|
|
return True
|
|
|
|
|
|
def get_wars_by_post_uids(post_uids: list, user: dict | None = None) -> dict:
|
|
uids = [uid for uid in (post_uids or []) if uid]
|
|
if not uids or "opinion_wars" not in db.tables:
|
|
return {}
|
|
placeholders, params = _in_clause(uids)
|
|
wars = [
|
|
dict(row)
|
|
for row in db.query(
|
|
f"SELECT * FROM opinion_wars WHERE post_uid IN ({placeholders}) "
|
|
"AND deleted_at IS NULL",
|
|
**params,
|
|
)
|
|
]
|
|
if not wars:
|
|
return {}
|
|
serialized = serialize_wars(wars, user)
|
|
return {item["post_uid"]: item for item in serialized}
|
|
|
|
|
|
def get_war_serialized(war: dict | None, user: dict | None = None) -> dict | None:
|
|
if not war:
|
|
return None
|
|
items = serialize_wars([dict(war)], user)
|
|
return items[0] if items else None
|
|
|
|
|
|
def get_war_serialized_for_post(post_uid: str, user: dict | None = None) -> dict | None:
|
|
return get_war_serialized(get_war_for_post(post_uid), user)
|
|
|
|
|
|
def serialize_wars(wars: list[dict], user: dict | None = None) -> list[dict]:
|
|
wars = [resolve_if_due(war) for war in wars]
|
|
war_uids = [war["uid"] for war in wars]
|
|
placeholders, params = _in_clause(war_uids)
|
|
fighters_by_war: dict[str, list[dict]] = {uid: [] for uid in war_uids}
|
|
if "opinion_war_fighters" in db.tables:
|
|
for row in db.query(
|
|
f"SELECT * FROM opinion_war_fighters WHERE war_uid IN ({placeholders}) "
|
|
"AND deleted_at IS NULL",
|
|
**params,
|
|
):
|
|
fighters_by_war.setdefault(row["war_uid"], []).append(dict(row))
|
|
events_by_war: dict[str, list[dict]] = {uid: [] for uid in war_uids}
|
|
if "opinion_war_events" in db.tables:
|
|
for row in db.query(
|
|
"SELECT * FROM ("
|
|
"SELECT e.*, ROW_NUMBER() OVER (PARTITION BY war_uid ORDER BY seq DESC) AS rn "
|
|
f"FROM opinion_war_events e WHERE war_uid IN ({placeholders}) "
|
|
"AND deleted_at IS NULL) WHERE rn <= :ticker",
|
|
**params,
|
|
ticker=rules.EVENT_TICKER_LIMIT,
|
|
):
|
|
events_by_war.setdefault(row["war_uid"], []).append(dict(row))
|
|
posts_by_uid: dict[str, dict] = {}
|
|
post_placeholders, post_params = _in_clause([war["post_uid"] for war in wars])
|
|
if "posts" in db.tables:
|
|
for row in db.query(
|
|
f"SELECT uid, slug, title FROM posts WHERE uid IN ({post_placeholders}) "
|
|
"AND deleted_at IS NULL",
|
|
**post_params,
|
|
):
|
|
posts_by_uid[row["uid"]] = dict(row)
|
|
user_uids = {war["user_uid"] for war in wars}
|
|
for rows in fighters_by_war.values():
|
|
ranked = _ranked(rows)
|
|
for fighter in ranked[: rules.TOP_CONTRIBUTORS]:
|
|
user_uids.add(fighter["user_uid"])
|
|
users = get_users_by_uids(list(user_uids))
|
|
return [
|
|
_serialize_war(
|
|
war,
|
|
_ranked(fighters_by_war.get(war["uid"], [])),
|
|
events_by_war.get(war["uid"], []),
|
|
posts_by_uid.get(war["post_uid"]),
|
|
users,
|
|
user,
|
|
)
|
|
for war in wars
|
|
]
|
|
|
|
|
|
def _ranked(fighters: list[dict]) -> list[dict]:
|
|
return sorted(
|
|
fighters,
|
|
key=lambda row: (
|
|
-(int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0)),
|
|
int(row.get("id") or 0),
|
|
),
|
|
)
|
|
|
|
|
|
def _person(users: dict, uid: str) -> dict:
|
|
row = users.get(uid) or {}
|
|
return {
|
|
"uid": uid,
|
|
"username": row.get("username") or "unknown",
|
|
"avatar_seed": row.get("avatar_seed"),
|
|
"level": int(row.get("level") or 1),
|
|
}
|
|
|
|
|
|
def _serialize_war(
|
|
war: dict,
|
|
ranked: list[dict],
|
|
event_rows: list[dict],
|
|
post: dict | None,
|
|
users: dict,
|
|
viewer: dict | None,
|
|
) -> dict:
|
|
hp_a = int(war.get("hp_a") or 0)
|
|
hp_b = int(war.get("hp_b") or 0)
|
|
pct_a, pct_b = rules.pct_split(hp_a, hp_b)
|
|
events = sorted((_event_dict(row) for row in event_rows), key=lambda e: -e["seq"])
|
|
last_seq = events[0]["seq"] if events else 0
|
|
status = war.get("status") or "active"
|
|
winner = war.get("winner") or ""
|
|
viewer_row = None
|
|
viewer_uid = (viewer or {}).get("uid") or ""
|
|
if viewer_uid:
|
|
for index, row in enumerate(ranked):
|
|
if row.get("user_uid") == viewer_uid:
|
|
viewer_row = {
|
|
"faction": row.get("faction") or "",
|
|
"hp": int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0),
|
|
"rank": index + 1,
|
|
"fight_count": int(row.get("fight_count") or 0),
|
|
"last_fight_at": row.get("last_fight_at") or "",
|
|
"next_fight_at": rules.cooldown_ready_at(row.get("last_fight_at") or ""),
|
|
"can_fight": status == "active"
|
|
and rules.can_fight_at(row.get("last_fight_at") or ""),
|
|
}
|
|
break
|
|
top_contributors = [
|
|
{
|
|
**_person(users, row["user_uid"]),
|
|
"faction": row.get("faction") or "",
|
|
"hp": int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0),
|
|
}
|
|
for row in ranked[: rules.TOP_CONTRIBUTORS]
|
|
if int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0) > 0
|
|
]
|
|
slug = (post or {}).get("slug") or war["post_uid"]
|
|
return {
|
|
"uid": war["uid"],
|
|
"post_uid": war["post_uid"],
|
|
"post_url": f"/posts/{slug}",
|
|
"post_title": (post or {}).get("title") or "",
|
|
"author": _person(users, war["user_uid"]),
|
|
"faction_a": war.get("faction_a") or "",
|
|
"faction_b": war.get("faction_b") or "",
|
|
"hp_a": hp_a,
|
|
"hp_b": hp_b,
|
|
"pct_a": pct_a,
|
|
"pct_b": pct_b,
|
|
"leader": war.get("leader") or "",
|
|
"fighter_count": len(ranked),
|
|
"status": status,
|
|
"winner": winner,
|
|
"winner_label": faction_label(war, winner) or "" if winner in ("a", "b") else "",
|
|
"created_at": war.get("created_at") or "",
|
|
"ends_at": war.get("ends_at") or "",
|
|
"ends_in": rules.ends_in_label(war.get("ends_at") or ""),
|
|
"resolved_at": war.get("resolved_at") or "",
|
|
"last_seq": last_seq,
|
|
"fight_cost": rules.FIGHT_COST_COINS,
|
|
"top_contributors": top_contributors,
|
|
"recent_events": events,
|
|
"viewer": viewer_row,
|
|
}
|
|
|
|
|
|
def list_wars(
|
|
*,
|
|
viewer: dict | None = None,
|
|
war_filter: str = "active",
|
|
search: str = "",
|
|
page: int = 1,
|
|
per_page: int = config.BATTLES_LIST_PER_PAGE,
|
|
) -> tuple[list[dict], dict]:
|
|
table = _wars()
|
|
resolve_due_wars()
|
|
clauses, filters = _list_clauses(table, viewer, war_filter, search)
|
|
if clauses is None:
|
|
return [], build_pagination(1, 0, per_page)
|
|
total = table.count(*clauses, deleted_at=None, **filters)
|
|
pagination = build_pagination(page, total, per_page)
|
|
offset = (pagination["page"] - 1) * pagination["per_page"]
|
|
rows = list(
|
|
table.find(
|
|
*clauses,
|
|
deleted_at=None,
|
|
order_by=["-created_at"],
|
|
_limit=pagination["per_page"],
|
|
_offset=offset,
|
|
**filters,
|
|
)
|
|
)
|
|
return serialize_wars([dict(row) for row in rows], viewer), pagination
|
|
|
|
|
|
def filter_counts(viewer: dict | None, search: str = "") -> dict[str, int]:
|
|
table = _wars()
|
|
counts: dict[str, int] = {}
|
|
for name in FILTERS:
|
|
clauses, filters = _list_clauses(table, viewer, name, search)
|
|
counts[name] = (
|
|
0 if clauses is None else table.count(*clauses, deleted_at=None, **filters)
|
|
)
|
|
return counts
|
|
|
|
|
|
def _joined_war_uids(viewer_uid: str) -> list[str]:
|
|
return [
|
|
row["war_uid"]
|
|
for row in _fighters().find(user_uid=viewer_uid, deleted_at=None)
|
|
]
|
|
|
|
|
|
def _list_clauses(table, viewer, war_filter, search):
|
|
if not table.exists:
|
|
return None, {}
|
|
viewer_uid = (viewer or {}).get("uid", "")
|
|
columns = table.table.columns
|
|
clauses = []
|
|
filters: dict = {}
|
|
if war_filter == "ended":
|
|
filters["status"] = "resolved"
|
|
elif war_filter == "mine":
|
|
if not viewer_uid:
|
|
return None, {}
|
|
joined = _joined_war_uids(viewer_uid)
|
|
own = columns.user_uid == viewer_uid
|
|
clauses.append(or_(own, columns.uid.in_(joined)) if joined else own)
|
|
else:
|
|
filters["status"] = "active"
|
|
if viewer_uid and war_filter != "mine":
|
|
blocked = get_blocked_uids(viewer_uid)
|
|
if blocked:
|
|
clauses.append(columns.user_uid.notin_(blocked))
|
|
match = text_search_clause(
|
|
table, search, ("faction_a", "faction_b"), author_field="user_uid"
|
|
)
|
|
if match is not None:
|
|
clauses.append(match)
|
|
return clauses, filters
|