Dodaj igru Superheroji (glasovna) + admin tab
Igra: 1-5 igrača, nekoliko rundi (konfigurabilno). Svaku rundu svaki igrač kaže izmišljenog lika (širok pojam — bilo koji medij), Idriz presudi ko bi pobijedio i zašto. Pobjednik runde se banuje do kraja partije. Redoslijed se rotira svaku rundu (zadnji ima prednost). Najviše pobjeda runda = pobjednik partije. - idriz/gamestore.py: trajno skladište (heroji, Elo rejting, broj rundi), bez vanjskih zavisnosti (i admin ga uvozi). clear_favorite() drži borbe dosljedne kroz partije (jači heroj ne gubi od znatno slabijeg). - idriz/game.py: resolve_hero (najjači LLM gpt-5.5 + web search fallback samo kad ne zna lika), adjudicate (presuda + obrazloženje na bosanskom), Match (tok partije). - assistant.py: 4 realtime alata (nova_partija, izbor, presudi, kraj) + upute; igra ide odmah nazad modelu, ne kroz pozadinski posao. - config.py: game_model (default = text_model). - admin: tab "Superheroji" (broj rundi + rang-lista + reset pamćenja). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
This commit is contained in:
15
README.md
15
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").
|
||||
|
||||
37
admin/app.py
37
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}")
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
<a href="{{ url_for('status') }}" class="{{ 'active' if active_page == 'status' else '' }}">
|
||||
<span class="icon">●</span> Status servisa
|
||||
</a>
|
||||
<a href="{{ url_for('superheroji') }}" class="{{ 'active' if active_page == 'superheroji' else '' }}">
|
||||
<span class="icon">★</span> Superheroji
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
{% if session.user %}<a href="{{ url_for('logout') }}">↩ Odjava</a>{% endif %}
|
||||
|
||||
66
admin/templates/superheroji.html
Normal file
66
admin/templates/superheroji.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Superheroji — Idriz Admin{% endblock %}
|
||||
{% block extra_css %}
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 20px 22px; margin-bottom: 22px; max-width: 640px; }
|
||||
.panel h2 { margin: 0 0 4px; font-size: 16px; }
|
||||
.panel p.hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
|
||||
.row { display: flex; align-items: center; gap: 12px; }
|
||||
input[type=number] { width: 90px; padding: 9px 12px; font-size: 15px;
|
||||
border: 1px solid var(--line); border-radius: 8px; }
|
||||
button { padding: 9px 18px; font-size: 14px; font-weight: 600; color: #fff;
|
||||
background: var(--accent); border: none; border-radius: 8px; cursor: pointer; }
|
||||
button:hover { filter: brightness(1.05); }
|
||||
button.danger { background: var(--bad); }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: 10px; overflow: hidden; max-width: 640px; }
|
||||
th, td { text-align: left; padding: 10px 14px; font-size: 14px; }
|
||||
th { background: #fafbfc; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--line); }
|
||||
tr:not(:last-child) td { border-bottom: 1px solid var(--line); }
|
||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.rank { color: var(--muted); width: 30px; }
|
||||
.src { font-size: 11px; color: var(--muted); }
|
||||
.empty { color: var(--muted); font-style: italic; }
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Superheroji</h1>
|
||||
<p class="sub">Podešavanje igre i pamćenje relativnih jačina likova.</p>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Broj rundi po partiji</h2>
|
||||
<p class="hint">Koliko rundi traje jedna partija ({{ min_rounds }}–{{ max_rounds }}). Primjenjuje se na sljedeću partiju.</p>
|
||||
<form method="post" class="row">
|
||||
<input type="hidden" name="action" value="rounds">
|
||||
<input type="number" name="rounds" min="{{ min_rounds }}" max="{{ max_rounds }}" value="{{ rounds }}">
|
||||
<button type="submit">Sačuvaj</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:16px; margin: 0 0 4px;">Rang-lista heroja</h2>
|
||||
<p class="sub">Snaga (rejting) raste kad heroj pobjeđuje. Idriz je koristi da borbe budu dosljedne kroz partije.</p>
|
||||
{% if heroes %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th class="rank">#</th><th>Heroj</th><th class="num">Snaga</th><th class="num">Pobjede</th><th class="num">Porazi</th><th class="num">Borbi</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for h in heroes %}
|
||||
<tr>
|
||||
<td class="rank">{{ loop.index }}</td>
|
||||
<td>{{ h.name }} {% if h.source == 'web' %}<span class="src">(web)</span>{% endif %}</td>
|
||||
<td class="num">{{ h.rating }}</td>
|
||||
<td class="num">{{ h.wins }}</td>
|
||||
<td class="num">{{ h.losses }}</td>
|
||||
<td class="num">{{ h.games }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" onsubmit="return confirm('Obrisati sve zapamćene jačine heroja? Ovo se ne može poništiti.');" style="margin-top:16px;">
|
||||
<input type="hidden" name="action" value="reset">
|
||||
<button type="submit" class="danger">Obriši pamćenje</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="empty">Još nema odigranih borbi. Rang-lista se puni kako djeca igraju.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
290
idriz/game.py
Normal file
290
idriz/game.py
Normal file
@@ -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,
|
||||
}
|
||||
175
idriz/gamestore.py
Normal file
175
idriz/gamestore.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user