Tag: 1.27

Intermediate commits (oldest → newest):
- Merge branch 'main' of https://github.com/migatu/conjurer
- Update AI and librarian commands to use 'GENERAL' context"
- Added the following changes to the codebase:
- bgfx
- fx and upgd
- Latex incoming changes
- installer fix
- var fx
- fx to fx
- don't trust ai
- tectonic
- debug
- debug
- disable exc masking
- upgrade
- rollback
- Figure out
- aaa
This commit is contained in:
2025-10-30 16:59:21 +01:00
parent 2aeb3d286f
commit a6ed8723a5
29 changed files with 988 additions and 155 deletions
+2 -2
View File
@@ -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}"
+117 -74
View File
@@ -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):
"""
+11
View File
@@ -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
+9 -1
View File
@@ -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"
+40
View File
@@ -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
+40
View File
@@ -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
+142
View File
@@ -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")
+233
View File
@@ -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
+30 -15
View File
@@ -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)
+28
View File
@@ -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()
+1
View File
@@ -13,6 +13,7 @@ spotipy
tiktoken
PyNaCl
flask[async]
PyMuPDF
waitress
clickupython
assemblyai[extras]
+3
View File
@@ -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"
1 name type locations ap_head ap_body ap_larm ap_rarm ap_lleg ap_rleg max_ag traits weight availability source notes
2 Flak Vest armor Body 0 4 0 0 0 0 Flak 7 Common DH2 CRB PRZYKŁAD – ZASTĄP
3 Power Armour (Astartes) armor All 8 10 8 8 9 9 Environmental; Auto-senses 100 Very Rare DW CRB PRZYKŁAD – ZASTĄP
+60
View File
@@ -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: `
<form>
<div class="form-group"><label>Base Target (WS/BS)</label><input name="base" type="number" value="40"/></div>
<div class="form-group"><label>Aim</label>
<select name="aim"><option value="0">None</option><option value="10">Half (+10)</option><option value="20">Full (+20)</option></select></div>
<div class="form-group"><label>Range</label>
<select name="range">
<option value="0">Standard</option>
<option value="30">Point Blank (+30)</option>
<option value="10">Short (+10)</option>
<option value="-10">Long (-10)</option>
<option value="-30">Extreme (-30)</option>
</select></div>
<div class="form-group"><label>Fire / Attack</label>
<select name="stance">
<option value="0">Standard / Single</option>
<option value="10">Semi (+10)</option>
<option value="-10">Full Auto (-10)</option>
<option value="30">All Out (Melee +30)</option>
</select></div>
<div class="form-group"><label>Target Size</label>
<select name="size">
<option value="0">Average</option>
<option value="10">Hulking (+10)</option>
<option value="20">Enormous (+20)</option>
<option value="30">Massive (+30)</option>
<option value="-10">Puny (-10)</option>
</select></div>
<div class="form-group"><label>Lighting</label>
<select name="light"><option value="0">Normal</option><option value="10">Good (+10)</option><option value="-10">Poor (-10)</option></select></div>
<div class="form-group"><label>Cover</label>
<select name="cover"><option value="0">None</option><option value="-10">Light (-10)</option><option value="-20">Heavy (-20)</option></select></div>
<div class="form-group"><label>Other Modifiers</label><input name="mod" type="number" value="0"/></div>
</form>`,
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 = `
<table style="width:100%;border-collapse:collapse">
<tr><td><b>Target</b></td><td>${total}</td><td><b>Roll</b></td><td>${r.total}</td></tr>
<tr><td><b>Result</b></td><td colspan="3">${ok?'<span style="color:green">SUCCESS</span>':'<span style="color:red">FAIL</span>'}${ok?dox+' DoS':dox+' DoF'}</td></tr>
</table>`;
r.toMessage({speaker: ChatMessage.getSpeaker({actor}), flavor: `🎯 <b>Attack Test</b><br/>${table}`});
}
}
}
}).render(true);
+6
View File
@@ -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
1 Roll Result
2 1 Energy/Head — wpis 1
3 2 Energy/Head — wpis 2
4 3 Energy/Head — wpis 3
5 4 Energy/Head — wpis 4
6 5 Energy/Head — wpis 5
+50
View File
@@ -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: `
<form>
<div class="form-group"><label>Willpower (target)</label><input name="wp" type="number" value="40"/></div>
<div class="form-group"><label>Psychic Rating (PR)</label><input name="pr" type="number" value="3"/></div>
<div class="form-group"><label>Mode</label>
<select name="mode"><option value="fettered">Fettered (no PP; PR/2)</option><option value="unfettered" selected>Unfettered (PP on doubles)</option><option value="push">Push (always PP; +PR)</option></select></div>
<div class="form-group"><label>Power difficulty/gear/etc. (flat mod)</label><input name="flat" type="number" value="0"/></div>
<div class="form-group"><label>Perils threshold</label><input name="thr" type="number" value="75"/></div>
</form>`,
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 = `<table style="width:100%;border-collapse:collapse">
<tr><td><b>Target</b></td><td>${target}</td><td><b>Roll</b></td><td>${roll.total}</td></tr>
<tr><td><b>Result</b></td><td colspan="3">${ok?'<span style="color:green">SUCCESS</span>':'<span style="color:red">FAIL</span>'}${ok?dos+' DoS':dos+' DoF'} ${doubles?' — <b>DOUBLES</b>':''}</td></tr>
<tr><td><b>Eff. PR</b></td><td>${effPR}</td><td><b>Range hint</b></td><td>${effPR*10} m (jeśli moc tak działa)</td></tr>
</table>`;
roll.toMessage({speaker: ChatMessage.getSpeaker({actor}), flavor:`🧠 <b>Focus Power</b> ${note}<br/>${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: <b>Psychic Phenomena</b> (+ <b>Perils of the Warp</b>)"});
}
}}
}
}).render(true);
+22
View File
@@ -0,0 +1,22 @@
// 🎯 Hit Location (DH mapping by reversed roll)
new Dialog({
title:"🎯 Hit Location",
content:`<form>
<div class="form-group"><label>Attack d100 roll</label><input name="roll" type="number" value="37"/></div>
</form>`,
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:`🎯 <b>Hit Location</b>: roll ${n} → reversed ${rev} → <b>${loc}</b>`});
}}
}
}).render(true);
+6
View File
@@ -0,0 +1,6 @@
const text=`<b>House Rules — DH2 chassis</b><br/>
• Unnatural mapowanie: dawne ×2 bonus → Unnatural (+X) tak, by SB/TB odzwierciedlały linię źródłową.<br/>
• Pancerz Astartes: zachowaj AP; zużycie zasilania jako +1 Fatigue co X scen zamiast liczenia minut.<br/>
• Aptitudes: archetypy z RT/DW/BC mają przypisane 23 Aptitudes DH2 dla kosztów XP.<br/>
• Psy: testy i PR z DH2 dla wszystkich; GK posiada Aegis Discipline (1×/scena reroll Perils) + 12 signature powers z DW.<br/>
• RT Acquisition → DH2 Influence z modyfikatorami kontekstowymi (teatr działań, mandat Inkwizycji, czas).`;ChatMessage.create({content:text});
+11
View File
@@ -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:`<input type="number" value="4">`, 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:`🚦 <b>Initiative</b> — ${t.name}: ${r.total}`});
}
ui.notifications.info("Inicjatywy ustawione.");
+21
View File
@@ -0,0 +1,21 @@
Roll,Result
1-5,Perils 15 — WPISZ
6-10,Perils 610 — WPISZ
11-15,Perils 1115 — WPISZ
16-20,Perils 1620 — WPISZ
21-25,Perils 2125 — WPISZ
26-30,Perils 2630 — WPISZ
31-35,Perils 3135 — WPISZ
36-40,Perils 3640 — WPISZ
41-45,Perils 4145 — WPISZ
46-50,Perils 4650 — WPISZ
51-55,Perils 5155 — WPISZ
56-60,Perils 5660 — WPISZ
61-65,Perils 6165 — WPISZ
66-70,Perils 6670 — WPISZ
71-75,Perils 7175 — WPISZ
76-80,Perils 7680 — WPISZ
81-85,Perils 8185 — WPISZ
86-90,Perils 8690 — WPISZ
91-95,Perils 9195 — WPISZ
96-100,Perils 96100 — WPISZ
1 Roll Result
2 1-5 Perils 1–5 — WPISZ
3 6-10 Perils 6–10 — WPISZ
4 11-15 Perils 11–15 — WPISZ
5 16-20 Perils 16–20 — WPISZ
6 21-25 Perils 21–25 — WPISZ
7 26-30 Perils 26–30 — WPISZ
8 31-35 Perils 31–35 — WPISZ
9 36-40 Perils 36–40 — WPISZ
10 41-45 Perils 41–45 — WPISZ
11 46-50 Perils 46–50 — WPISZ
12 51-55 Perils 51–55 — WPISZ
13 56-60 Perils 56–60 — WPISZ
14 61-65 Perils 61–65 — WPISZ
15 66-70 Perils 66–70 — WPISZ
16 71-75 Perils 71–75 — WPISZ
17 76-80 Perils 76–80 — WPISZ
18 81-85 Perils 81–85 — WPISZ
19 86-90 Perils 86–90 — WPISZ
20 91-95 Perils 91–95 — WPISZ
21 96-100 Perils 96–100 — WPISZ
+3
View File
@@ -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"
1 name type discipline action test range sustained effect source notes
2 Smite power Biomancy Half WP Challenging (+0) PR*10m No 1d10+PR E; Tearing DH2 CRB PRZYKŁAD – ZASTĄP
3 Foreboding power Divination Reaction Per Difficult (-10) Self No Use as Evasion; DoS rules DH2 CRB PRZYKŁAD – ZASTĄP
+21
View File
@@ -0,0 +1,21 @@
Roll,Result
1-5,PP 15 — WPISZ
6-10,PP 610 — WPISZ
11-15,PP 1115 — WPISZ
16-20,PP 1620 — WPISZ
21-25,PP 2125 — WPISZ
26-30,PP 2630 — WPISZ
31-35,PP 3135 — WPISZ
36-40,PP 3640 — WPISZ
41-45,PP 4145 — WPISZ
46-50,PP 4650 — WPISZ
51-55,PP 5155 — WPISZ
56-60,PP 5660 — WPISZ
61-65,PP 6165 — WPISZ
66-70,PP 6670 — WPISZ
71-75,PP 7175 — WPISZ
76-80,PP 7680 — WPISZ
81-85,PP 8185 — WPISZ
86-90,PP 8690 — WPISZ
91-95,PP 9195 — WPISZ
96-100,PP 96100 — WPISZ
1 Roll Result
2 1-5 PP 1–5 — WPISZ
3 6-10 PP 6–10 — WPISZ
4 11-15 PP 11–15 — WPISZ
5 16-20 PP 16–20 — WPISZ
6 21-25 PP 21–25 — WPISZ
7 26-30 PP 26–30 — WPISZ
8 31-35 PP 31–35 — WPISZ
9 36-40 PP 36–40 — WPISZ
10 41-45 PP 41–45 — WPISZ
11 46-50 PP 46–50 — WPISZ
12 51-55 PP 51–55 — WPISZ
13 56-60 PP 56–60 — WPISZ
14 61-65 PP 61–65 — WPISZ
15 66-70 PP 66–70 — WPISZ
16 71-75 PP 71–75 — WPISZ
17 76-80 PP 76–80 — WPISZ
18 81-85 PP 81–85 — WPISZ
19 86-90 PP 86–90 — WPISZ
20 91-95 PP 91–95 — WPISZ
21 96-100 PP 96–100 — WPISZ
+28
View File
@@ -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=>`<label><input type="checkbox" name="c" value="${c.id}"> ${c.label}</label>`).join("<br/>");
new Dialog({
title:"🩹 Conditions",
content:`<form>${opts}<div class="form-group"><label>Mode</label>
<select name="mode"><option value="toggle">Toggle</option><option value="on">Apply</option><option value="off">Remove</option></select></div></form>`,
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);
+38
View File
@@ -0,0 +1,38 @@
// 💥 Quick Damage — supports Tearing, Proven(X), Primitive(X), flat mod
new Dialog({
title:"💥 Damage Roller",
content: `
<form>
<div class="form-group"><label>Flat modifier (e.g., +3)</label><input name="mod" type="number" value="0"/></div>
<div class="form-group"><label>Traits</label>
<label><input type="checkbox" name="tear"> Tearing</label>
<label><input type="checkbox" name="prov"> Proven</label>
<input name="provV" type="number" value="0" style="width:60px" placeholder="X"/>
<label><input type="checkbox" name="prim"> Primitive</label>
<input name="primV" type="number" value="0" style="width:60px" placeholder="X"/>
</div>
</form>`,
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 = `💥 <b>Damage</b><br/>Die: ${die}${tearing?` (Tearing ${r1.total}/${r2.total.total})`:''} + Mod ${mod} = <b>${total}</b>`;
if (proven>0) flavor += `<br/>Proven(${proven}) zastosowano`;
if (primitive>0) flavor += `<br/>Primitive(${primitive}) zastosowano`;
if (rf) flavor += `<br/><b>⚡ Natural 10</b> — rozważ Zealous Hatred.`;
ChatMessage.create({speaker: ChatMessage.getSpeaker(), content: flavor});
}}
}
}).render(true);
+26
View File
@@ -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.");
+3
View File
@@ -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"
1 name type tier aptitudes prereq effect source notes
2 Ambidextrous talent 1 Agility; Offence Ag 30 -10 to off-hand penalty DH2 CRB PRZYKŁAD – ZASTĄP
3 Aegis Discipline (GK) talent 2 Willpower; Defence Psyker Reroll Perils 1×scene HOUSE DODAJ WŁASNY OPIS
+3
View File
@@ -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"
1 name type subtype damage pen range rof qualities weight availability source notes
2 Lasgun M36 weapon Basic 1d10+3 0 100m S/3/– Reliable 4 Common DH2 CRB p.142 PRZYKŁAD – ZASTĄP
3 Astartes Bolter weapon Basic 1d10+9 4 90m S/2/– Tearing; Unreliable 9 Rare DW CRB PRZYKŁAD – ZASTĄP
+33
View File
@@ -0,0 +1,33 @@
// ⚡ Zealous Hatred helper (DH2) — roll 1d5 crit OR add +1d5 dmg
new Dialog({
title: "⚡ Zealous Hatred",
content: `
<form>
<div class="form-group"><label>Wound damage after Armour/TB?</label>
<select name="penetrated"><option value="yes">Yes roll Critical (1d5)</option><option value="no">No add +1d5 damage</option></select></div>
<div class="form-group"><label>Damage Type</label>
<select name="dtype"><option>Energy</option><option>Impact</option><option>Rending</option><option>Explosive</option></select></div>
<div class="form-group"><label>Hit Location</label>
<select name="loc"><option>Head</option><option>Body</option><option>Left Arm</option><option>Right Arm</option><option>Left Leg</option><option>Right Leg</option></select></div>
<div class="form-group"><label>Table name (optional override)</label><input name="tname" type="text" placeholder="Crit: Energy - Head"/></div>
</form>`,
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:`⚡ <b>Zealous Hatred</b>: Critical ${r.total} — brak tabeli <b>${name}</b> (utwórz lub zmień nazwę).`});
} else {
const r = await (new Roll("1d5")).roll({async:true});
r.toMessage({flavor:"⚡ <b>Zealous Hatred</b>: Dodaj do obrażeń <b>+1d5</b> (atak nie przebił Soak)."});
}
}}
}
}).render(true);
-63
View File
@@ -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,
+1
View File
@@ -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)