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:`
${opts}
+
`, + 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)