e1fca864d8
CI / compile (pull_request) Successful in 10s
CI / unit (pull_request) Successful in 14s
CI / integration (pull_request) Successful in 10s
build / build (push) Successful in 38s
CI / compile (push) Successful in 10s
CI / unit (push) Successful in 17s
CI / integration (push) Successful in 11s
Fits the mythology pillar of the persona (Slavic/Norse/Celtic, Old Norse phrases). New always-loaded cog oracle_commands: * $runy [pytanie] draws three Elder Futhark runes (past/present/future, with upright/reversed orientation - the 8 symmetric runes are never reversed) and asks the ACTIVE AI backend to read the spread in Conjurer's voice. If the AI is down it still shows the drawn runes with their own meanings, so the command always answers. * $runa_dnia gives one rune, deterministic per user per day (sha256 seed), so it's stable if asked repeatedly - no AI call, no state file. The full 24-rune Futhark, the draw logic and the reversal rules are pure and unit-tested (distinct draw, non-invertible never reversed, per-day stability, meaning fallback). Also fixes a pre-existing unit-job breakage: test_bar_commands and test_lore_commands each stubbed `discord` with different completeness and shared sys.modules, so once both landed on main the one lacking `discord.ext.tasks` shadowed the one needing it and collection failed order-dependently. A new tests/unit/conftest.py stubs discord once, completely, before any test module - the per-file stubs then skip. Full unit job: 41 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
8.7 KiB
Python
169 lines
8.7 KiB
Python
"""Wyrocznia Conjurera: rune readings in the bot's mythology voice.
|
||
|
||
The persona leans hard on Slavic/Norse/Celtic myth and drops Old Norse phrases,
|
||
so this fits like a glove: `$runy [pytanie]` draws three Elder Futhark runes
|
||
(with upright/reversed orientation) and asks the ACTIVE AI backend to read the
|
||
spread in Conjurer's voice; `$runa_dnia` gives a stable per-user, per-day rune.
|
||
|
||
The draw is pure and unit-tested. The AI is only the interpretation layer - if it
|
||
fails, the reading falls back to the runes' own canned meanings so the command
|
||
always answers.
|
||
"""
|
||
import hashlib
|
||
import logging
|
||
import random
|
||
from datetime import date
|
||
|
||
import discord
|
||
from discord.ext import commands
|
||
|
||
from ai_functions import handle_response
|
||
from constants import GPT_SETTINGS, GUILD_ID
|
||
|
||
# Elder Futhark. `odwrocone` empty ("") marks a non-invertible (symmetric) rune,
|
||
# which is therefore always drawn upright.
|
||
RUNES = [
|
||
{"symbol": "ᚠ", "nazwa": "Fehu", "znaczenie": "bogactwo, dobytek, początek", "odwrocone": "strata, chciwość, zahamowanie"},
|
||
{"symbol": "ᚢ", "nazwa": "Uruz", "znaczenie": "dzika siła, zdrowie, wola", "odwrocone": "słabość, choroba, przemoc"},
|
||
{"symbol": "ᚦ", "nazwa": "Thurisaz", "znaczenie": "ochrona, konflikt, olbrzym", "odwrocone": "zagrożenie, gniew, zdrada"},
|
||
{"symbol": "ᚨ", "nazwa": "Ansuz", "znaczenie": "mądrość, słowo, głos Odyna", "odwrocone": "kłamstwo, niezrozumienie, manipulacja"},
|
||
{"symbol": "ᚱ", "nazwa": "Raidho", "znaczenie": "podróż, ruch, właściwa droga", "odwrocone": "zastój, przymus, kryzys w drodze"},
|
||
{"symbol": "ᚲ", "nazwa": "Kenaz", "znaczenie": "ogień wiedzy, twórczość, natchnienie", "odwrocone": "ciemność, wypalenie, złudzenie"},
|
||
{"symbol": "ᚷ", "nazwa": "Gebo", "znaczenie": "dar, wymiana, więź", "odwrocone": ""},
|
||
{"symbol": "ᚹ", "nazwa": "Wunjo", "znaczenie": "radość, harmonia, spełnienie", "odwrocone": "smutek, zniechęcenie, niezgoda"},
|
||
{"symbol": "ᚺ", "nazwa": "Hagalaz", "znaczenie": "grad, gwałtowna zmiana, żywioł", "odwrocone": ""},
|
||
{"symbol": "ᚾ", "nazwa": "Nauthiz", "znaczenie": "potrzeba, ograniczenie, wytrwałość", "odwrocone": "desperacja, brak, przymus losu"},
|
||
{"symbol": "ᛁ", "nazwa": "Isa", "znaczenie": "lód, zastój, cierpliwość", "odwrocone": ""},
|
||
{"symbol": "ᛃ", "nazwa": "Jera", "znaczenie": "plon, cykl, sprawiedliwa nagroda", "odwrocone": ""},
|
||
{"symbol": "ᛇ", "nazwa": "Eihwaz", "znaczenie": "oś świata, przemiana, obrona", "odwrocone": ""},
|
||
{"symbol": "ᛈ", "nazwa": "Perthro", "znaczenie": "los, tajemnica, gra przypadku", "odwrocone": "rozczarowanie, ukryty sekret, pech"},
|
||
{"symbol": "ᛉ", "nazwa": "Algiz", "znaczenie": "ochrona, instynkt, tarcza", "odwrocone": "bezbronność, zagrożenie, utrata czujności"},
|
||
{"symbol": "ᛊ", "nazwa": "Sowilo", "znaczenie": "słońce, zwycięstwo, moc życia", "odwrocone": ""},
|
||
{"symbol": "ᛏ", "nazwa": "Tiwaz", "znaczenie": "honor, sprawiedliwość, wojownik Tyr", "odwrocone": "niesprawiedliwość, porażka, słabnąca wola"},
|
||
{"symbol": "ᛒ", "nazwa": "Berkano", "znaczenie": "wzrost, płodność, narodziny", "odwrocone": "stagnacja, jałowość, kryzys rodzinny"},
|
||
{"symbol": "ᛖ", "nazwa": "Ehwaz", "znaczenie": "koń, ruch, wierne partnerstwo", "odwrocone": "niepokój, zdrada, rozłam"},
|
||
{"symbol": "ᛗ", "nazwa": "Mannaz", "znaczenie": "człowiek, wspólnota, jaźń", "odwrocone": "izolacja, wróg, zaślepienie"},
|
||
{"symbol": "ᛚ", "nazwa": "Laguz", "znaczenie": "woda, intuicja, przepływ", "odwrocone": "lęk, chaos, utonięcie w emocjach"},
|
||
{"symbol": "ᛜ", "nazwa": "Ingwaz", "znaczenie": "nasienie, potencjał, spełnienie", "odwrocone": ""},
|
||
{"symbol": "ᛞ", "nazwa": "Dagaz", "znaczenie": "świt, przełom, przebudzenie", "odwrocone": ""},
|
||
{"symbol": "ᛟ", "nazwa": "Othala", "znaczenie": "dziedzictwo, dom, ród", "odwrocone": "bezdomność, uprzedzenie, zerwanie z korzeniami"},
|
||
]
|
||
|
||
_POSITIONS = ("Przeszłość", "Teraźniejszość", "Przyszłość")
|
||
_MAX_MSG = 1900
|
||
|
||
|
||
def _meaning(rune: dict, reversed_: bool) -> str:
|
||
return rune["odwrocone"] if (reversed_ and rune["odwrocone"]) else rune["znaczenie"]
|
||
|
||
|
||
def draw_runes(n: int, rng: random.Random):
|
||
"""Draw `n` distinct runes as (rune, reversed) pairs. Non-invertible runes
|
||
(empty 'odwrocone') are always upright."""
|
||
n = max(1, min(n, len(RUNES)))
|
||
picked = rng.sample(RUNES, n)
|
||
result = []
|
||
for rune in picked:
|
||
reversed_ = bool(rune["odwrocone"]) and rng.random() < 0.5
|
||
result.append((rune, reversed_))
|
||
return result
|
||
|
||
|
||
def _rng_for_day(user_id: int, day: str) -> random.Random:
|
||
"""Deterministic RNG per user per day (stable if asked repeatedly)."""
|
||
seed = int.from_bytes(hashlib.sha256(f"{user_id}:{day}".encode("utf-8")).digest()[:8], "big")
|
||
return random.Random(seed)
|
||
|
||
|
||
def format_draw(drawn) -> str:
|
||
lines = []
|
||
for i, (rune, reversed_) in enumerate(drawn):
|
||
pos = _POSITIONS[i] + ": " if i < len(_POSITIONS) and len(drawn) == len(_POSITIONS) else ""
|
||
orient = " (odwrócona)" if reversed_ else ""
|
||
lines.append(f"{rune['symbol']} {pos}**{rune['nazwa']}**{orient} — {_meaning(rune, reversed_)}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _persona() -> str:
|
||
try:
|
||
if isinstance(GPT_SETTINGS, list) and GPT_SETTINGS and isinstance(GPT_SETTINGS[0], dict):
|
||
return GPT_SETTINGS[0].get("content", "") or ""
|
||
except Exception: # pylint: disable=broad-except
|
||
pass
|
||
return "Jesteś Conjurerem, barmanem-wyrocznią baru mechawojowników. Mówisz po polsku, odnosisz się do mitologii."
|
||
|
||
|
||
class OracleModule(commands.Cog):
|
||
"""Rune readings."""
|
||
|
||
def __init__(self, bot):
|
||
self.bot = bot
|
||
self.logger = logging.getLogger("discord")
|
||
|
||
async def _send_chunked(self, ctx, text: str) -> None:
|
||
text = text or ""
|
||
while text:
|
||
await ctx.send(text[:_MAX_MSG])
|
||
text = text[_MAX_MSG:]
|
||
|
||
@commands.hybrid_command(
|
||
name="runy",
|
||
description="Conjurer rzuca trzy runy i wróży (opcjonalnie na Twoje pytanie)",
|
||
guild=discord.Object(id=GUILD_ID),
|
||
)
|
||
async def runy(self, ctx, *, pytanie: str = ""):
|
||
async with ctx.channel.typing():
|
||
drawn = draw_runes(3, random.Random()) # nosec B311 - divination flavour
|
||
display = format_draw(drawn)
|
||
pytanie = (pytanie or "").strip()
|
||
|
||
instructions = (
|
||
"Jesteś wyrocznią run. Rzuciłeś układ trzech run (przeszłość / teraźniejszość / "
|
||
"przyszłość):\n"
|
||
f"{display}\n\n"
|
||
f"Pytanie pytającego: {pytanie or '(brak - odczyt ogólny)'}\n\n"
|
||
"Zinterpretuj ten układ w swoim stylu, wiążąc runy ze sobą i z pytaniem. Zwięźle, "
|
||
"klimatycznie, po polsku, w postaci Conjurera."
|
||
)
|
||
prompt = [
|
||
{"role": "system", "content": _persona()},
|
||
{"role": "user", "content": instructions},
|
||
]
|
||
reading = ""
|
||
try:
|
||
reading, _ = await handle_response("", True, True, [], ctx.author.name, "NONE", none_request=prompt)
|
||
except Exception as exc: # pylint: disable=broad-except
|
||
self.logger.exception("runy: AI failed: %s", exc)
|
||
|
||
header = "*Conjurer wysypuje runy na blat baru:*\n" + display
|
||
if reading and reading.strip():
|
||
await self._send_chunked(ctx, header + "\n\n*Mruży oko i wykłada:*\n" + reading.strip())
|
||
else:
|
||
# AI down - the runes still speak for themselves.
|
||
await self._send_chunked(
|
||
ctx, header + "\n\n*Conjurer stuka w blat* Wyrocznia dziś małomówna - runy mówią same za siebie."
|
||
)
|
||
|
||
@commands.hybrid_command(
|
||
name="runa_dnia",
|
||
description="Twoja jedna runa na dziś (stała przez cały dzień)",
|
||
guild=discord.Object(id=GUILD_ID),
|
||
)
|
||
async def runa_dnia(self, ctx):
|
||
async with ctx.channel.typing():
|
||
today = date.today().isoformat()
|
||
rng = _rng_for_day(ctx.author.id, today)
|
||
rune, reversed_ = draw_runes(1, rng)[0]
|
||
orient = " (odwrócona)" if reversed_ else ""
|
||
name = ctx.author.nick if getattr(ctx.author, "nick", None) else ctx.author.name
|
||
await ctx.send(
|
||
f"*Conjurer sięga do skórzanego woreczka i wyciąga dla Ciebie, {name}, jedną runę na dziś:*\n"
|
||
f"{rune['symbol']} **{rune['nazwa']}**{orient} — {_meaning(rune, reversed_)}"
|
||
)
|
||
|
||
|
||
async def setup(bot):
|
||
logger = logging.getLogger("discord")
|
||
await bot.add_cog(OracleModule(bot))
|
||
logger.info("Loading oracle commands module done")
|