oracle: $runy / $runa_dnia — wróżby z run Starszego Futharku #7
@@ -93,6 +93,7 @@ CORE_EXTENSIONS = [
|
||||
"other_commands",
|
||||
"bar_commands",
|
||||
"lore_commands",
|
||||
"oracle_commands",
|
||||
"latex_commands",
|
||||
"voice_recognition_commands",
|
||||
"conanjurer_commands",
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Shared stubs for the unit tests.
|
||||
|
||||
Several bot modules under test import ``discord`` (and ``discord.ext.commands`` /
|
||||
``discord.ext.tasks``) at import time. The pytest-only unit CI job doesn't
|
||||
install discord, so the test modules stub it themselves - but each stubbed only
|
||||
the pieces IT needed, and because they share ``sys.modules`` the first one to run
|
||||
won (skip-if-present), leaving a later module that needs e.g. ``discord.ext.tasks``
|
||||
with an incomplete stub. That made collection order-dependent and flaky (e.g.
|
||||
test_bar_commands, whose stub omits ``tasks``, shadowing test_lore_commands,
|
||||
which needs it).
|
||||
|
||||
Stubbing ``discord`` here - once, completely, before any test module is imported
|
||||
- removes the ordering dependency. The per-file stubs then simply skip. Only
|
||||
``discord`` is stubbed centrally; ai_functions / communication_subroutine stay
|
||||
per-file because different modules legitimately want the real vs a stubbed one.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _ensure_discord_stub() -> None:
|
||||
if "discord" in sys.modules:
|
||||
return
|
||||
try: # real discord present (local dev) -> use it
|
||||
import discord # noqa: F401
|
||||
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
mod = types.ModuleType("discord")
|
||||
|
||||
class Object:
|
||||
def __init__(self, id=None): # noqa: A002 - mirrors discord.Object
|
||||
self.id = id
|
||||
|
||||
class Member:
|
||||
pass
|
||||
|
||||
mod.Object = Object
|
||||
mod.Member = Member
|
||||
|
||||
ext = types.ModuleType("discord.ext")
|
||||
commands = types.ModuleType("discord.ext.commands")
|
||||
tasks = types.ModuleType("discord.ext.tasks")
|
||||
|
||||
class Cog:
|
||||
pass
|
||||
|
||||
commands.Cog = Cog
|
||||
commands.hybrid_command = lambda **_kwargs: (lambda fn: fn)
|
||||
|
||||
def loop(**_kwargs):
|
||||
def decorator(fn):
|
||||
fn.before_loop = lambda f: f
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
tasks.loop = loop
|
||||
commands.tasks = tasks
|
||||
ext.commands = commands
|
||||
ext.tasks = tasks
|
||||
mod.ext = ext
|
||||
|
||||
sys.modules["discord"] = mod
|
||||
sys.modules["discord.ext"] = ext
|
||||
sys.modules["discord.ext.commands"] = commands
|
||||
sys.modules["discord.ext.tasks"] = tasks
|
||||
|
||||
|
||||
_ensure_discord_stub()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Unit tests for oracle_commands' pure rune-draw logic.
|
||||
|
||||
Heavy imports (discord, ai_functions) are stubbed only when genuinely absent,
|
||||
matching the pytest-only unit CI job.
|
||||
"""
|
||||
import random
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _stub_if_missing(name, build):
|
||||
if name in sys.modules:
|
||||
return
|
||||
try:
|
||||
__import__(name)
|
||||
except ImportError:
|
||||
sys.modules[name] = build()
|
||||
|
||||
|
||||
def _build_discord():
|
||||
mod = types.ModuleType("discord")
|
||||
|
||||
class Object:
|
||||
def __init__(self, id=None): # noqa: A002
|
||||
self.id = id
|
||||
|
||||
mod.Object = Object
|
||||
ext = types.ModuleType("discord.ext")
|
||||
commands = types.ModuleType("discord.ext.commands")
|
||||
|
||||
class Cog:
|
||||
pass
|
||||
|
||||
commands.Cog = Cog
|
||||
commands.hybrid_command = lambda **_k: (lambda fn: fn)
|
||||
ext.commands = commands
|
||||
mod.ext = ext
|
||||
sys.modules["discord.ext"] = ext
|
||||
sys.modules["discord.ext.commands"] = commands
|
||||
return mod
|
||||
|
||||
|
||||
def _build_ai_functions():
|
||||
mod = types.ModuleType("ai_functions")
|
||||
|
||||
async def handle_response(*_a, **_k):
|
||||
return ("stub reading", [])
|
||||
|
||||
mod.handle_response = handle_response
|
||||
return mod
|
||||
|
||||
|
||||
_stub_if_missing("discord", _build_discord)
|
||||
_stub_if_missing("ai_functions", _build_ai_functions)
|
||||
|
||||
import oracle_commands # noqa: E402 (after stubbing)
|
||||
|
||||
|
||||
def test_full_elder_futhark_present():
|
||||
assert len(oracle_commands.RUNES) == 24
|
||||
# every rune has the required fields
|
||||
for r in oracle_commands.RUNES:
|
||||
assert {"symbol", "nazwa", "znaczenie", "odwrocone"} <= set(r)
|
||||
|
||||
|
||||
def test_draw_returns_distinct_runes():
|
||||
drawn = oracle_commands.draw_runes(3, random.Random(1))
|
||||
names = [r["nazwa"] for r, _ in drawn]
|
||||
assert len(names) == 3
|
||||
assert len(set(names)) == 3 # distinct
|
||||
|
||||
|
||||
def test_non_invertible_runes_are_never_reversed():
|
||||
# Run many draws of the whole deck; any rune with empty 'odwrocone' must
|
||||
# never come up reversed.
|
||||
for seed in range(200):
|
||||
for rune, reversed_ in oracle_commands.draw_runes(24, random.Random(seed)):
|
||||
if not rune["odwrocone"]:
|
||||
assert reversed_ is False, f"{rune['nazwa']} was reversed but is non-invertible"
|
||||
|
||||
|
||||
def test_runa_dnia_is_stable_per_user_per_day():
|
||||
# Same user + same day -> identical draw; different day -> RNG differs.
|
||||
r1 = oracle_commands.draw_runes(1, oracle_commands._rng_for_day(42, "2026-07-31"))[0]
|
||||
r2 = oracle_commands.draw_runes(1, oracle_commands._rng_for_day(42, "2026-07-31"))[0]
|
||||
assert r1[0]["nazwa"] == r2[0]["nazwa"] and r1[1] == r2[1]
|
||||
|
||||
seeds = {
|
||||
oracle_commands._rng_for_day(uid, "2026-07-31").random()
|
||||
for uid in range(50)
|
||||
}
|
||||
assert len(seeds) > 1 # different users get different streams
|
||||
|
||||
|
||||
def test_meaning_falls_back_to_upright_for_non_invertible():
|
||||
gebo = next(r for r in oracle_commands.RUNES if r["nazwa"] == "Gebo")
|
||||
# even if asked "reversed", a non-invertible rune yields its upright meaning
|
||||
assert oracle_commands._meaning(gebo, True) == gebo["znaczenie"]
|
||||
Reference in New Issue
Block a user