# 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