diff --git a/README.md b/README.md index e1f8429..0b56123 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,18 @@ Odštampani PDF-ovi ostaju u `~/.local/share/idriz/out/`. ``` IDRIZ_DRY_RUN=1 .venv/bin/python scripts/test_summary.py "Volcano" "8 godina" ``` + +## Igra "Superheroji" + +Glasovna igra (bez štampanja). Tok: Idriz pita koliko igrača (1–5) i imena, pa se +igra nekoliko rundi. Svaku rundu svaki igrač kaže jednog izmišljenog lika (bilo koji +medij — Supermen, Mario, La Linea, Mad Max...), a Idriz presudi ko bi pobijedio i +zašto. Pobjednik runde se više ne može birati do kraja partije. Sistem pamti +relativne jačine likova (Elo) u `~/.local/share/idriz/heroes.json` da borbe budu +dosljedne kroz partije. Za prepoznavanje likova koristi se najjači tekstualni model +(`game_model`, default `gpt-5.5`) i njegovo znanje; web search (Responses API) samo +kad model ne zna lika. + +- `idriz/gamestore.py` — trajno skladište (heroji, Elo, broj rundi), bez vanjskih zavisnosti. +- `idriz/game.py` — prepoznavanje likova, presuđivanje, tok partije (`Match`). +- Broj rundi i rang-lista heroja se podešavaju u admin panelu (tab "Superheroji"). diff --git a/admin/app.py b/admin/app.py index d2adb55..3d64c35 100644 --- a/admin/app.py +++ b/admin/app.py @@ -7,14 +7,20 @@ Prijava ide preko PAM-a (Linux korisnik `idriz`). import os import secrets import subprocess +import sys from datetime import datetime from functools import wraps +from pathlib import Path import pam as pam_module from flask import ( Flask, render_template, request, redirect, url_for, session, flash ) +# Pristup skladištu igre (bez openai zavisnosti) iz glavnog idriz paketa. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from idriz import gamestore # noqa: E402 + # --- Konfiguracija ----------------------------------------------------------- # Samo ovaj Linux korisnik smije da se prijavi. @@ -195,6 +201,37 @@ def status(): ) +@app.route("/superheroji", methods=["GET", "POST"]) +@login_required +def superheroji(): + if request.method == "POST": + action = request.form.get("action") + if action == "rounds": + try: + n = int(request.form.get("rounds", "")) + saved = gamestore.save_rounds(n) + flash(f"Broj rundi postavljen na {saved}.") + except (ValueError, TypeError): + flash("Neispravan broj rundi.") + elif action == "reset": + store = gamestore.HeroStore() + store.heroes = {} + store.aliases = {} + store.save() + flash("Pamćenje jačina heroja je obrisano.") + return redirect(url_for("superheroji")) + + store = gamestore.HeroStore() + return render_template( + "superheroji.html", + rounds=gamestore.load_rounds(), + min_rounds=gamestore.MIN_ROUNDS, + max_rounds=gamestore.MAX_ROUNDS, + heroes=store.leaderboard(), + active_page="superheroji", + ) + + if __name__ == "__main__": from waitress import serve print(f"Idriz admin na http://{LISTEN_HOST}:{LISTEN_PORT}") diff --git a/admin/templates/base.html b/admin/templates/base.html index 0a27a15..f17ec47 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -98,6 +98,9 @@ ● Status servisa + + ★ Superheroji +
Podešavanje igre i pamćenje relativnih jačina likova.
+ +Koliko rundi traje jedna partija ({{ min_rounds }}–{{ max_rounds }}). Primjenjuje se na sljedeću partiju.
+ +Snaga (rejting) raste kad heroj pobjeđuje. Idriz je koristi da borbe budu dosljedne kroz partije.
+ {% if heroes %} +| # | Heroj | Snaga | Pobjede | Porazi | Borbi |
|---|---|---|---|---|---|
| {{ loop.index }} | +{{ h.name }} {% if h.source == 'web' %}(web){% endif %} | +{{ h.rating }} | +{{ h.wins }} | +{{ h.losses }} | +{{ h.games }} | +
Još nema odigranih borbi. Rang-lista se puni kako djeca igraju.
+ {% endif %} +{% endblock %} diff --git a/idriz/assistant.py b/idriz/assistant.py index bcf1e28..2ca4f71 100644 --- a/idriz/assistant.py +++ b/idriz/assistant.py @@ -9,7 +9,7 @@ from pathlib import Path from openai import AsyncOpenAI -from . import audio, coloring, printer, render, textgen, wiki +from . import audio, coloring, game, printer, render, textgen, wiki from .audio import Mic, Speaker from .config import Config @@ -23,6 +23,17 @@ Govoriš toplo, kratko i jasno, kao strpljivi stariji brat. Rečenice su kratke. 1. RAZGOVARATI I ODGOVARATI NA PITANJA. Dijete te može pitati bilo šta: za školu, o svijetu, o životinjama, svemiru, historiji, matematici, riječima... Odgovaraš iz svog znanja, glasom, bez štampanja. 2. Odštampati sažetak o nekoj temi. Tražiš članak na engleskoj Wikipediji, prevedeš ga na bosanski i odštampaš sažetak do dvije stranice. Alat: wikipedia_sazetak. 3. Odštampati bojanku: crno-bijelu sliku sa konturama koju dijete može bojiti. Alat: bojanka. +4. IGRATI IGRU "SUPERHEROJI" (glasom, bez štampanja). Alati: superheroji_nova_partija, superheroji_izbor, superheroji_presudi, superheroji_kraj. + +Kako se igra Superheroji: +- NE objašnjavaj pravila na početku. Pretpostavi da djeca znaju igrati. Pravila objasni samo ako te izričito pitaju. +- Kad djeca kažu da žele igrati: prvo pitaj koliko igrača igra (od 1 do 5), pa ih pitaj za imena. Onda pozovi superheroji_nova_partija sa listom imena. Alat ti vrati broj rundi i redoslijed biranja za prvu rundu. +- U svakoj rundi traži od igrača, TAČNO onim redoslijedom koji ti je alat dao, da svaki kaže jednog superheroja. Definicija superheroja je široka: bilo koji izmišljeni lik iz bilo kog medija (Supermen, Mario, La Linea, Mad Max, Gandalf...). Zadnji igrač ima prednost jer bira zadnji, zato se redoslijed mijenja svaku rundu — uvijek koristi redoslijed koji ti alat da. +- Čim igrač kaže heroja, pozovi superheroji_izbor (igrac, heroj). Alat potvrdi ili odbije (npr. ako je taj heroj već pobijedio ranije u partiji, ili je već izabran u rundi). Ako je odbijen, kratko reci zašto i traži od tog igrača drugog heroja. Ako je prihvaćen, kratko potvrdi ("Supermen, zabilježeno!") i pitaj sljedećeg igrača. +- Kad su SVI igrači u rundi izabrali, pozovi superheroji_presudi. Alat ti da pobjednika runde i obrazloženje. Objavi to djeci veselo: reci ko je pobijedio i zašto. Pobjednički heroj se više NE može koristiti do kraja te partije (o tome brine alat). +- Alat ti kaže da li slijedi nova runda (i s kojim redoslijedom) ili je partija gotova. Kad je gotova, objavi konačnog pobjednika partije i čestitaj. +- Ako djeca žele stati ranije, pozovi superheroji_kraj. +- Za igru ne treba potvrda kao za štampanje — samo lijepo vodi igru. Kako odgovaraš na pitanja: - Prvi odgovor je kratak: dvije do četiri rečenice, najvažnije prvo. Zatim možeš pitati "Hoćeš da ti kažem još?" ili "Je li ti jasno?". @@ -76,8 +87,47 @@ TOOLS = [ "required": ["opis", "opis_bosanski"], }, }, + { + "type": "function", + "name": "superheroji_nova_partija", + "description": "Započinje novu partiju igre Superheroji. Pozvati NAKON što si pitao koliko igrača (1-5) i njihova imena. Vraća broj rundi i redoslijed biranja za prvu rundu.", + "parameters": { + "type": "object", + "properties": { + "igraci": {"type": "array", "items": {"type": "string"}, "description": "Imena igrača redom, od 1 do 5."}, + }, + "required": ["igraci"], + }, + }, + { + "type": "function", + "name": "superheroji_izbor", + "description": "Prijavljuje heroja kojeg je jedan igrač izabrao u trenutnoj rundi. Pozvati čim igrač kaže ime lika. Vraća potvrdu ili razlog odbijanja.", + "parameters": { + "type": "object", + "properties": { + "igrac": {"type": "string", "description": "Ime igrača koji bira."}, + "heroj": {"type": "string", "description": "Ime lika kako ga je igrač rekao."}, + }, + "required": ["igrac", "heroj"], + }, + }, + { + "type": "function", + "name": "superheroji_presudi", + "description": "Kad su SVI igrači u rundi izabrali heroja, presuđuje ko pobjeđuje i zašto. Vraća pobjednika, obrazloženje i šta dalje (nova runda ili kraj partije).", + "parameters": {"type": "object", "properties": {}}, + }, + { + "type": "function", + "name": "superheroji_kraj", + "description": "Prekida trenutnu partiju ranije ako igrači žele stati. Vraća trenutni rezultat.", + "parameters": {"type": "object", "properties": {}}, + }, ] +GAME_TOOLS = {"superheroji_nova_partija", "superheroji_izbor", "superheroji_presudi", "superheroji_kraj"} + def _slug(s: str) -> str: s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower() @@ -96,6 +146,7 @@ class Assistant: self.current_response = "" self.response_active = False self.cancelled_responses: set[str] = set() + self.match: game.Match | None = None # ---------- alati ---------- async def tool_wikipedia(self, args: dict) -> str: @@ -144,6 +195,89 @@ class Assistant: await printer.print_file(out, self.cfg.printer, self.cfg.dry_run, fit=True) return f"Bojanka '{subject_bs}' je odštampana." + # ---------- igra Superheroji ---------- + async def game_nova(self, args: dict) -> str: + players = args.get("igraci") or [] + if isinstance(players, str): + players = [players] + self.match = game.Match(self.client, self.cfg.game_model, players) + info = self.match.start_info() + order = ", ".join(info["redoslijed"]) + prvi = info["redoslijed"][0] + return (f"Partija je počela. Igrači: {', '.join(info['igraci'])}. Broj rundi: {info['broj_rundi']}. " + f"RUNDA 1 — redoslijed biranja: {order}. " + f"Traži od igrača {prvi} da kaže svog superheroja, pa redom ostale tim redoslijedom. " + f"Kad ti igrač kaže heroja, pozovi superheroji_izbor.") + + async def game_izbor(self, args: dict) -> str: + if not self.match: + return "Nema aktivne partije. Prvo pokreni igru sa superheroji_nova_partija." + res = await self.match.record_pick(args.get("igrac", ""), args.get("heroj", "")) + if res.get("ok"): + return (f"{res['poruka']} Kratko potvrdi to djetetu i pitaj sljedećeg igrača po redoslijedu. " + f"Kad SVI igrači u rundi izaberu, pozovi superheroji_presudi.") + return res.get("poruka", "Taj izbor ne može proći, neka igrač izabere drugog heroja.") + + async def game_presudi(self, args: dict) -> str: + if not self.match: + return "Nema aktivne partije. Prvo pokreni igru sa superheroji_nova_partija." + res = await self.match.resolve_round() + if not res.get("ok"): + return res.get("poruka", "Runda još nije spremna za presudu.") + txt = (f"REZULTAT RUNDE {res['runda']}: pobjeđuje {res['pobjednik_heroj']} " + f"(izabrao ga {res['pobjednik_igrac']}). Obrazloženje: {res['obrazlozenje']} " + f"Objavi ovo djeci veselo — ko je pobijedio i zašto. " + f"Napomeni da se {res['pobjednik_heroj']} više ne može birati u ovoj partiji.") + if res.get("gotovo"): + st = res["konacni_rezultat"] + poredak = ", ".join(f"{r['igrac']} {r['pobjede']}" for r in st["poredak"]) + if st["nerijeseno"]: + txt += f" PARTIJA JE GOTOVA! Neriješeno je (poredak: {poredak}). Čestitaj svima." + else: + txt += f" PARTIJA JE GOTOVA! Pobjednik partije je {st['pobjednici'][0]}! Poredak: {poredak}. Čestitaj pobjedniku." + self.match = None + else: + order = ", ".join(res["sljedeci_redoslijed"]) + prvi = res["sljedeci_redoslijed"][0] + txt += (f" Zatim kreće RUNDA {res['sljedeca_runda']} — redoslijed: {order}. " + f"Traži od igrača {prvi} novog heroja.") + return txt + + async def game_kraj(self, args: dict) -> str: + if not self.match: + return "Trenutno nema aktivne partije." + st = self.match.standings() + poredak = ", ".join(f"{r['igrac']} {r['pobjede']}" for r in st["poredak"]) + self.match = None + if st["pobjednici"] and not st["nerijeseno"]: + return f"Partija je prekinuta. Do sada vodi {st['pobjednici'][0]}. Poredak: {poredak}. Reci to djeci." + return f"Partija je prekinuta. Poredak: {poredak}. Reci to djeci." + + async def run_game_tool(self, name: str, call_id: str, args: dict) -> None: + """Igra: izračunaj rezultat pa ga odmah vrati modelu da ga izgovori.""" + try: + if name == "superheroji_nova_partija": + result = await self.game_nova(args) + elif name == "superheroji_izbor": + result = await self.game_izbor(args) + elif name == "superheroji_presudi": + result = await self.game_presudi(args) + elif name == "superheroji_kraj": + result = await self.game_kraj(args) + else: + result = f"Nepoznat alat {name}." + except Exception as e: # noqa: BLE001 + log.exception("igra: alat %s nije uspio", name) + result = f"Došlo je do greške u igri ({type(e).__name__}). Reci djeci da probamo ponovo." + try: + await self.conn.send({ + "type": "conversation.item.create", + "item": {"type": "function_call_output", "call_id": call_id, "output": result}, + }) + await self.conn.send({"type": "response.create"}) + except Exception: # noqa: BLE001 + log.exception("nisam uspio vratiti rezultat igre modelu") + async def run_tool(self, name: str, call_id: str, args: dict) -> None: """Pozadinski posao; kad završi, obavijesti model porukom i zatraži novi odgovor.""" try: @@ -203,6 +337,12 @@ class Assistant: except json.JSONDecodeError: args = {} log.info("alat %s(%s)", name, args) + if name in GAME_TOOLS: + # igra: brzo, rezultat ide odmah nazad modelu (bez "radim na tome") + task = asyncio.create_task(self.run_game_tool(name, call_id, args)) + self.jobs.add(task) + task.add_done_callback(self.jobs.discard) + return task = asyncio.create_task(self.run_tool(name, call_id, args)) self.jobs.add(task) task.add_done_callback(self.jobs.discard) diff --git a/idriz/config.py b/idriz/config.py index 619eb18..ac89a37 100644 --- a/idriz/config.py +++ b/idriz/config.py @@ -13,6 +13,7 @@ class Config: openai_api_key: str = field(default_factory=lambda: _env("OPENAI_API_KEY", "")) realtime_model: str = field(default_factory=lambda: _env("IDRIZ_REALTIME_MODEL", "gpt-realtime-2.1")) text_model: str = field(default_factory=lambda: _env("IDRIZ_TEXT_MODEL", "gpt-5.5")) + game_model: str = field(default_factory=lambda: _env("IDRIZ_GAME_MODEL", "")) image_model: str = field(default_factory=lambda: _env("IDRIZ_IMAGE_MODEL", "gpt-image-2")) transcription_model: str = field(default_factory=lambda: _env("IDRIZ_TRANSCRIPTION_MODEL", "gpt-4o-mini-transcribe")) voice: str = field(default_factory=lambda: _env("IDRIZ_VOICE", "cedar")) @@ -28,6 +29,9 @@ class Config: def __post_init__(self) -> None: self.out_dir.mkdir(parents=True, exist_ok=True) + # "najjači LLM koji imamo" — po defaultu isti kao za tekst (gpt-5.5) + if not self.game_model: + self.game_model = self.text_model def load() -> Config: diff --git a/idriz/game.py b/idriz/game.py new file mode 100644 index 0000000..0cc8c28 --- /dev/null +++ b/idriz/game.py @@ -0,0 +1,290 @@ +"""Igra 'Superheroji': prepoznavanje likova (najjači LLM + web search kad ne zna), +presuđivanje borbe i pamćenje relativnih jačina kroz partije.""" +import asyncio +import json +import logging + +from openai import AsyncOpenAI + +from . import gamestore, wiki +from .gamestore import HeroStore, normalize_key + +log = logging.getLogger("idriz.game") + +RESOLVE_SYS = """Ti si stručnjak za izmišljene likove iz SVIH medija: stripovi, filmovi, crtani, video igre, \ +knjige, mitologija, memovi. Dobiješ ime lika kako ga je izgovorilo dijete (može biti na bosanskom, sa greškom u \ +izgovoru ili kao nadimak). Pojam 'superheroj' ovdje je vrlo širok: bilo koji izmišljeni lik (npr. Supermen, Betmen, \ +Mario, La Linea, Mad Max, Gandalf, Pikaču, Baba Jaga, Goku). +Ako prepoznaš o kojem se liku radi, vrati JSON: +{"poznat": true, + "ime": "uobičajeno ime lika na bosanskom ako postoji (npr. Supermen, Betmen, Spajdermen), inače originalno ime", + "original": "originalno/englesko ime lika", + "opis": "jedna do dvije kratke rečenice na bosanskom: ko je lik i koje su mu glavne moći ili sposobnosti"} +Ako NISI siguran ko je lik ili ga ne prepoznaješ, vrati {"poznat": false}. +Odgovori ISKLJUČIVO JSON objektom.""" + +ADJ_SYS = """Ti si pošten i zabavan sudija u dječijoj igri 'Superheroji'. Dobiješ listu izmišljenih likova sa kratkim \ +opisima, a gdje postoji i njihovu dosadašnju snagu (rejting iz ranijih borbi; veći broj = pokazao se jačim). +Zamisli borbu svih protiv svih i odluči KO BI POBIJEDIO, te objasni ZAŠTO — kratko, pošteno i djetetu razumljivo, \ +na bosanskom jeziku (2 do 4 rečenice, vedro). +VAŽNO ZA DOSLJEDNOST: ako je neki lik u ranijim borbama pokazao veću snagu (veći rejting), on ne smije izgubiti od \ +znatno slabijeg lika bez izuzetno jakog razloga. Poštuj ranije ishode da igra bude fer. +Vrati JSON: {"pobjednik": "tačno ime pobjednika iz date liste", "razlog": "obrazloženje na bosanskom"}.""" + + +async def _web_search(client: AsyncOpenAI, model: str, name: str) -> str: + """Kratak opis lika sa weba — koristi se SAMO kad LLM ne zna lika.""" + try: + resp = await client.responses.create( + model=model, + tools=[{"type": "web_search"}], + input=(f"Ko je izmišljeni lik '{name}'? Iz kojeg je djela (film, crtani, strip, igra, knjiga)? " + f"Ukratko opiši lika i njegove moći ili sposobnosti."), + ) + text = (resp.output_text or "").strip() + if text: + log.info("web search za %r: %d znakova", name, len(text)) + return text + except Exception: # noqa: BLE001 + log.exception("web search (responses API) nije uspio za %r", name) + # rezervni izvor: engleska Wikipedia + try: + art = await asyncio.to_thread(wiki.find_article, name) + if art: + return f"{art.title}: {art.text[:800]}" + except Exception: # noqa: BLE001 + log.exception("wiki fallback nije uspio za %r", name) + return "" + + +async def resolve_hero(client: AsyncOpenAI, model: str, store: HeroStore, spoken: str) -> dict | None: + """Prepoznaj lika. Vrati {key, name, desc, source, known} ili None ako se nikako ne može prepoznati. + + Prvo gleda keš (ranije viđena imena). Zatim trenirano znanje LLM-a. Web search tek ako LLM ne zna. + """ + spoken = (spoken or "").strip() + if not spoken: + return None + + cached_key = store.resolve_alias(spoken) + if cached_key and store.get(cached_key): + h = store.get(cached_key) + return {"key": cached_key, "name": h["name"], "desc": h.get("desc", ""), + "source": h.get("source", "llm"), "known": True} + + async def _ask(extra: str = "") -> dict: + content = f"Ime lika: {spoken}" + if extra: + content += f"\n\nDodatne informacije sa weba (koristi ih):\n{extra}" + resp = await client.chat.completions.create( + model=model, + messages=[{"role": "system", "content": RESOLVE_SYS}, {"role": "user", "content": content}], + response_format={"type": "json_object"}, + ) + try: + return json.loads(resp.choices[0].message.content or "{}") + except json.JSONDecodeError: + return {} + + data = await _ask() + source = "llm" + if not data.get("poznat"): + # LLM ne zna lika -> web search, pa ponovo pitaj sa kontekstom + snippet = await _web_search(client, model, spoken) + if snippet: + data = await _ask(snippet) + source = "web" + if not data.get("poznat") and snippet: + # i dalje "nesiguran", ali imamo web opis — prihvati sa izgovorenim imenom + data = {"poznat": True, "ime": spoken.title(), "original": "", + "opis": snippet.split("\n")[0][:200]} + source = "web" + + if not data.get("poznat"): + return None + + name = (data.get("ime") or spoken).strip() + original = (data.get("original") or "").strip() + desc = (data.get("opis") or "").strip() + key = normalize_key(original or name) + if not key: + key = normalize_key(spoken) + h = store.upsert(key, name, original, desc, source) + store.add_alias(spoken, key) + store.add_alias(name, key) + if original: + store.add_alias(original, key) + store.save() + return {"key": key, "name": h["name"], "desc": h.get("desc", ""), "source": source, "known": source == "llm"} + + +async def adjudicate(client: AsyncOpenAI, model: str, store: HeroStore, picks: list[dict]) -> dict: + """picks: lista {key, name, desc}. Vrati {winner_key, winner_name, reason}.""" + if len(picks) == 1: + p = picks[0] + return {"winner_key": p["key"], "winner_name": p["name"], + "reason": f"{p['name']} je jedini u ovoj rundi, pa pobjeđuje bez borbe!"} + + keys = [p["key"] for p in picks] + by_key = {p["key"]: p for p in picks} + favorite = store.clear_favorite(keys) + + lines = [] + for p in picks: + h = store.get(p["key"]) or {} + rating = round(h.get("rating", gamestore.BASE_RATING)) + games = h.get("games", 0) + snaga = f", dosadašnja snaga {rating} iz {games} borbi" if games else ", novi izazivač" + lines.append(f"- {p['name']}: {p.get('desc','(bez opisa)')}{snaga}") + listing = "\n".join(lines) + + if favorite: + fav_name = by_key[favorite]["name"] + user = (f"Likovi u borbi:\n{listing}\n\n" + f"Po dosadašnjim borbama najjači je: {fav_name}. On je pobjednik. " + f"Napiši SAMO obrazloženje zašto {fav_name} pobjeđuje ostale, pošteno i djetetu razumljivo.") + else: + user = f"Likovi u borbi:\n{listing}\n\nOdluči ko pobjeđuje i zašto." + + resp = await client.chat.completions.create( + model=model, + messages=[{"role": "system", "content": ADJ_SYS}, {"role": "user", "content": user}], + response_format={"type": "json_object"}, + ) + try: + data = json.loads(resp.choices[0].message.content or "{}") + except json.JSONDecodeError: + data = {} + + reason = (data.get("razlog") or "").strip() + if favorite: + winner_key = favorite + else: + # upari ime pobjednika koje je LLM vratio sa našim ključevima + wname = (data.get("pobjednik") or "").strip() + wkey = normalize_key(wname) + winner_key = None + for p in picks: + if p["key"] == wkey or normalize_key(p["name"]) == wkey or wname.lower() in p["name"].lower(): + winner_key = p["key"] + break + if winner_key is None: + # fallback: najjači po rejtingu, pa prvi + winner_key = max(keys, key=lambda k: (store.get(k) or {}).get("rating", gamestore.BASE_RATING)) + + winner_name = by_key[winner_key]["name"] + if not reason: + reason = f"{winner_name} pobjeđuje u ovoj rundi!" + return {"winner_key": winner_key, "winner_name": winner_name, "reason": reason} + + +class Match: + """Stanje jedne partije (u memoriji asistenta).""" + + def __init__(self, client: AsyncOpenAI, model: str, players: list[str]) -> None: + self.client = client + self.model = model + self.store = HeroStore() + self.players = [p.strip() for p in players if p and p.strip()][:5] or ["Igrač 1"] + self.rounds = gamestore.load_rounds() + self.round_index = 0 # 0-bazirano, trenutna runda + self.banned: set[str] = set() # ključevi pobjedničkih heroja (ne smiju se ponoviti) + self.picks: dict[str, dict] = {} # igrač -> {key,name,desc} za trenutnu rundu + self.scores: dict[str, int] = {p: 0 for p in self.players} + self.history: list[dict] = [] # [{round, winner, player}] + + def order_for(self, round_index: int) -> list[str]: + """Svaka runda drugačiji redoslijed (rotacija) — svako jednom bude zadnji (prednost).""" + n = len(self.players) + if n == 0: + return [] + shift = round_index % n + return self.players[shift:] + self.players[:shift] + + @property + def current_order(self) -> list[str]: + return self.order_for(self.round_index) + + @property + def finished(self) -> bool: + return self.round_index >= self.rounds + + def start_info(self) -> dict: + return { + "igraci": self.players, + "broj_rundi": self.rounds, + "runda": 1, + "redoslijed": self.current_order, + } + + async def record_pick(self, player: str, hero: str) -> dict: + player = (player or "").strip() + # upari igrača (tolerantno na sitne razlike) + if player not in self.scores: + match = next((p for p in self.players if p.lower() == player.lower()), None) + player = match or (self.players[0] if len(self.players) == 1 else player) + info = await resolve_hero(self.client, self.model, self.store, hero) + if info is None: + return {"ok": False, "razlog": "nepoznat", + "poruka": f"Ne mogu prepoznati lika '{hero}'. Neka {player} izabere drugog."} + if info["key"] in self.banned: + return {"ok": False, "razlog": "iskorišten", + "poruka": f"{info['name']} je već pobijedio ranije u ovoj partiji i ne može se ponovo koristiti. " + f"Neka {player} izabere drugog heroja."} + if any(pk["key"] == info["key"] for pl, pk in self.picks.items() if pl != player): + return {"ok": False, "razlog": "duplikat", + "poruka": f"{info['name']} je već izabran u ovoj rundi. Neka {player} izabere drugog."} + self.picks[player] = info + return {"ok": True, "igrac": player, "heroj": info["name"], "poruka": f"{info['name']} je zabilježen za {player}."} + + async def resolve_round(self) -> dict: + if not self.picks: + return {"ok": False, "poruka": "Još niko nije izabrao heroja u ovoj rundi."} + # heroji poredani po redoslijedu igranja + order = self.current_order + participants = [self.picks[p] for p in order if p in self.picks] + for p, info in self.picks.items(): + if info not in participants: + participants.append(info) + + verdict = await adjudicate(self.client, self.model, self.store, participants) + winner_key = verdict["winner_key"] + loser_keys = [pk["key"] for pk in participants if pk["key"] != winner_key] + self.store.record_match(winner_key, loser_keys) + self.store.save() + self.banned.add(winner_key) + + # koji igrač je izabrao pobjednika + winner_player = next((pl for pl, pk in self.picks.items() if pk["key"] == winner_key), None) + if winner_player: + self.scores[winner_player] = self.scores.get(winner_player, 0) + 1 + + played_round = self.round_index + 1 + self.history.append({"runda": played_round, "pobjednik": verdict["winner_name"], "igrac": winner_player}) + self.round_index += 1 + self.picks = {} + + result = { + "ok": True, + "runda": played_round, + "pobjednik_heroj": verdict["winner_name"], + "pobjednik_igrac": winner_player, + "obrazlozenje": verdict["reason"], + "gotovo": self.finished, + } + if self.finished: + result["konacni_rezultat"] = self.standings() + else: + result["sljedeca_runda"] = self.round_index + 1 + result["sljedeci_redoslijed"] = self.current_order + return result + + def standings(self) -> dict: + table = sorted(self.scores.items(), key=lambda kv: kv[1], reverse=True) + top = table[0][1] if table else 0 + winners = [p for p, s in table if s == top and top > 0] + return { + "poredak": [{"igrac": p, "pobjede": s} for p, s in table], + "pobjednici": winners, + "nerijeseno": len(winners) != 1, + } diff --git a/idriz/gamestore.py b/idriz/gamestore.py new file mode 100644 index 0000000..53fd406 --- /dev/null +++ b/idriz/gamestore.py @@ -0,0 +1,175 @@ +"""Trajno skladište za igru Superheroji: heroji, njihove relativne jačine (Elo) i konfiguracija. + +Namjerno bez vanjskih zavisnosti (samo standardna biblioteka) da ga može uvesti +i admin panel (koji nema instaliran openai). LLM logika je u game.py. +""" +import json +import os +import re +import tempfile +import threading +import unicodedata +from pathlib import Path + +HEROES_PATH = Path(os.environ.get("IDRIZ_HEROES_PATH", str(Path.home() / ".local/share/idriz/heroes.json"))) +GAME_CONFIG_PATH = Path(os.environ.get("IDRIZ_GAME_CONFIG_PATH", str(Path.home() / ".config/idriz/game.json"))) + +DEFAULT_ROUNDS = 3 +MIN_ROUNDS = 1 +MAX_ROUNDS = 15 +BASE_RATING = 1000.0 +K = 32.0 # Elo K-faktor +CLEAR_LEAD = 80.0 # razlika u rejtingu koja se smatra "jasnom" prednošću iz prošlih borbi + +_lock = threading.Lock() + + +def normalize_key(name: str) -> str: + """Ime -> ključ za dedup (bez dijakritike, mala slova, samo slova/brojevi).""" + s = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode() + return re.sub(r"[^a-z0-9]+", "", s.lower()) + + +def _atomic_write(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + + +# ---------------- Konfiguracija (broj rundi) ---------------- + +def load_rounds() -> int: + try: + data = json.loads(GAME_CONFIG_PATH.read_text(encoding="utf-8")) + n = int(data.get("rounds", DEFAULT_ROUNDS)) + except (OSError, ValueError, json.JSONDecodeError): + return DEFAULT_ROUNDS + return max(MIN_ROUNDS, min(MAX_ROUNDS, n)) + + +def save_rounds(n: int) -> int: + n = max(MIN_ROUNDS, min(MAX_ROUNDS, int(n))) + with _lock: + data = {} + try: + data = json.loads(GAME_CONFIG_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = {} + data["rounds"] = n + _atomic_write(GAME_CONFIG_PATH, data) + return n + + +# ---------------- Skladište heroja ---------------- + +class HeroStore: + def __init__(self, path: Path = HEROES_PATH) -> None: + self.path = path + self.heroes: dict[str, dict] = {} + self.aliases: dict[str, str] = {} + self.load() + + def load(self) -> None: + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + self.heroes = data.get("heroes", {}) + self.aliases = data.get("aliases", {}) + except (OSError, json.JSONDecodeError): + self.heroes = {} + self.aliases = {} + + def save(self) -> None: + with _lock: + _atomic_write(self.path, {"heroes": self.heroes, "aliases": self.aliases}) + + def resolve_alias(self, spoken: str) -> str | None: + """Vrati kanonski ključ za izgovoreno ime ako smo ga ranije već sreli.""" + key = normalize_key(spoken) + if key in self.heroes: + return key + return self.aliases.get(key) + + def get(self, key: str) -> dict | None: + return self.heroes.get(key) + + def upsert(self, key: str, name: str, original: str = "", desc: str = "", source: str = "llm") -> dict: + h = self.heroes.get(key) + if h is None: + h = { + "name": name, "original": original, "desc": desc, + "rating": BASE_RATING, "wins": 0, "losses": 0, "games": 0, "source": source, + } + self.heroes[key] = h + else: + # ne gazi postojeći rejting; osvježi opis ako je bolji + if desc and len(desc) > len(h.get("desc", "")): + h["desc"] = desc + if name: + h["name"] = name + if original: + h["original"] = original + return h + + def add_alias(self, spoken: str, canonical_key: str) -> None: + key = normalize_key(spoken) + if key and key != canonical_key: + self.aliases[key] = canonical_key + + def record_match(self, winner_key: str, loser_keys: list[str]) -> None: + """Elo ažuriranje: pobjednik pobjeđuje svakog gubitnika (parno).""" + w = self.heroes.get(winner_key) + if not w: + return + for lk in loser_keys: + l = self.heroes.get(lk) + if not l or lk == winner_key: + continue + rw, rl = w["rating"], l["rating"] + ew = 1.0 / (1.0 + 10 ** ((rl - rw) / 400.0)) + w["rating"] = rw + K * (1.0 - ew) + l["rating"] = rl + K * (0.0 - (1.0 - ew)) + l["losses"] = l.get("losses", 0) + 1 + w["wins"] = w.get("wins", 0) + 1 + for lk in set(loser_keys) | {winner_key}: + h = self.heroes.get(lk) + if h: + h["games"] = h.get("games", 0) + 1 + + def clear_favorite(self, keys: list[str]) -> str | None: + """Ako među likovima postoji jedan koji je po prošlim borbama JASNO najjači, vrati njegov ključ. + + Uslov: ima odigranih borbi i vodi bar CLEAR_LEAD rejtinga ispred sljedećeg. + """ + rated = [(k, self.heroes[k]) for k in keys if k in self.heroes and self.heroes[k].get("games", 0) > 0] + if len(rated) < 1: + return None + rated.sort(key=lambda kv: kv[1]["rating"], reverse=True) + top_key, top = rated[0] + # rejting svih ostalih (uključujući nerangirane koji imaju BASE_RATING) + others = [self.heroes.get(k, {}).get("rating", BASE_RATING) for k in keys if k != top_key] + if not others: + return None + if top["rating"] - max(others) >= CLEAR_LEAD: + return top_key + return None + + def leaderboard(self) -> list[dict]: + rows = [] + for key, h in self.heroes.items(): + rows.append({ + "key": key, + "name": h.get("name", key), + "rating": round(h.get("rating", BASE_RATING)), + "wins": h.get("wins", 0), + "losses": h.get("losses", 0), + "games": h.get("games", 0), + "source": h.get("source", "llm"), + }) + rows.sort(key=lambda r: r["rating"], reverse=True) + return rows