oracle: $runy / $runa_dnia — wróżby z run Starszego Futharku #7
@@ -93,6 +93,7 @@ CORE_EXTENSIONS = [
|
|||||||
"other_commands",
|
"other_commands",
|
||||||
"bar_commands",
|
"bar_commands",
|
||||||
"lore_commands",
|
"lore_commands",
|
||||||
|
"oracle_commands",
|
||||||
"latex_commands",
|
"latex_commands",
|
||||||
"voice_recognition_commands",
|
"voice_recognition_commands",
|
||||||
"conanjurer_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