Compare commits
2 Commits
2025-09-01
...
2025-09-02
| Author | SHA1 | Date | |
|---|---|---|---|
| af315783e0 | |||
| 15ce02eec1 |
@@ -45,16 +45,32 @@ steps:
|
||||
commands:
|
||||
- pdf=$(ls *.pdf | head -n1)
|
||||
- tag=$(date +%F)
|
||||
# Create the release (ignore error if it already exists)
|
||||
- |
|
||||
curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
echo "Creating release for tag $tag..."
|
||||
create_resp=$(curl -s -w "%{http_code}" -o /tmp/create.json -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"$tag\", \"name\": \"$tag\", \"draft\": false, \"prerelease\": false}" \
|
||||
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases || true
|
||||
# Upload the PDF as a release asset
|
||||
- |
|
||||
curl -X POST \
|
||||
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
|
||||
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases)
|
||||
echo "Create release HTTP status: $create_resp"
|
||||
echo "Fetching release ID..."
|
||||
release_id=$(curl -s \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/tags/$tag |
|
||||
awk -F: '/"id"[ ]*:/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
|
||||
echo "Release ID = $release_id"
|
||||
echo "Checking if asset $pdf already exists..."
|
||||
assets=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
|
||||
echo "Assets response: $assets"
|
||||
if echo "$assets" | grep -q "\"name\":\"$pdf\""; then
|
||||
echo "Asset $pdf already uploaded, skipping."
|
||||
exit 0
|
||||
fi
|
||||
echo "Uploading $pdf to release $release_id..."
|
||||
upload_resp=$(curl -s -w "%{http_code}" -o /tmp/upload.json -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-F "attachment=@$pdf" \
|
||||
"https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$tag/assets"
|
||||
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
|
||||
echo "Upload HTTP status: $upload_resp"
|
||||
echo "Upload response: $(cat /tmp/upload.json)"
|
||||
|
||||
@@ -63,7 +63,7 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
|
||||
* Three-column layout:
|
||||
|
||||
* Column 1: Map, Adventure Hooks, Rumors
|
||||
* Column 2: Keyed Rooms
|
||||
* Column 2: Rooms
|
||||
* Column 3: Encounters, Treasure, NPCs
|
||||
|
||||
---
|
||||
|
||||
@@ -62,15 +62,15 @@ async function callOllama(prompt, model = "gemma3n:e4b", retries = 5, stepName =
|
||||
|
||||
function parseList(raw) {
|
||||
return raw
|
||||
.split(/\n|(?=\d+[).]\s)/g)
|
||||
.map(line => cleanText(line.replace(/^\d+[).\s-]*/, "")))
|
||||
.split(/\n?\d+[).]\s+/)
|
||||
.map(line => cleanText(line))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseObjects(raw, type = "rooms") {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map(line => cleanText(line.replace(/^\d+[).\s-]*/, "")))
|
||||
.split(/\n?\d+[).]\s+/)
|
||||
.map(entry => cleanText(entry))
|
||||
.filter(Boolean)
|
||||
.map(entry => {
|
||||
const [name, ...descParts] = entry.split(/[-–—:]/);
|
||||
@@ -97,7 +97,7 @@ Each title should come from a different style or theme. Make the set varied and
|
||||
- Weird fantasy: uncanny, surreal, unsettling
|
||||
- Whimsical: fun, quirky, playful
|
||||
|
||||
Avoid repeating materials or adjectives. Do not include explanations, markdown, or preambles. Only the 10 numbered titles.`,
|
||||
Avoid repeating materials or adjectives. Avoid the words "obsidian" and "clockwork". Do not include explanations, markdown, or preambles. Only the 10 numbered titles.`,
|
||||
undefined, 5, "Step 1: Titles"
|
||||
);
|
||||
const titles10 = parseList(titles10Raw, 30);
|
||||
@@ -108,7 +108,7 @@ Avoid repeating materials or adjectives. Do not include explanations, markdown,
|
||||
`Here are 10 dungeon titles:
|
||||
${titles10.join("\n")}
|
||||
|
||||
Select the 3 most interesting titles from the above list and create 2 additional unique titles.
|
||||
Randomly select 3 of the titles from the above list and create 2 additional unique titles. Avoid the words "obsidian" and "clockwork".
|
||||
Output exactly 5 titles as a numbered list, plain text only. No explanations.`,
|
||||
undefined, 5, "Step 2: Narrow Titles"
|
||||
);
|
||||
@@ -117,7 +117,7 @@ Output exactly 5 titles as a numbered list, plain text only. No explanations.`,
|
||||
|
||||
// --- Step 3: Final title ---
|
||||
const bestTitleRaw = await callOllama(
|
||||
`From the following 5 dungeon titles, select the one that sounds the most fun to play.
|
||||
`From the following 5 dungeon titles, randomly select only one of them.
|
||||
Output only the title, no explanation, no numbering, no extra text:
|
||||
|
||||
${titles5.join("\n")}`,
|
||||
@@ -129,7 +129,7 @@ ${titles5.join("\n")}`,
|
||||
// --- Step 4: Flavor text ---
|
||||
const flavorRaw = await callOllama(
|
||||
`Write a single evocative paragraph describing the dungeon titled "${title}".
|
||||
Do not include hooks, NPCs, treasure, or instructions. Output plain text only, one paragraph.`,
|
||||
Do not include hooks, NPCs, treasure, or instructions. Output plain text only, one paragraph. Maximum 4 sentences.`,
|
||||
undefined, 5, "Step 4: Flavor"
|
||||
);
|
||||
const flavor = flavorRaw;
|
||||
@@ -141,40 +141,23 @@ Do not include hooks, NPCs, treasure, or instructions. Output plain text only, o
|
||||
|
||||
${flavor}
|
||||
|
||||
Generate 3 adventure hooks (one sentence each) and 3 rumors (one sentence each).
|
||||
Output numbered lists only, plain text. Maximum 120 characters per item. No explanations or extra text.
|
||||
Format as:
|
||||
|
||||
Hooks:
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
Rumors:
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...`
|
||||
,
|
||||
Generate 3 short adventure hooks or rumors (mix them naturally).
|
||||
Output as a single numbered list, plain text only.
|
||||
Maximum 2 sentences per item. No explanations or extra text.`,
|
||||
undefined, 5, "Step 5: Hooks & Rumors"
|
||||
);
|
||||
const [hooksSection, rumorsSection] = hooksRumorsRaw.split(/Rumors[:\n]/i);
|
||||
const hooks = parseList(hooksSection.replace(/Hooks[:\n]*/i, ""), 120);
|
||||
const rumors = parseList(rumorsSection || "", 120);
|
||||
console.log("🔹 Hooks:", hooks);
|
||||
console.log("🔹 Rumors:", rumors);
|
||||
const hooksRumors = parseList(hooksRumorsRaw, 120);
|
||||
console.log("🔹 Hooks & Rumors:", hooksRumors);
|
||||
|
||||
// --- Step 6: Rooms & Encounters ---
|
||||
const roomsEncountersRaw = await callOllama(
|
||||
`Using the flavor, hooks, and rumors:
|
||||
`Using the flavor and these hooks/rumors:
|
||||
|
||||
Flavor:
|
||||
${flavor}
|
||||
|
||||
Hooks:
|
||||
${hooks.join("\n")}
|
||||
|
||||
Rumors:
|
||||
${rumors.join("\n")}
|
||||
Hooks & Rumors:
|
||||
${hooksRumors.join("\n")}
|
||||
|
||||
Generate 5 rooms (name + short description) and 3 encounters (name + details).
|
||||
Output two numbered lists, labeled "Rooms:" and "Encounters:". Plain text only. No extra explanation.`,
|
||||
@@ -197,11 +180,33 @@ Output numbered lists labeled "Treasure:" and "NPCs:". Plain text only, no extra
|
||||
undefined, 5, "Step 7: Treasure & NPCs"
|
||||
);
|
||||
const [treasureSection, npcsSection] = treasureNpcsRaw.split(/NPCs[:\n]/i);
|
||||
const treasure = parseList(treasureSection.replace(/Treasure[:\n]*/i, ""), 120);
|
||||
const treasure = parseList(treasureSection.replace(/Treasures?[:\n]*/i, ""), 120);
|
||||
const npcs = parseObjects(npcsSection || "", "npcs", 120);
|
||||
console.log("🔹 Treasure:", treasure);
|
||||
console.log("🔹 NPCs:", npcs);
|
||||
|
||||
// --- Step 8: Plot Resolutions ---
|
||||
const plotResolutionsRaw = await callOllama(
|
||||
`Based on the following dungeon flavor and story hooks:
|
||||
|
||||
Flavor:
|
||||
${flavor}
|
||||
|
||||
Hooks & Rumors:
|
||||
${hooksRumors.join("\n")}
|
||||
|
||||
Major NPCs / Encounters:
|
||||
${[...npcs.map(n => n.name), ...encounters.map(e => e.name)].join(", ")}
|
||||
|
||||
Suggest 3 possible, non-conflicting story climaxes or plot resolutions for adventurers exploring this dungeon.
|
||||
These are prompts and ideas for brainstorming the dungeon's ending, not fixed outcomes.
|
||||
Start each item with phrases like "The adventurers could..." or "The PCs might..." to emphasize their hypothetical nature.
|
||||
Keep each item short (max 2 sentences). Output as a numbered list, plain text only.`,
|
||||
undefined, 5, "Step 8: Plot Resolutions"
|
||||
);
|
||||
const plotResolutions = parseList(plotResolutionsRaw, 180);
|
||||
console.log("🔹 Plot Resolutions:", plotResolutions);
|
||||
|
||||
console.log("\n🎉 Dungeon generation complete!");
|
||||
return { title, flavor, map: "map.png", hooks, rumors, rooms, encounters, treasure, npcs };
|
||||
return { title, flavor, map: "map.png", hooksRumors, rooms, encounters, treasure, npcs, plotResolutions };
|
||||
}
|
||||
|
||||
@@ -4,36 +4,31 @@ function pickRandom(arr) {
|
||||
|
||||
export function dungeonTemplate(data) {
|
||||
const bodyFonts = [
|
||||
"'Libre Baskerville', serif",
|
||||
"'Cardo', serif",
|
||||
"'Lora', serif",
|
||||
"'Merriweather', serif",
|
||||
"'Fraunces', serif",
|
||||
"'Source Serif 4', serif",
|
||||
"'Lora', serif"
|
||||
"'Libre Baskerville', serif",
|
||||
"'Source Serif 4', serif"
|
||||
];
|
||||
|
||||
const headingFonts = [
|
||||
"'Cinzel Decorative', cursive",
|
||||
"'MedievalSharp', cursive",
|
||||
"'Metamorphous', cursive",
|
||||
"'Playfair Display', serif",
|
||||
"'Alegreya Sans SC', sans-serif"
|
||||
"'Playfair Display', serif"
|
||||
];
|
||||
|
||||
const tableFonts = [
|
||||
"'Alegreya Sans', sans-serif",
|
||||
"'Cabin', sans-serif",
|
||||
"'IBM Plex Sans', sans-serif",
|
||||
"'Cormorant Garamond', serif",
|
||||
"'Special Elite', monospace"
|
||||
"'Cormorant Garamond', serif"
|
||||
];
|
||||
|
||||
const quoteFonts = [
|
||||
"'Walter Turncoat', cursive",
|
||||
"'Uncial Antiqua', serif",
|
||||
"'Beth Ellen', cursive",
|
||||
"'Pinyon Script', cursive",
|
||||
"'Dela Gothic One', sans-serif"
|
||||
"'Pinyon Script', cursive"
|
||||
];
|
||||
|
||||
const bodyFont = pickRandom(bodyFonts);
|
||||
@@ -47,62 +42,69 @@ export function dungeonTemplate(data) {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${data.title}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville&family=Lora&family=Cinzel+Decorative&family=MedievalSharp&family=Alegreya+Sans+SC&family=Alegreya+Sans&family=Cabin&family=Walter+Turncoat&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative&family=MedievalSharp&family=Metamorphous&family=Playfair+Display&family=Alegreya+Sans&family=Cabin&family=IBM+Plex+Sans&family=Cormorant+Garamond&family=Lora&family=Merriweather&family=Libre+Baskerville&family=Source+Serif+4&family=Walter+Turncoat&family=Uncial+Antiqua&family=Beth+Ellen&family=Pinyon+Script&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
body {
|
||||
margin: 0; padding: 1cm;
|
||||
background: #d6c5a3;
|
||||
margin: 0; padding: 1.5cm;
|
||||
background: #f5f5f5;
|
||||
font-family: ${bodyFont};
|
||||
color: #2b2118;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.3em;
|
||||
color: #1a1a1a;
|
||||
font-size: 0.7em;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
h1 {
|
||||
font-family: ${headingFont};
|
||||
text-align: center;
|
||||
font-size: 2em;
|
||||
margin: 0.2em 0 0.3em;
|
||||
color: #3e1f0e;
|
||||
border-bottom: 2px solid #3e1f0e;
|
||||
color: #1a1a1a;
|
||||
border-bottom: 2px solid #1a1a1a;
|
||||
padding-bottom: 0.2em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.flavor {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
font-family: ${quoteFont};
|
||||
margin: 0.4em 0 1em;
|
||||
font-size: 0.95em;
|
||||
margin: 0.4em 0 0.8em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.8cm;
|
||||
gap: 0.5cm;
|
||||
align-items: start;
|
||||
}
|
||||
.col {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 0.4em;
|
||||
gap: 0.15em;
|
||||
}
|
||||
h2 {
|
||||
font-family: ${headingFont};
|
||||
font-size: 1em;
|
||||
font-size: 1.0em;
|
||||
margin: 0.3em 0 0.1em;
|
||||
color: #3e1f0e;
|
||||
border-bottom: 1px solid #3e1f0e;
|
||||
color: #1a1a1a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
padding-bottom: 0.1em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.map img { max-width: 100%; border: 2px solid #3e1f0e; border-radius: 0.2cm; }
|
||||
.room h3 { margin: 0.2em 0 0.05em; font-size: 0.95em; font-weight: bold; }
|
||||
.room p { text-align: justify; word-wrap: break-word; margin: 0.1em 0 0.3em; }
|
||||
ul { padding-left: 1em; margin: 0.1em 0 0.4em; }
|
||||
ul { padding-left: 1em; margin: 0.1em 0 0.3em; }
|
||||
li { margin-bottom: 0.2em; }
|
||||
table { width: 100%; border-collapse: collapse; font-family: ${tableFont}; font-size: 0.8em; }
|
||||
th, td { border: 1px solid #3e1f0e; padding: 0.2em; text-align: left; vertical-align: top; }
|
||||
th { background: #d9c6a5; }
|
||||
table tr:hover { background: rgba(62,31,14,0.05); }
|
||||
th, td { border: 1px solid #1a1a1a; padding: 0.2em; text-align: left; vertical-align: top; }
|
||||
th { background: #e0e0e0; }
|
||||
table tr:hover { background: rgba(0, 0, 0, 0.05); }
|
||||
.map-page {
|
||||
page-break-before: always;
|
||||
text-align: center;
|
||||
}
|
||||
.map-page img { max-width: 100%; max-height: 27cm; border: 2px solid #1a1a1a; border-radius: 0.2cm; }
|
||||
footer {
|
||||
text-align: center; font-size: 0.7em; color: #5a4632; margin-top: 0.5em; font-style: italic;
|
||||
text-align: center; font-size: 0.65em; color: #555; margin-top: 0.5em; font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -112,18 +114,10 @@ export function dungeonTemplate(data) {
|
||||
|
||||
<div class="columns">
|
||||
<div class="col">
|
||||
<h2>Map</h2>
|
||||
<div class="map"><img src="file://${data.map}" alt="Dungeon Map"></div>
|
||||
<h2>Adventure Hooks & Rumors</h2>
|
||||
<ul>${data.hooksRumors.map(item => `<li>${item}</li>`).join("")}</ul>
|
||||
|
||||
<h2>Adventure Hooks</h2>
|
||||
<ul>${data.hooks.map(h => `<li>${h}</li>`).join("")}</ul>
|
||||
|
||||
<h2>Rumors</h2>
|
||||
<ul>${data.rumors.map(r => `<li>${r}</li>`).join("")}</ul>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h2>Keyed Rooms</h2>
|
||||
<h2>Locations</h2>
|
||||
${data.rooms.map((room, i) => `<div class="room"><h3>${i + 1}. ${room.name}</h3><p>${room.description}</p></div>`).join("")}
|
||||
</div>
|
||||
|
||||
@@ -135,12 +129,22 @@ export function dungeonTemplate(data) {
|
||||
|
||||
<h2>Treasure</h2>
|
||||
<ul>${data.treasure.map(t => `<li>${t}</li>`).join("")}</ul>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h2>NPCs</h2>
|
||||
<ul>${data.npcs.map(n => `<li><b>${n.name}</b>: ${n.trait}</li>`).join("")}</ul>
|
||||
|
||||
<h2>Plot Resolutions</h2>
|
||||
<ul>${data.plotResolutions.map(p => `<li>${p}</li>`).join("")}</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-page">
|
||||
<h2>Dungeon Map</h2>
|
||||
<img src="file://${data.map}" alt="Dungeon Map">
|
||||
</div>
|
||||
|
||||
<footer>Generated with Scrollsmith • © ${new Date().getFullYear()}</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "auto-dm",
|
||||
"name": "scrollsmith",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user