diff --git a/ai_commands.py b/ai_commands.py
index 353746e..baa3bc1 100644
--- a/ai_commands.py
+++ b/ai_commands.py
@@ -294,7 +294,7 @@ class Events(commands.Cog):
bartender,
MESSAGE_TABLE,
username,
- "CONVERSATION",
+ "GENERAL",
)
await discord_friendly_reply(message, result)
@@ -332,7 +332,7 @@ class Events(commands.Cog):
True,
MESSAGE_TABLE,
username,
- "RANDOM",
+ "GENERAL",
)
await discord_friendly_reply(
message, f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}"
diff --git a/ai_functions.py b/ai_functions.py
index d02b148..633f2ba 100644
--- a/ai_functions.py
+++ b/ai_functions.py
@@ -5,6 +5,7 @@ import random
import openai
import tiktoken
+import time
from other_functions import discord_friendly_send
from constants import (
ASSISTANTS,
@@ -23,6 +24,39 @@ from constants import (
#this do per user
VECTOR_STORE_ID = -1
+
+async def _openai_call(messages, model, temperature=0.2):
+ """
+ Responses API dla 4o/4.1*, fallback Chat Completions dla gpt-3.5-turbo.
+ Zwraca czysty string odpowiedzi.
+ """
+ logger = logging.getLogger("discord")
+
+ if model.startswith("gpt-3.5"):
+ logger.info("3.5")
+ # legacy path – bez zmian w Twoim kodzie wyżej/niżej
+ resp = await OPENAICLIENT.chat.completions.create(
+ model=model,
+ messages=messages,
+ temperature=temperature,
+ )
+ result = ""
+ for choice in resp.choices:
+ result += choice.message.content
+ return result.strip()
+
+ else:
+ logger.info("4.0+")
+ # Responses API (zalecane dla 4o/4.1*)
+ resp = await OPENAICLIENT.responses.create(
+ model=model,
+ temperature=temperature,
+ input=messages,
+ )
+ # SDK zapewnia output_text dla zwykłych odpowiedzi
+ return (getattr(resp.output_text, "output_text", None) or "").strip() or str(resp.output_text)
+
+
def create_vector_store():
# Create a vector store caled "Financial Statements"
return OPENAICLIENT.beta.vector_stores.create_and_poll(name="Hammer Stash")
@@ -99,7 +133,7 @@ def num_tokens_from_string(message, model):
async def handle_response(
- prompt, vykidailo, bartender, history, username, request_type
+ prompt, vykidailo, bartender, history, username, request_type, algorithm="gpt-4o", none_request=""
):
"""
Handle responses by appending them to a history, use OpenAI to
@@ -115,6 +149,10 @@ async def handle_response(
:param music: The "music" parameter is a boolean value that indicates whether the conversation is
related to music or not. If it is True, the conversation history will be stored in a different file
and the response will be generated using a different model
+ :param request_type: The type of request being made, which can be "MUSIC", "RANDOM", "NONE" or "GENERAL"
+ GENERAL is for regular conversations, MUSIC is for music-related requests,
+ RANDOM is for random requests, and NONE is to not store the request in memory
+ :param algorithm: The algorithm to be used for generating the response, default is "gpt-4o"
:return: The function `handle_response` returns a tuple containing the `result` and `MESSAGE_TABLE`.
"""
logger = logging.getLogger("discord")
@@ -141,7 +179,7 @@ async def handle_response(
file_memory.seek(0)
# convert back to json.
json.dump(file_data, file_memory, indent=4)
- else:
+ elif request_type == "GENERAL":
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory:
# First we load existing data into a dict.
file_data = json.load(file_memory)
@@ -151,69 +189,79 @@ async def handle_response(
# convert back to json.
json.dump(file_data, file_memory, indent=4)
history = []
- history.append(GPT_SETTINGS[0])
- chat_gpt_config_request_size = num_tokens_from_string(GPT_SETTINGS[0], "gpt-4")
+ if request_type != "NONE":
+ history.append(GPT_SETTINGS[0])
+ chat_gpt_config_request_size = num_tokens_from_string(GPT_SETTINGS[0], "gpt-4")
- for slowo, reakcja in WORD_REACTIONS.items():
- if not reakcja[3]:
- content = (
- "Kiedy słyszysz "
- + slowo
- + " to reagujesz lub dzieje się to "
- + reakcja[0]
- )
- temp = {"role": "system", "content": content}
- chat_gpt_config_request_size += num_tokens_from_string(temp, "gpt-4")
- history.append(temp)
+ for slowo, reakcja in WORD_REACTIONS.items():
+ if not reakcja[3]:
+ content = (
+ "Kiedy słyszysz "
+ + slowo
+ + " to reagujesz lub dzieje się to "
+ + reakcja[0]
+ )
+ temp = {"role": "system", "content": content}
+ chat_gpt_config_request_size += num_tokens_from_string(temp, "gpt-4")
+ history.append(temp)
- final_prompt = username + ":" + prompt
- logger.debug(
- "Rozmiar zapytania przed dodaniem historii %s", chat_gpt_config_request_size
- )
- if request_type == "MUSIC":
- algorithm = "gpt-4o"
- table = MESSAGE_TABLE_MUZYKA
- token_amount = 10700
- elif request_type == "RANDOM":
- algorithm = "gpt-4o"
- table = MESSAGE_TABLE
- token_amount = 10700
- else:
- table = MESSAGE_TABLE
- algorithm = "gpt-4o"
- token_amount = 10700
-
- prompt_gpt_request_size = num_tokens_from_string(
- {"role": "user", "content": final_prompt}, "gpt-4"
- )
- temptable = []
- for i in reversed(table):
- temp_token = num_tokens_from_string(i, "gpt-4")
+ final_prompt = username + ":" + prompt
logger.debug(
- "Rozmiar zapytania %s prompt %s temp %s",
- chat_gpt_config_request_size,
- prompt_gpt_request_size,
- temp_token,
+ "Rozmiar zapytania przed dodaniem historii %s", chat_gpt_config_request_size
)
- if (
- chat_gpt_config_request_size
- < token_amount + prompt_gpt_request_size + temp_token
- ):
- temptable.insert(1, i)
- chat_gpt_config_request_size += temp_token
- history.extend(temptable)
- temp = {"role": "user", "content": final_prompt}
- history.append(temp)
+ if request_type == "MUSIC":
+ table = MESSAGE_TABLE_MUZYKA
+ token_amount = 10700
+ elif request_type == "RANDOM":
+ table = MESSAGE_TABLE
+ token_amount = 10700
+ elif request_type == "GENERAL":
+ table = MESSAGE_TABLE
+ token_amount = 10700
+ else:
+ table = []
+ token_amount = 10000
+
+
+ prompt_gpt_request_size = num_tokens_from_string(
+ {"role": "user", "content": final_prompt}, "gpt-4"
+ )
+ temptable = []
+ for i in reversed(table):
+ temp_token = num_tokens_from_string(i, "gpt-4")
+ logger.debug(
+ "Rozmiar zapytania %s prompt %s temp %s",
+ chat_gpt_config_request_size,
+ prompt_gpt_request_size,
+ temp_token,
+ )
+ if (
+ chat_gpt_config_request_size
+ < token_amount + prompt_gpt_request_size + temp_token
+ ):
+ temptable.insert(1, i)
+ chat_gpt_config_request_size += temp_token
+ history.extend(temptable)
+ temp = {"role": "user", "content": final_prompt}
+ history.append(temp)
+ else:
+ history = none_request
logger.info("Rozmiar zapytania po wyslaniu %s", chat_gpt_config_request_size)
try:
- response = await OPENAICLIENT.chat.completions.create(
- model=algorithm, messages=history
+ # ...przygotowanie messages/system prompt/itp. jak masz...
+ # retry/backoff + deadline (zachowuje Twoją semantykę logowania)
+ timeout_sec = 120
+ deadline = time.time() + timeout_sec
+ response = await asyncio.wait_for(
+ _openai_call(messages=history, model=algorithm),
+ timeout=max(0.1, deadline - time.time())
)
+
except openai.APITimeoutError as e:
# Handle timeout error, e.g. retry or log
- result = f"*Kondziu patrzy na terminal, czeka, czeka, czeka,.... Jeszcze chwile czeka Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai spróbuj od nowa. *Na ekranie pojawia się*: {e}"
+ response = f"*Kondziu patrzy na terminal, czeka, czeka, czeka,.... Jeszcze chwile czeka Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai spróbuj od nowa. *Na ekranie pojawia się*: {e}"
except openai.APIConnectionError as e:
- result = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai. *Na ekranie pojawia się*: {e}"
+ response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai. *Na ekranie pojawia się*: {e}"
except openai.BadRequestError as e:
# Handle invalid request error, e.g. validate parameters or log
resp, _ = await handle_response(
@@ -224,7 +272,7 @@ async def handle_response(
username,
"RANDOM",
)
- result = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}"
+ response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}"
except openai.APIResponseValidationError as e:
# Handle invalid request error, e.g. validate parameters or log
resp, _ = await handle_response(
@@ -235,33 +283,25 @@ async def handle_response(
username,
"RANDOM",
)
- result = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}"
+ response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}"
except openai.AuthenticationError as e:
# Handle authentication error, e.g. check credentials or log
- result = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z hasłem zjebało. *Na terminalu pojawia się:* {e}"
+ response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z hasłem zjebało. *Na terminalu pojawia się:* {e}"
except openai.PermissionDeniedError as e:
# Handle permission error, e.g. check scope or log
- result = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z uprawnieniami zjebało. *Na terminalu pojawia się:* {e}"
+ response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z uprawnieniami zjebało. *Na terminalu pojawia się:* {e}"
except openai.RateLimitError as e:
- result = f"*Kondziu patrzy na terminal* Wołaj szefa. Zapłacić rachunki za AI trzeba. Jak chcesz to się na #zebranie dorzuć. {e}"
+ response = f"*Kondziu patrzy na terminal* Wołaj szefa. Zapłacić rachunki za AI trzeba. Jak chcesz to się na #zebranie dorzuć. {e}"
except openai.UnprocessableEntityError as e:
- result = f"*Kondziu patrzy na terminal. Potem na to co każesz mu wysłać....* Ja wiem że jesteśmy w barze BDSM - ale nie da się włożyć TEGO w TO. *Za jego plecami na terminalu pojawia się:* {e}"
+ response = f"*Kondziu patrzy na terminal. Potem na to co każesz mu wysłać....* Ja wiem że jesteśmy w barze BDSM - ale nie da się włożyć TEGO w TO. *Za jego plecami na terminalu pojawia się:* {e}"
except openai.APIError as e:
# Handle API error, e.g. retry or log
- result = f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {e}"
+ response = f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {e}"
logger.info("Historia wysłana:")
logger.info(history)
await asyncio.sleep(15)
- result = ""
- logger.debug("Odpowiedzi")
- logger.info(response)
- logger.debug(response.choices)
- for choice in response.choices:
- result += choice.message.content
- logger.info("Sformatowane odpowiedzi")
- logger.info(result)
- temp = {"role": "assistant", "content": result}
+ temp = {"role": "assistant", "content": response}
history.append(temp)
if request_type == "MUSIC":
with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as file_music_memory:
@@ -272,6 +312,7 @@ async def handle_response(
file_music_memory.seek(0)
# convert back to json.
json.dump(file_data, file_music_memory, indent=4)
+ return response, MESSAGE_TABLE_MUZYKA
elif request_type == "RANDOM":
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory:
# First we load existing data into a dict.
@@ -281,7 +322,8 @@ async def handle_response(
file_memory.seek(0)
# convert back to json.
json.dump(file_data, file_memory, indent=4)
- else:
+ return response, MESSAGE_TABLE
+ elif request_type == "GENERAL":
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory:
# First we load existing data into a dict.
file_data = json.load(file_memory)
@@ -290,8 +332,9 @@ async def handle_response(
file_memory.seek(0)
# convert back to json.
json.dump(file_data, file_memory, indent=4)
- return result, MESSAGE_TABLE
-
+ return response, MESSAGE_TABLE
+ else:
+ return response, []
async def get_random_cyclic_message(client):
"""
diff --git a/constants.py b/constants.py
index ee4b1bd..f120878 100644
--- a/constants.py
+++ b/constants.py
@@ -140,3 +140,14 @@ with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as temp_music_memory_file
MESSAGE_TABLE_MUZYKA = json.load(temp_music_memory_file)
SPECJALNE_ZIEMNIACZKI = GPT_SETTINGS[1]
ASSISTANTS = {}
+
+
+LATEX_TEX_ENGINE = "tectonic"
+LATEX_MAX_COMPILE_SECONDS = 45
+LATEX_MAX_ATTACH_MB = 8
+LATEX_MAX_ZIP_MB = 25
+
+OPENAI_MODEL = "gpt-4o-mini"
+
+ALLOWED_ROLES = ["Nocna Zmiana", "Jarl", "Thane", "Bartender"]
+GUILD_ID = 664789470779932693
\ No newline at end of file
diff --git a/deploy.sh b/deploy.sh
index 0651f6a..a1eafb2 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -1,6 +1,6 @@
#!/bin/bash
-total_commands=26
+total_commands=29
current_command=0
function print_progress {
@@ -86,6 +86,14 @@ print_progress "cp ./conjurer/file_search_functions.py ./Conjurer/"
cp ./conjurer/file_search_commands.py ./Conjurer
print_progress "cp ./conjurer/file_search_functions.py ./Conjurer/"
+cp ./conjurer/latex_commands.py ./Conjurer/
+print_progress "cp ./conjurer/latex_commands.py ./Conjurer/"
+
+cp ./conjurer/latex_functions.py ./Conjurer/
+print_progress "cp ./conjurer/latex_functions.py ./Conjurer/"
+
+cp ./conjurer/librarian_functions.py ./Conjurer
+print_progress "cp ./conjurer/librarian_functions.py ./Conjurer/"
cp ./conjurer/thin_client.py ./Conjurer/bot.py
print_progress "cp ./conjurer/thin_client.py ./Conjurer/bot.py"
diff --git a/dj_debug_fonts.sh b/dj_debug_fonts.sh
new file mode 100644
index 0000000..f3557a4
--- /dev/null
+++ b/dj_debug_fonts.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+exists() { command -v "$1" >/dev/null 2>&1; }
+
+echo "[i] Checking environment..."
+for c in fc-list pdffonts tectonic; do
+ if ! exists "$c"; then
+ echo " - $c: NOT FOUND (optional but recommended: sudo apt install -y fontconfig poppler-utils tectonic)"
+ else
+ echo " - $c: OK"
+ fi
+done
+
+echo "[i] Listing local ./fonts content:"
+ls -l ./fonts 2>/dev/null || echo " (no ./fonts directory)"
+
+if exists fc-list; then
+ echo "[i] System fonts (grep Garamond|Cinzel|FELL):"
+ fc-list | grep -Ei "Garamond|Cinzel|Fell" || echo " (none found in system)"
+fi
+
+pdfs=(dj_cheat_sheet_transitions.pdf dj_tracklist.pdf dj_mini_sheet.pdf dj_notes.pdf dj_setup_mapping.pdf dj_one_laptop_fallback.pdf dj_rider.pdf dj_podrecznik.pdf)
+if exists pdffonts; then
+ for p in "${pdfs[@]}"; do
+ [ -f "$p" ] || continue
+ echo "[i] Fonts embedded in $p:"
+ pdffonts "$p" || true
+ done
+else
+ echo "[!] pdffonts not installed; cannot list embedded fonts."
+fi
+
+echo "[i] Grepping last Tectonic log (if any .log files exist)..."
+logs=$(ls -1 *.log 2>/dev/null || true)
+if [ -n "$logs" ]; then
+ grep -Ei "fontspec|warning|not found" *.log || echo " (no relevant warnings)"
+else
+ echo " (no .log files)"
+fi
diff --git a/dj_oneclick_fix.sh b/dj_oneclick_fix.sh
new file mode 100644
index 0000000..2f025d8
--- /dev/null
+++ b/dj_oneclick_fix.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1" >&2; exit 1; }; }
+
+echo "[+] Checking tools..."
+need wget
+need unzip
+need tectonic || { echo "Install tectonic first (e.g. sudo apt install tectonic)"; exit 1; }
+
+mkdir -p fonts
+
+echo "[+] Downloading EB Garamond (Initials) to ./fonts ..."
+tmpzip="/tmp/ebgaramond.zip"
+wget -q -O "$tmpzip" https://github.com/octaviopardo/EBGaramond/releases/download/v0.016/EBGaramond-ttf.zip
+unzip -jq "$tmpzip" "*Initials*.ttf" -d fonts
+ls -1 fonts | grep -i initials || echo "WARN: EBGaramond Initials not found in zip?"
+
+echo "[+] Downloading Cinzel Decorative (Black) to ./fonts ..."
+tmpzip="/tmp/cinzel.zip"
+wget -q -O "$tmpzip" "https://fonts.google.com/download?family=Cinzel%20Decorative" || true
+unzip -jq "$tmpzip" "*Decorative-Black*.ttf" -d fonts || echo "WARN: Cinzel Decorative Black not found; continuing."
+
+echo "[+] (Optional) Downloading IM FELL English SC to ./fonts ..."
+tmpzip="/tmp/imfell.zip"
+wget -q -O "$tmpzip" "https://fonts.google.com/download?family=IM%20Fell%20English%20SC" || true
+unzip -jq "$tmpzip" "*.ttf" -d fonts || echo "INFO: IM FELL SC optional; skip if not needed."
+
+echo "[+] Building PDFs with Tectonic..."
+for f in dj_cheat_sheet_transitions.tex dj_tracklist.tex dj_mini_sheet.tex dj_notes.tex dj_setup_mapping.tex dj_one_laptop_fallback.tex dj_rider.tex dj_podrecznik.tex; do
+ if [ -f "$f" ]; then
+ echo " -> $f"
+ tectonic "$f" >/dev/null
+ else
+ echo "SKIP: $f not found"
+ fi
+done
+
+echo "[+] Done. Generated PDFs:"
+ls -1 *.pdf 2>/dev/null || true
diff --git a/latex_commands.py b/latex_commands.py
new file mode 100644
index 0000000..25a9303
--- /dev/null
+++ b/latex_commands.py
@@ -0,0 +1,142 @@
+# latex_commands.py
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+import io
+from typing import Optional, List
+import logging
+
+import discord
+from discord import app_commands
+from discord.ext import commands
+
+from constants import (
+ LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, LATEX_MAX_ATTACH_MB, LATEX_MAX_ZIP_MB,
+ OPENAI_MODEL, ALLOWED_ROLES, GUILD_ID
+)
+from latex_functions import (
+ is_tex_attachment, compile_single_tex_bytes, compile_zip_to_zip, ask_openai_diagnosis
+)
+
+class LatexModule(commands.Cog):
+ def __init__(self, bot: commands.Bot, logger_name: str):
+ self.bot = bot
+ self.logger = logging.getLogger(logger_name)
+ self.logger_name = logger_name
+
+ # -------- /tex (PDF + diagnoza; log -> debug logger) --------
+ @commands.hybrid_command(
+ nsfw=False,
+ name="tex",
+ description="Kompiluje załączone .tex; PDF-y odsyła. Błędy: diagnoza z OpenAI.",
+ guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID),
+ )
+ @commands.has_any_role(*ALLOWED_ROLES)
+ async def tex(self, ctx: commands.Context, include_source_excerpt: Optional[bool] = True):
+ self.logger.info("LaTeX command invoked by %s", ctx.author)
+ ch = ctx.message.channel
+ async with ch.typing():
+ atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)]
+ if not atts:
+ return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False)
+ self.logger.debug("LaTeX attachments: %s", ", ".join(a.filename for a in atts))
+ files: List[discord.File] = []
+ parts: List[str] = []
+ for att in atts:
+ try:
+ data = await att.read()
+ self.logger.info("LaTeX read %s bytes from %s", len(data), att.filename)
+ res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name)
+ self.logger.info("LaTeX result for %s: %s", att.filename, res)
+ self.logger.info("LaTeX log (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:])
+ if res["ok"] and res["pdf_bytes"]:
+ b = io.BytesIO(res["pdf_bytes"])
+ b.seek(0)
+ b.name = res["pdf_name"]
+ files.append(discord.File(b, filename=res["pdf_name"]))
+ parts.append(f"✅ `{att.filename}` → `{res['pdf_name']}`")
+ else:
+ self.logger.info("LaTeX compile failed for %s", att.filename)
+ excerpt = ""
+ if include_source_excerpt:
+ try:
+ excerpt = data.decode("utf-8", errors="ignore")[:4000]
+ except Exception:
+ excerpt = ""
+ advice = await ask_openai_diagnosis(res["log_text"] or "", excerpt, OPENAI_MODEL, logger=self.logger_name)
+ parts.append(f"❌ `{att.filename}` — błąd kompilacji.\nDiagnoza:\n{advice}")
+ raise
+ except Exception as e:
+ self.logger.info("Exception %s: %s", att.filename, e)
+ parts.append(f"⚠️ `{att.filename}` — wyjątek (szczegóły w logu).")
+ raise
+
+ content = f"**LaTeX** — {len(atts)} plik(ów). Model: `{OPENAI_MODEL}`\n\n" + "\n\n".join(parts)
+ await ctx.reply(content if len(content)<1900 else content[:1900]+"…", files=files, mention_author=False)
+
+ # -------- /latexclean (PDF only; log -> debug logger) --------
+ @commands.hybrid_command(
+ nsfw=False,
+ name="latexclean",
+ description="Kompiluje .tex i odsyła TYLKO PDF (log w debug).",
+ guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID),
+ )
+ @commands.has_any_role(*ALLOWED_ROLES)
+ async def latexclean(self, ctx: commands.Context):
+ ch = ctx.message.channel
+ async with ch.typing():
+ atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)]
+ if not atts:
+ return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False)
+
+ files: List[discord.File] = []
+ parts: List[str] = []
+ for att in atts:
+ try:
+ data = await att.read()
+ res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name)
+ self.logger.debug("LaTeX CLEAN (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:])
+ if res["ok"] and res["pdf_bytes"]:
+ b = io.BytesIO(res["pdf_bytes"])
+ b.seek(0)
+ b.name = res["pdf_name"]
+ files.append(discord.File(b, filename=res["pdf_name"]))
+ parts.append(f"✅ `{att.filename}`")
+ else:
+ parts.append(f"❌ `{att.filename}` — błąd (log w debug).")
+ except Exception as e:
+ self.logger.debug("CLEAN exception %s: %s", att.filename, e)
+ parts.append(f"⚠️ `{att.filename}` — wyjątek (log w debug).")
+ raise
+
+ await ctx.reply("**LaTeX clean** — " + ", ".join(parts), files=files, mention_author=False)
+
+ # -------- /texbatch (ZIP -> ZIP) --------
+ @app_commands.command(
+ name="texbatch",
+ description="Wyślij ZIP z .tex → odeślę ZIP z PDF-ami (tylko udane)."
+ )
+ @app_commands.describe(archive=f"Archiwum .zip (max ~{LATEX_MAX_ZIP_MB} MiB)")
+ @app_commands.checks.has_any_role(*ALLOWED_ROLES)
+ async def texbatch(self, interaction: discord.Interaction, archive: discord.Attachment):
+ await interaction.response.defer()
+ if not archive or not archive.filename.lower().endswith(".zip"):
+ return await interaction.followup.send("Dołącz ZIP (.zip) z plikami .tex.", ephemeral=True)
+ if archive.size > LATEX_MAX_ZIP_MB * 1024 * 1024:
+ return await interaction.followup.send(f"ZIP > {LATEX_MAX_ZIP_MB} MiB — za duży.", ephemeral=True)
+
+ data = await archive.read()
+ out_zip_bytes, failed = await compile_zip_to_zip(data, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name)
+ if not out_zip_bytes and failed:
+ return await interaction.followup.send("Brak PDF-ów. " + "; ".join(failed[:10]), ephemeral=True)
+
+ bio = io.BytesIO(out_zip_bytes)
+ bio.seek(0)
+ bio.name = "compiled_pdfs.zip"
+ if failed:
+ self.logger.debug("BATCH failed: %s", ", ".join(failed))
+ await interaction.followup.send("✅ Gotowe (ZIP w załączniku)." + (f" ❌ Błędy: {len(failed)}" if failed else ""), files=[discord.File(bio, filename=bio.name)])
+
+async def setup(bot):
+ logger = logging.getLogger("discord")
+ await bot.add_cog(LatexModule(bot, logger_name="discord"))
+ logger.info("Loading kinky latex module done")
diff --git a/latex_functions.py b/latex_functions.py
new file mode 100644
index 0000000..295b0eb
--- /dev/null
+++ b/latex_functions.py
@@ -0,0 +1,233 @@
+# latex_functions.py
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+import tempfile
+import zipfile
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+from ai_functions import handle_response
+
+SAFE_TEX_NAME = re.compile(r"^[\w\-. ]+\.tex$", re.IGNORECASE)
+
+
+def is_safe_tex_name(name: str) -> bool:
+ return bool(SAFE_TEX_NAME.match(name or "")) and name.lower().endswith(".tex")
+
+
+def is_tex_attachment(att, max_mb: int) -> bool:
+ """Filtruje discord.Attachment: tylko .tex + limit MB + nazwa bezpieczna."""
+ return bool(
+ getattr(att, "filename", "")
+ and is_safe_tex_name(att.filename)
+ and getattr(att, "size", 0) <= max_mb * 1024 * 1024
+ )
+
+
+def _build_cmd(engine: str, tex_filename: str) -> list[str]:
+ base = (engine or "").strip().lower()
+ if base in ("pdflatex", "xelatex", "lualatex"):
+ return [
+ base,
+ "-interaction=nonstopmode",
+ "-halt-on-error",
+ "-file-line-error",
+ "-no-shell-escape",
+ tex_filename,
+ ]
+ if base == "latexmk":
+ return [
+ "latexmk",
+ "-pdf",
+ "-interaction=nonstopmode",
+ "-halt-on-error",
+ "-file-line-error",
+ tex_filename,
+ ]
+ if base == "tectonic":
+ return [
+ "tectonic",
+ "-X",
+ "compile",
+ "--keep-logs",
+ "--outdir",
+ ".",
+ tex_filename,
+ ]
+ return [
+ "pdflatex",
+ "-interaction=nonstopmode",
+ "-halt-on-error",
+ "-file-line-error",
+ "-no-shell-escape",
+ tex_filename,
+ ]
+
+
+async def run_latex_two_passes(
+ tex_filename: str,
+ workdir: Path,
+ tex_engine: str,
+ timeout_sec: int,
+ logger: str = None,
+):
+ logger = logging.getLogger(logger) if logger else None
+
+ async def run_once(cmd: list[str]) -> tuple[int, str]:
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ cwd=str(workdir),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.STDOUT,
+ )
+ try:
+ out_b, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
+ return (proc.returncode or 0), (out_b or b"").decode(
+ "utf-8", errors="replace"
+ )
+ except asyncio.TimeoutError:
+ try:
+ proc.kill()
+ except Exception:
+ raise
+ return 124, f"[Timeout] Compilation exceeded {timeout_sec}s.\n"
+
+ logs = []
+ cmd = _build_cmd(tex_engine, tex_filename)
+ if logger:
+ logger.debug("LaTeX cmd: %s (cwd=%s)", " ".join(cmd), workdir)
+
+ rc1, out1 = await run_once(cmd)
+ logs.append(out1)
+ second_pass = not ((tex_engine or "").strip().lower() == "tectonic")
+
+ if rc1 != 0:
+ log_text = "".join(logs)
+ pdf = workdir / (Path(tex_filename).stem + ".pdf")
+ return False, log_text, pdf
+
+ if second_pass:
+ rc2, out2 = await run_once(cmd)
+ logs.append(out2)
+ ok = rc2 == 0
+ else:
+ ok = rc1 == 0
+
+ log_text = "".join(logs)
+ pdf = workdir / (Path(tex_filename).stem + ".pdf")
+ ok = ok and pdf.exists()
+ return ok, log_text, pdf
+
+
+async def compile_single_tex_bytes(
+ tex_bytes: bytes,
+ filename: str,
+ tex_engine: str,
+ timeout_sec: int,
+ logger: str = None,
+) -> Dict[str, Optional[object]]:
+ if not is_safe_tex_name(filename):
+ return {
+ "ok": False,
+ "pdf_bytes": None,
+ "log_text": "Invalid .tex filename",
+ "pdf_name": None,
+ }
+ with tempfile.TemporaryDirectory(prefix="latex_one_") as td:
+ wd = Path(td)
+ tex_path = wd / filename
+ tex_path.write_bytes(tex_bytes)
+ ok, log_text, pdf_path = await run_latex_two_passes(
+ filename, wd, tex_engine, timeout_sec, logger=logger
+ )
+ if ok and pdf_path.exists():
+ return {
+ "ok": True,
+ "pdf_bytes": pdf_path.read_bytes(),
+ "log_text": log_text,
+ "pdf_name": pdf_path.name,
+ }
+ return {
+ "ok": False,
+ "pdf_bytes": None,
+ "log_text": log_text,
+ "pdf_name": pdf_path.name,
+ }
+
+
+async def compile_zip_to_zip(
+ zip_bytes: bytes, tex_engine: str, timeout_sec: int, logger: str = None
+) -> Tuple[bytes, List[str]]:
+ failed: List[str] = []
+ out_pdf_paths: List[Path] = []
+ with tempfile.TemporaryDirectory(prefix="latex_zip_") as td:
+ td_path = Path(td)
+ in_zip = td_path / "in.zip"
+ in_zip.write_bytes(zip_bytes)
+ extract_dir = td_path / "in"
+ extract_dir.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(in_zip, "r") as zf:
+ zf.extractall(extract_dir)
+
+ tex_files = [p for p in extract_dir.rglob("*.tex") if is_safe_tex_name(p.name)]
+ if not tex_files:
+ return b"", ["No .tex files found in archive."]
+
+ for tex in tex_files:
+ try:
+ work = td_path / f"build_{tex.stem}"
+ work.mkdir(parents=True, exist_ok=True)
+ target_tex = work / tex.name
+ target_tex.write_bytes(tex.read_bytes())
+ ok, log_text, pdf_path = await run_latex_two_passes(
+ target_tex.name, work, tex_engine, timeout_sec, logger=logger
+ )
+ if ok and pdf_path.exists():
+ out_pdf_paths.append(pdf_path)
+ else:
+ failed.append(tex.name)
+ if logger:
+ logger.debug("BATCH FAIL %s\n%s", tex.name, log_text[-1500:])
+ except Exception as e:
+ failed.append(f"{tex.name} (exception: {e})")
+ if logger:
+ logger.debug("BATCH EXC %s: %s", tex.name, e)
+ raise
+
+ out_zip = td_path / "compiled_pdfs.zip"
+ with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for pdf in out_pdf_paths:
+ zf.write(pdf, arcname=pdf.name)
+
+ return out_zip.read_bytes(), failed
+
+
+async def ask_openai_diagnosis(
+ log_text: str, tex_excerpt: str, model: str, logger: str = None
+) -> str:
+ logger = logging.getLogger(logger) if logger else None
+ sys = (
+ "You are a LaTeX build diagnostician. Analyze pdflatex log and return:\n"
+ "1) Root causes (bullets)\n2) Minimal working fix (code block)\n3) Alternative fix"
+ )
+ logger.debug("LaTeX DIAG (%s)\n%s", model, log_text[-9000:])
+ usr = f"---LOG---\n{log_text[-9000:]}\n---END LOG---"
+ if tex_excerpt:
+ usr += f"\n---TEX EXCERPT---\n{tex_excerpt[:4000]}\n---END EXCERPT---"
+ prompt = [{"role": sys, "content": "none"}, {"role": "user", "content": usr}]
+
+ response, _ = await handle_response(
+ prompt,
+ None,
+ None,
+ prompt,
+ "latex_diagnosis",
+ "NONE",
+ algorithm=model,
+ none_request=prompt,
+ )
+ return response
diff --git a/librarian_commands.py b/librarian_commands.py
index ed858f6..aa12f6b 100644
--- a/librarian_commands.py
+++ b/librarian_commands.py
@@ -8,6 +8,7 @@ from queue import Empty
import discord
import pdf2image
+import fitz
import PyPDF2
import requests
from discord.ext import commands, tasks
@@ -53,20 +54,34 @@ class DataModule(commands.Cog):
filename = res[random.randrange(0, len(res) - 1)]
# select random page
file = open(DIR_PATH_SADOX + filename, "rb")
- readpdf = PyPDF2.PdfReader(file)
- totalpages = len(readpdf.pages)
- # trunk-ignore(bandit/B311)
- page = random.randrange(1, totalpages)
- # convert page to image
- image = pdf2image.convert_from_path(
- DIR_PATH_SADOX + filename, first_page=page, last_page=page
- )
- byte_io_stream = io.BytesIO()
- image[0].save(byte_io_stream, "JPEG")
- byte_io_stream.seek(0)
- byte_io_stream.name = "image.jpg"
- file = discord.File(byte_io_stream)
- await ctx.send(file=file)
+ if True:
+ doc = fitz.open(DIR_PATH_SADOX + filename)
+ totalpages = len(doc)
+ # trunk-ignore(bandit/B311)
+ page_index = random.randrange(0, totalpages)
+ page = doc.load_page(page_index)
+ mat = fitz.Matrix(2.0, 2.0) # powiększenie
+ pix = page.get_pixmap(matrix=mat, alpha=False)
+
+ byte_io_stream = io.BytesIO(pix.tobytes("png"))
+ byte_io_stream.seek(0)
+ byte_io_stream.name = "image.png"
+ await ctx.send(file=discord.File(byte_io_stream))
+ else: #legacy
+ readpdf = PyPDF2.PdfReader(file)
+ totalpages = len(readpdf.pages)
+ # trunk-ignore(bandit/B311)
+ page = random.randrange(1, totalpages)
+ # convert page to image
+ image = pdf2image.convert_from_path(
+ DIR_PATH_SADOX + filename, first_page=page, last_page=page
+ )
+ byte_io_stream = io.BytesIO()
+ image[0].save(byte_io_stream, "JPEG")
+ byte_io_stream.seek(0)
+ byte_io_stream.name = "image.jpg"
+ file = discord.File(byte_io_stream)
+ await ctx.send(file=file)
self.logger.info("Get sadox completed")
@tasks.loop(seconds=3)
@@ -222,7 +237,7 @@ class DataModule(commands.Cog):
global MESSAGE_TABLE # pylint: disable=global-statement
result, MESSAGE_TABLE = await handle_response(
- prompt, vykidailo, bartender, MESSAGE_TABLE, username, "CONVERSATION"
+ prompt, vykidailo, bartender, MESSAGE_TABLE, username, "GENERAL"
)
if len(result) < 1500:
await ctx.send(result)
diff --git a/librarian_functions.py b/librarian_functions.py
new file mode 100644
index 0000000..4855426
--- /dev/null
+++ b/librarian_functions.py
@@ -0,0 +1,28 @@
+# pdf_render.py
+import io
+
+try:
+ import fitz # PyMuPDF
+ _HAS_PYMUPDF = True
+except Exception:
+ _HAS_PYMUPDF = False
+ from pdf2image import convert_from_path
+
+def pdf_page_to_image_bytes(path: str, page_index: int = 0, zoom: float = 2.0, fmt: str = "PNG") -> bytes:
+ """
+ Zwraca bytes obrazka z jednej strony PDF:
+ - PyMuPDF (szybki) jeśli dostępny,
+ - inaczej pdf2image + poppler (wymaga 'pdftoppm').
+ page_index: 0-based
+ """
+ if _HAS_PYMUPDF:
+ doc = fitz.open(path)
+ page = doc.load_page(page_index)
+ mat = fitz.Matrix(zoom, zoom)
+ pix = page.get_pixmap(matrix=mat, alpha=False)
+ return pix.tobytes(fmt.lower())
+ # fallback
+ images = convert_from_path(path, first_page=page_index+1, last_page=page_index+1, fmt=fmt)
+ bio = io.BytesIO()
+ images[0].save(bio, fmt)
+ return bio.getvalue()
diff --git a/requirements_bot.txt b/requirements_bot.txt
index 9eeca53..621be26 100644
--- a/requirements_bot.txt
+++ b/requirements_bot.txt
@@ -13,6 +13,7 @@ spotipy
tiktoken
PyNaCl
flask[async]
+PyMuPDF
waitress
clickupython
assemblyai[extras]
diff --git a/rpg/armour.csv b/rpg/armour.csv
new file mode 100644
index 0000000..1afa6f2
--- /dev/null
+++ b/rpg/armour.csv
@@ -0,0 +1,3 @@
+name,type,locations,ap_head,ap_body,ap_larm,ap_rarm,ap_lleg,ap_rleg,max_ag,traits,weight,availability,source,notes
+Flak Vest,armor,"Body",0,4,0,0,0,0,,Flak,7,Common,"DH2 CRB","PRZYKŁAD – ZASTĄP"
+Power Armour (Astartes),armor,"All",8,10,8,8,9,9,,Environmental; Auto-senses,100,"Very Rare","DW CRB","PRZYKŁAD – ZASTĄP"
diff --git a/rpg/attack_test.js b/rpg/attack_test.js
new file mode 100644
index 0000000..2f8e32d
--- /dev/null
+++ b/rpg/attack_test.js
@@ -0,0 +1,60 @@
+// DH2 Attack Test v2 — presets: Aim/Range/Fire + Size/Light/Cover
+const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
+if (!actor) return ui.notifications.warn("Zaznacz token albo przypisz postać.");
+new Dialog({
+ title: "🎯 Attack Test (WS/BS)",
+ content: `
+
`,
+ buttons: {
+ roll: {
+ label: "Roll",
+ callback: async (html) => {
+ const get = n => Number(html.find(`[name="${n}"]`).val());
+ const base = get("base");
+ const total = base + get("aim") + get("range") + get("stance") + get("size") + get("light") + get("cover") + get("mod");
+ const r = await(new Roll("1d100")).roll({async:true});
+ const ok = r.total <= total;
+ const margin = Math.abs(total - r.total);
+ const dox = ok ? 1 + Math.floor(margin/10) : Math.floor(margin/10);
+ const table = `
+
+ | Target | ${total} | Roll | ${r.total} |
+ | Result | ${ok?'SUCCESS':'FAIL'} — ${ok?dox+' DoS':dox+' DoF'} |
+
`;
+ r.toMessage({speaker: ChatMessage.getSpeaker({actor}), flavor: `🎯 Attack Test
${table}`});
+ }
+ }
+ }
+}).render(true);
diff --git a/rpg/critical_hits.csv b/rpg/critical_hits.csv
new file mode 100644
index 0000000..d30ef62
--- /dev/null
+++ b/rpg/critical_hits.csv
@@ -0,0 +1,6 @@
+Roll,Result
+1,Energy/Head — wpis 1
+2,Energy/Head — wpis 2
+3,Energy/Head — wpis 3
+4,Energy/Head — wpis 4
+5,Energy/Head — wpis 5
diff --git a/rpg/focus_power.js b/rpg/focus_power.js
new file mode 100644
index 0000000..8b8cddd
--- /dev/null
+++ b/rpg/focus_power.js
@@ -0,0 +1,50 @@
+// 🧠 Focus Power (DH2) — WP test + Phenomena/Perils with mode presets
+const actor = canvas.tokens.controlled[0]?.actor ?? game.user.character;
+if (!actor) return ui.notifications.warn("Zaznacz token.");
+new Dialog({
+ title: "🧠 Focus Power",
+ content: `
+ `,
+ buttons: {
+ roll: { label: "Roll", callback: async html => {
+ const wp = Number(html.find('[name="wp"]').val());
+ const pr = Number(html.find('[name="pr"]').val());
+ const mode = html.find('[name="mode"]').val();
+ const flat = Number(html.find('[name="flat"]').val());
+ const thr = Number(html.find('[name="thr"]').val());
+ let effPR = pr, ppmod = 0, ppAlways = false, note = "";
+ if (mode==="fettered"){ effPR = Math.max(1, Math.floor(pr/2)); note="(Fettered: PR/2, brak Phenomena)"; }
+ if (mode==="push"){ effPR = pr+3; ppmod=10; ppAlways = true; note="(Push: +3 PR, Phenomena zawsze, +10)"; }
+ const target = wp + flat;
+ const roll = await (new Roll("1d100")).roll({async:true});
+ const ok = roll.total <= target;
+ const dos = ok ? 1 + Math.floor((target - roll.total)/10) : Math.floor((roll.total - target)/10);
+ const doubles = (roll.total%11===0) || (roll.total===100);
+ const info = `
+ | Target | ${target} | Roll | ${roll.total} |
+ | Result | ${ok?'SUCCESS':'FAIL'} — ${ok?dos+' DoS':dos+' DoF'} ${doubles?' — DOUBLES':''} |
+ | Eff. PR | ${effPR} | Range hint | ${effPR*10} m (jeśli moc tak działa) |
+
`;
+ roll.toMessage({speaker: ChatMessage.getSpeaker({actor}), flavor:`🧠 Focus Power ${note}
${info}`});
+ const needPP = (mode==="unfettered" && doubles) || (mode==="push") ;
+ if (needPP){
+ const tbl = game.tables.getName("Psychic Phenomena");
+ if (tbl){
+ const r = await (new Roll(`1d100 + ${ppmod}`)).roll({async:true});
+ await tbl.draw({displayResults:true, roll:r});
+ if (r.total >= thr){
+ const per = game.tables.getName("Perils of the Warp");
+ if (per) await per.draw({displayResults:true});
+ }
+ } else ChatMessage.create({content:"Utwórz RollTable: Psychic Phenomena (+ Perils of the Warp)"});
+ }
+ }}
+ }
+}).render(true);
diff --git a/rpg/hit_location.js b/rpg/hit_location.js
new file mode 100644
index 0000000..7c2b2a6
--- /dev/null
+++ b/rpg/hit_location.js
@@ -0,0 +1,22 @@
+// 🎯 Hit Location (DH mapping by reversed roll)
+new Dialog({
+ title:"🎯 Hit Location",
+ content:``,
+ buttons:{
+ go:{label:"Resolve", callback: html=>{
+ const n = Math.max(1, Math.min(100, Number(html.find('[name="roll"]').val())));
+ const rev = Number(String(n).padStart(2,"0").split("").reverse().join(""));
+ let loc = "";
+ if (rev<=10) loc="Head";
+ else if (rev<=20) loc="Right Arm";
+ else if (rev<=30) loc="Left Arm";
+ else if (rev<=70) loc="Body";
+ else if (rev<=85) loc="Right Leg";
+ else loc="Left Leg";
+ ChatMessage.create({content:`🎯 Hit Location: roll ${n} → reversed ${rev} → ${loc}`});
+ }}
+ }
+ }).render(true);
+
\ No newline at end of file
diff --git a/rpg/hose_rules.js b/rpg/hose_rules.js
new file mode 100644
index 0000000..eec1dcd
--- /dev/null
+++ b/rpg/hose_rules.js
@@ -0,0 +1,6 @@
+const text=`House Rules — DH2 chassis
+• Unnatural mapowanie: dawne ×2 bonus → Unnatural (+X) tak, by SB/TB odzwierciedlały linię źródłową.
+• Pancerz Astartes: zachowaj AP; zużycie zasilania jako +1 Fatigue co X scen zamiast liczenia minut.
+• Aptitudes: archetypy z RT/DW/BC mają przypisane 2–3 Aptitudes DH2 dla kosztów XP.
+• Psy: testy i PR z DH2 dla wszystkich; GK posiada Aegis Discipline (1×/scena reroll Perils) + 1–2 signature powers z DW.
+• RT Acquisition → DH2 Influence z modyfikatorami kontekstowymi (teatr działań, mandat Inkwizycji, czas).`;ChatMessage.create({content:text});
diff --git a/rpg/inititative.js b/rpg/inititative.js
new file mode 100644
index 0000000..f37d0c8
--- /dev/null
+++ b/rpg/inititative.js
@@ -0,0 +1,11 @@
+// 🚦 Initiative for selected tokens (1d10 + AG Bonus prompt)
+if (!canvas.tokens.controlled.length) return ui.notifications.warn("Zaznacz co najmniej jeden token.");
+const combat = game.combat ?? await Combat.implementation.create({});
+for (const t of canvas.tokens.controlled){
+ if (!combat.combatants.some(c=>c.tokenId===t.id)) await combat.createEmbeddedDocuments("Combatant",[ {tokenId:t.id, sceneId: canvas.scene.id, hidden:false} ]);
+ const ag = Number(await Dialog.prompt({title:`AG Bonus for ${t.name}`, content:``, label:"OK"}));
+ const r = await (new Roll(`1d10 + ${ag}`)).roll({async:true});
+ await combat.setInitiative(combat.combatants.find(c=>c.tokenId===t.id).id, r.total);
+ r.toMessage({flavor:`🚦 Initiative — ${t.name}: ${r.total}`});
+}
+ui.notifications.info("Inicjatywy ustawione.");
diff --git a/rpg/perils.csv b/rpg/perils.csv
new file mode 100644
index 0000000..aa97592
--- /dev/null
+++ b/rpg/perils.csv
@@ -0,0 +1,21 @@
+Roll,Result
+1-5,Perils 1–5 — WPISZ
+6-10,Perils 6–10 — WPISZ
+11-15,Perils 11–15 — WPISZ
+16-20,Perils 16–20 — WPISZ
+21-25,Perils 21–25 — WPISZ
+26-30,Perils 26–30 — WPISZ
+31-35,Perils 31–35 — WPISZ
+36-40,Perils 36–40 — WPISZ
+41-45,Perils 41–45 — WPISZ
+46-50,Perils 46–50 — WPISZ
+51-55,Perils 51–55 — WPISZ
+56-60,Perils 56–60 — WPISZ
+61-65,Perils 61–65 — WPISZ
+66-70,Perils 66–70 — WPISZ
+71-75,Perils 71–75 — WPISZ
+76-80,Perils 76–80 — WPISZ
+81-85,Perils 81–85 — WPISZ
+86-90,Perils 86–90 — WPISZ
+91-95,Perils 91–95 — WPISZ
+96-100,Perils 96–100 — WPISZ
diff --git a/rpg/powers.csv b/rpg/powers.csv
new file mode 100644
index 0000000..c274d44
--- /dev/null
+++ b/rpg/powers.csv
@@ -0,0 +1,3 @@
+name,type,discipline,action,test,range,sustained,effect,source,notes
+Smite,power,Biomancy,Half,"WP Challenging (+0)","PR*10m",No,"1d10+PR E; Tearing","DH2 CRB","PRZYKŁAD – ZASTĄP"
+Foreboding,power,Divination,Reaction,"Per Difficult (-10)","Self",No,"Use as Evasion; DoS rules","DH2 CRB","PRZYKŁAD – ZASTĄP"
diff --git a/rpg/pp.csv b/rpg/pp.csv
new file mode 100644
index 0000000..a5bd82f
--- /dev/null
+++ b/rpg/pp.csv
@@ -0,0 +1,21 @@
+Roll,Result
+1-5,PP 1–5 — WPISZ
+6-10,PP 6–10 — WPISZ
+11-15,PP 11–15 — WPISZ
+16-20,PP 16–20 — WPISZ
+21-25,PP 21–25 — WPISZ
+26-30,PP 26–30 — WPISZ
+31-35,PP 31–35 — WPISZ
+36-40,PP 36–40 — WPISZ
+41-45,PP 41–45 — WPISZ
+46-50,PP 46–50 — WPISZ
+51-55,PP 51–55 — WPISZ
+56-60,PP 56–60 — WPISZ
+61-65,PP 61–65 — WPISZ
+66-70,PP 66–70 — WPISZ
+71-75,PP 71–75 — WPISZ
+76-80,PP 76–80 — WPISZ
+81-85,PP 81–85 — WPISZ
+86-90,PP 86–90 — WPISZ
+91-95,PP 91–95 — WPISZ
+96-100,PP 96–100 — WPISZ
diff --git a/rpg/quick_conditions.js b/rpg/quick_conditions.js
new file mode 100644
index 0000000..90689f6
--- /dev/null
+++ b/rpg/quick_conditions.js
@@ -0,0 +1,28 @@
+// 🩹 Toggle conditions on selected tokens (Foundry v13)
+const choices = [
+ {id:"fatigued", label:"Fatigued"},
+ {id:"stunned", label:"Stunned"},
+ {id:"prone", label:"Prone"},
+ {id:"frightened", label:"Frightened (Fear)"}
+ ];
+ const opts = choices.map(c=>``).join("
");
+ new Dialog({
+ title:"🩹 Conditions",
+ content:``,
+ buttons:{
+ go:{label:"Apply",callback: html=>{
+ const ids = Array.from(html.find('input[name="c"]:checked')).map(e=>e.value);
+ const mode = html.find('[name="mode"]').val();
+ const getEf = id => CONFIG.statusEffects.find(e=>e.id===id) ?? {id};
+ canvas.tokens.controlled.forEach(t=>{
+ ids.forEach(id=>{
+ if (mode==="toggle") t.toggleEffect(getEf(id));
+ else if (mode==="on") t.actor?.effects?.some(e=>e.getFlag("core","statusId")===id) ? null : t.toggleEffect(getEf(id));
+ else if (mode==="off") t.actor?.effects?.some(e=>e.getFlag("core","statusId")===id) ? t.toggleEffect(getEf(id)) : null;
+ });
+ });
+ }}
+ }
+ }).render(true);
+
\ No newline at end of file
diff --git a/rpg/quick_damage.js b/rpg/quick_damage.js
new file mode 100644
index 0000000..2020436
--- /dev/null
+++ b/rpg/quick_damage.js
@@ -0,0 +1,38 @@
+// 💥 Quick Damage — supports Tearing, Proven(X), Primitive(X), flat mod
+new Dialog({
+ title:"💥 Damage Roller",
+ content: `
+ `,
+ buttons:{
+ go:{label:"Roll", callback: async html=>{
+ const mod = Number(html.find('[name="mod"]').val());
+ const tearing = html.find('[name="tear"]')[0].checked;
+ const proven = html.find('[name="prov"]')[0].checked ? Number(html.find('[name="provV"]').val()) : 0;
+ const primitive = html.find('[name="prim"]')[0].checked ? Number(html.find('[name="primV"]').val()) : 0;
+ // base die (d10) with tearing (best of 2)
+ const r1 = await (new Roll("1d10")).roll({async:true});
+ const r2 = tearing ? await (new Roll("1d10")).roll({async:true}) : null;
+ let die = tearing ? Math.max(r1.total, r2.total) : r1.total;
+ // apply Proven/Primitive
+ if (proven>0) die = Math.max(die, proven);
+ if (primitive>0) die = Math.min(die, primitive);
+ const rf = (die===10); // potential Zealous Hatred trigger
+ const total = die + mod;
+ let flavor = `💥 Damage
Die: ${die}${tearing?` (Tearing ${r1.total}/${r2.total.total})`:''} + Mod ${mod} = ${total}`;
+ if (proven>0) flavor += `
Proven(${proven}) zastosowano`;
+ if (primitive>0) flavor += `
Primitive(${primitive}) zastosowano`;
+ if (rf) flavor += `
⚡ Natural 10 — rozważ Zealous Hatred.`;
+ ChatMessage.create({speaker: ChatMessage.getSpeaker(), content: flavor});
+ }}
+ }
+ }).render(true);
+
\ No newline at end of file
diff --git a/rpg/table_gen.js b/rpg/table_gen.js
new file mode 100644
index 0000000..ec0f29c
--- /dev/null
+++ b/rpg/table_gen.js
@@ -0,0 +1,26 @@
+// 📚 Create placeholder RollTables for Crits + Psychic Phenomena/Perils (with icons)
+const icon = {Energy:"⚡", Impact:"🔨", Rending:"🗡️", Explosive:"💥"};
+const dmgTypes = ["Energy","Impact","Rending","Explosive"];
+const locs = ["Head","Body","Left Arm","Right Arm","Left Leg","Right Leg"];
+async function makeCrit(dtype, loc){
+ const name = `Crit: ${dtype} - ${loc}`;
+ if (game.tables.getName(name)) return;
+ const results = [];
+ for (let i=1;i<=5;i++){
+ results.push({type:0, text:`${icon[dtype]||""} ${dtype}/${loc} — wpis ${i} (uzupełnij z PDF)`, weight:1, range:[i,i]});
+ }
+ await RollTable.implementation.create({name, formula:"1d5", replacement:true, displayRoll:false, results});
+}
+async function makeWide(name, emoji){
+ if (game.tables.getName(name)) return;
+ const results = [];
+ for (let i=0;i<20;i++){
+ const lo=i*5+1, hi=i*5+5;
+ results.push({type:0, text:`${emoji} ${name} ${lo}-${hi} — wpis (uzupełnij z PDF)`, weight:1, range:[lo,hi]});
+ }
+ await RollTable.implementation.create({name, formula:"1d100", replacement:true, displayRoll:false, results});
+}
+for (const d of dmgTypes) for (const l of locs) await makeCrit(d,l);
+await makeWide("Psychic Phenomena","🌀");
+await makeWide("Perils of the Warp","☠️");
+ui.notifications.info("Utworzono puste tabele: Crits (4×6) + Phenomena + Perils.");
diff --git a/rpg/talents.csv b/rpg/talents.csv
new file mode 100644
index 0000000..529b9bb
--- /dev/null
+++ b/rpg/talents.csv
@@ -0,0 +1,3 @@
+name,type,tier,aptitudes,prereq,effect,source,notes
+Ambidextrous,talent,1,"Agility; Offence","Ag 30","-10 to off-hand penalty","DH2 CRB","PRZYKŁAD – ZASTĄP"
+Aegis Discipline (GK),talent,2,"Willpower; Defence","Psyker","Reroll Perils 1×scene","HOUSE","DODAJ WŁASNY OPIS"
diff --git a/rpg/weapons.csv b/rpg/weapons.csv
new file mode 100644
index 0000000..8c9eee5
--- /dev/null
+++ b/rpg/weapons.csv
@@ -0,0 +1,3 @@
+name,type,subtype,damage,pen,range,rof,qualities,weight,availability,source,notes
+Lasgun M36,weapon,Basic,"1d10+3",0,100m,"S/3/–","Reliable",4,Common,"DH2 CRB p.142","PRZYKŁAD – ZASTĄP"
+Astartes Bolter,weapon,Basic,"1d10+9",4,90m,"S/2/–","Tearing; Unreliable",9,Rare,"DW CRB","PRZYKŁAD – ZASTĄP"
diff --git a/rpg/zealous_hatred.js b/rpg/zealous_hatred.js
new file mode 100644
index 0000000..a945ca9
--- /dev/null
+++ b/rpg/zealous_hatred.js
@@ -0,0 +1,33 @@
+// ⚡ Zealous Hatred helper (DH2) — roll 1d5 crit OR add +1d5 dmg
+new Dialog({
+ title: "⚡ Zealous Hatred",
+ content: `
+ `,
+ buttons: {
+ go: { label: "Resolve", callback: async html => {
+ const pen = html.find('[name="penetrated"]').val();
+ if (pen === "yes") {
+ const dtype = html.find('[name="dtype"]').val();
+ const loc = html.find('[name="loc"]').val();
+ const override = html.find('[name="tname"]').val()?.trim();
+ const name = override || `Crit: ${dtype} - ${loc}`;
+ const r = await (new Roll("1d5")).roll({async:true});
+ const table = game.tables.getName(name);
+ if (table) await table.draw({displayResults:true, roll:r});
+ else r.toMessage({flavor:`⚡ Zealous Hatred: Critical ${r.total} — brak tabeli ${name} (utwórz lub zmień nazwę).`});
+ } else {
+ const r = await (new Roll("1d5")).roll({async:true});
+ r.toMessage({flavor:"⚡ Zealous Hatred: Dodaj do obrażeń +1d5 (atak nie przebił Soak)."});
+ }
+ }}
+ }
+ }).render(true);
+
\ No newline at end of file
diff --git a/settings.json b/settings.json
index 497a3c4..aef5d82 100644
--- a/settings.json
+++ b/settings.json
@@ -22,20 +22,6 @@
false,
false
],
- "indie\\b": [
- "Indie? *Wyciąga rewolwerową wyrzutnię taktycznych bomb jądrowych* Gdzie??? *Zaczyna się maniakalnie śmiać*",
- 15.0,
- 0,
- false,
- false
- ],
- "hindus": [
- "Hindus? Gdzie.... *Wyciąga spod lady ciężki miotacz płomieni i zaczyna się maniakalnie śmiać*",
- 15.0,
- 0,
- false,
- false
- ],
"chuj\\b": [
"Eeeee.... Szefie... ktoś cię woła! Wskazuje na Hammera",
15.0,
@@ -99,27 +85,6 @@
false,
false
],
- "tatarek\\b": [
- "Tatarek? Nie dramatyzuj....",
- 15.0,
- 0,
- false,
- false
- ],
- "krowa\\b": [
- "Krówka? Nie dramatyzuj....",
- 15.0,
- 0,
- false,
- false
- ],
- "krówka\\b": [
- "Krówka? Nie dramatyzuj....",
- 15.0,
- 0,
- false,
- false
- ],
"jessenia\\b": [
"Jessenia? Jak przyszła tu w różowej pidżamie z uszkami to nawet nie mrugnąłem okiem. Te oczy.... No ale jakiś debil Ją prowokował mówiąc \"Zdominuj mnie\" Akurat miała dobry humor więc przeżył - ale po 115.0 sekundach był już na \"Tak Pani, przepraszam że zająłem czas Pani\" Respect dla kobitki",
15.0,
@@ -190,20 +155,6 @@
false,
false
],
- "elokwentna\\b": [
- "O matko. Znowu ta dyskusja? Hammer ma stary słownik gdzie elokwentna i pyskata są praktycznie synonimami. Jedyna różnica że swojej uległej można za bycie pyskatym dać po dupie.",
- 15.0,
- 0,
- false,
- false
- ],
- "elokwencja\\b": [
- "O matko. Znowu ta dyskusja? Hammer ma stary słownik gdzie elokwentna i pyskata są praktycznie synonimami. Jedyna różnica że swojej uległej można za bycie pyskatym dać po dupie.",
- 15.0,
- 0,
- false,
- false
- ],
"same plusy\\b": [
"Jak na cmentarzu Szefie. Jak na cmentarzu",
15.0,
@@ -253,13 +204,6 @@
false,
false
],
- "fallain\\b": [
- "Kochana Krówka. Skłonność do dramatów i zakrwawiania ścian. Jedna z kilku NAPRAWDE srogich masochistek... Ach ta krew :drool:",
- 15.0,
- 0,
- false,
- false
- ],
"roar": [
"Kici, kici....",
15.0,
@@ -316,13 +260,6 @@
false,
true
],
- "revalyacyjnie\\b": [
- "Zajebisty dowcip szefie. Na pewno się \"Twojej Byłej Dziewczynie\":tm: spodoba.",
- 15.0,
- 0,
- false,
- true
- ],
"nocna zmiana\\b": [
"Nocna Zmiana? No to właściciele tego baru. Taki troche Hammer Harema. Kto to jest Harem>? Łokurwa. Harem Hammera. Ale on tu robi za jedyną hurysę, a reszta to szejkowie. Tak zrobię Ci szejka.",
15.0,
diff --git a/thin_client.py b/thin_client.py
index 38c4fa5..93b4a35 100755
--- a/thin_client.py
+++ b/thin_client.py
@@ -69,6 +69,7 @@ async def on_ready():
await client.load_extension("other_commands")
await client.load_extension("voice_recognition_commands")
await client.load_extension("file_search_commands")
+ await client.load_extension("latex_commands")
logger.info("Sensors: online")
logger.info(client.cogs)