Spaces:
Sleeping
Sleeping
Merge branch 'feat/better-maps' of github.com:ASH1998/Rune-Goblin into feat/better-maps
Browse files- app/rpg_app.py +37 -2
- app/rpg_static/rpg.css +54 -0
- app/rpg_static/rpg.js +131 -7
- src/rune_goblin/world.py +49 -9
- tests/test_world_cast.py +40 -1
app/rpg_app.py
CHANGED
|
@@ -46,7 +46,7 @@ PLAY_PAGE = """<!doctype html>
|
|
| 46 |
<title>Rune Goblin RPG</title>
|
| 47 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 48 |
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
| 49 |
-
<link rel="stylesheet" href="/rg/static/rpg.css?v=
|
| 50 |
</head>
|
| 51 |
<body>
|
| 52 |
<div id="rg-root">
|
|
@@ -95,6 +95,41 @@ PLAY_PAGE = """<!doctype html>
|
|
| 95 |
<div class="rg-draw" id="rg-draw">
|
| 96 |
<h3>Draw your spell</h3>
|
| 97 |
<canvas id="rg-sketch" width="340" height="340"></canvas>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
<div class="row">
|
| 99 |
<button class="rg-btn primary" id="rg-draw-cast">🔮 Cast Drawing</button>
|
| 100 |
<button class="rg-btn" id="rg-draw-clear">Clear</button>
|
|
@@ -131,7 +166,7 @@ PLAY_PAGE = """<!doctype html>
|
|
| 131 |
<div class="rg-hint">WASD / Arrows move · 1–9 pick runes · Space cast · E draw · C clear · M minimap · step into portals to travel</div>
|
| 132 |
</div>
|
| 133 |
</div>
|
| 134 |
-
<script src="/rg/static/rpg.js?v=
|
| 135 |
</body>
|
| 136 |
</html>
|
| 137 |
"""
|
|
|
|
| 46 |
<title>Rune Goblin RPG</title>
|
| 47 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 48 |
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
| 49 |
+
<link rel="stylesheet" href="/rg/static/rpg.css?v=66">
|
| 50 |
</head>
|
| 51 |
<body>
|
| 52 |
<div id="rg-root">
|
|
|
|
| 95 |
<div class="rg-draw" id="rg-draw">
|
| 96 |
<h3>Draw your spell</h3>
|
| 97 |
<canvas id="rg-sketch" width="340" height="340"></canvas>
|
| 98 |
+
<button class="rg-btn rg-customize" id="rg-draw-customize" type="button" aria-expanded="false" aria-controls="rg-draw-tools">Customize</button>
|
| 99 |
+
<div class="rg-draw-tools" id="rg-draw-tools" aria-label="Drawing controls">
|
| 100 |
+
<div class="rg-tool-group">
|
| 101 |
+
<span>Ink</span>
|
| 102 |
+
<button class="rg-swatch on" type="button" data-rg-ink="#17120b" title="Black ink" style="--swatch:#17120b"></button>
|
| 103 |
+
<button class="rg-swatch" type="button" data-rg-ink="#7b2cff" title="Violet ink" style="--swatch:#7b2cff"></button>
|
| 104 |
+
<button class="rg-swatch" type="button" data-rg-ink="#008bd8" title="Blue ink" style="--swatch:#008bd8"></button>
|
| 105 |
+
<button class="rg-swatch" type="button" data-rg-ink="#14893c" title="Green ink" style="--swatch:#14893c"></button>
|
| 106 |
+
<button class="rg-swatch" type="button" data-rg-ink="#c23b22" title="Red ink" style="--swatch:#c23b22"></button>
|
| 107 |
+
<button class="rg-swatch" type="button" data-rg-ink="#ffb000" title="Gold ink" style="--swatch:#ffb000"></button>
|
| 108 |
+
<button class="rg-swatch" type="button" data-rg-ink="#ffffff" title="White ink" style="--swatch:#ffffff"></button>
|
| 109 |
+
<button class="rg-swatch" type="button" data-rg-ink="#ff5db1" title="Pink ink" style="--swatch:#ff5db1"></button>
|
| 110 |
+
<button class="rg-swatch" type="button" data-rg-ink="#00c7a8" title="Teal ink" style="--swatch:#00c7a8"></button>
|
| 111 |
+
</div>
|
| 112 |
+
<div class="rg-tool-group">
|
| 113 |
+
<span>Fill</span>
|
| 114 |
+
<button class="rg-fill on" type="button" data-rg-fill="#ffe7a8" title="Gold fill" style="--swatch:#ffe7a8"></button>
|
| 115 |
+
<button class="rg-fill" type="button" data-rg-fill="#b9f6ca" title="Green fill" style="--swatch:#b9f6ca"></button>
|
| 116 |
+
<button class="rg-fill" type="button" data-rg-fill="#a6ddff" title="Blue fill" style="--swatch:#a6ddff"></button>
|
| 117 |
+
<button class="rg-fill" type="button" data-rg-fill="#ffc1d9" title="Pink fill" style="--swatch:#ffc1d9"></button>
|
| 118 |
+
<button class="rg-fill" type="button" data-rg-fill="#d4c2ff" title="Violet fill" style="--swatch:#d4c2ff"></button>
|
| 119 |
+
<button class="rg-fill" type="button" data-rg-fill="#ffffff" title="White fill" style="--swatch:#ffffff"></button>
|
| 120 |
+
</div>
|
| 121 |
+
<div class="rg-tool-group rg-size-tool">
|
| 122 |
+
<span>Size</span>
|
| 123 |
+
<input id="rg-line-size" type="range" min="3" max="18" value="7" aria-label="Line size">
|
| 124 |
+
</div>
|
| 125 |
+
<div class="rg-tool-group">
|
| 126 |
+
<span>Line</span>
|
| 127 |
+
<button class="rg-line-style on" type="button" data-rg-line="round" title="Round line">●</button>
|
| 128 |
+
<button class="rg-line-style" type="button" data-rg-line="square" title="Square line">■</button>
|
| 129 |
+
<button class="rg-line-style" type="button" data-rg-line="dot" title="Dotted line">···</button>
|
| 130 |
+
<button class="rg-line-style" type="button" data-rg-line="soft" title="Soft marker">◌</button>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
<div class="row">
|
| 134 |
<button class="rg-btn primary" id="rg-draw-cast">🔮 Cast Drawing</button>
|
| 135 |
<button class="rg-btn" id="rg-draw-clear">Clear</button>
|
|
|
|
| 166 |
<div class="rg-hint">WASD / Arrows move · 1–9 pick runes · Space cast · E draw · C clear · M minimap · step into portals to travel</div>
|
| 167 |
</div>
|
| 168 |
</div>
|
| 169 |
+
<script src="/rg/static/rpg.js?v=68"></script>
|
| 170 |
</body>
|
| 171 |
</html>
|
| 172 |
"""
|
app/rpg_static/rpg.css
CHANGED
|
@@ -56,6 +56,29 @@ html, body {
|
|
| 56 |
.rg-btn:active { transform:translatey(1px); }
|
| 57 |
.rg-btn.primary { background:var(--accent); color:#160d24; font-weight:bold; }
|
| 58 |
.rg-btn.ghost { background:transparent; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
.rg-admin-goto { background:#173b2a; color:var(--green); border-color:#2f6f4d; max-width:200px; }
|
| 60 |
.rg-admin-goto:hover { background:#1d4d37; }
|
| 61 |
.rg-sel { color:var(--accent); font-size:13px; min-width:110px; }
|
|
@@ -105,8 +128,39 @@ html, body {
|
|
| 105 |
.rg-draw.open { display:flex; }
|
| 106 |
.rg-draw h3 { font-family:"Press Start 2P",monospace; color:var(--accent); font-size:13px; margin:0; }
|
| 107 |
#rg-sketch { background:#efe1bd; border:3px solid #8e6e37; border-radius:8px; cursor:crosshair; touch-action:none; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
.rg-draw .row { display:flex; gap:8px; }
|
| 109 |
.rg-draw .tip { color:var(--muted); font-size:11px; max-width:340px; text-align:center; }
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
/* end screen */
|
| 112 |
.rg-end {
|
|
|
|
| 56 |
.rg-btn:active { transform:translatey(1px); }
|
| 57 |
.rg-btn.primary { background:var(--accent); color:#160d24; font-weight:bold; }
|
| 58 |
.rg-btn.ghost { background:transparent; }
|
| 59 |
+
#rg-draw-open {
|
| 60 |
+
border:2px solid transparent; padding:7px 12px; color:#f8efff;
|
| 61 |
+
background:
|
| 62 |
+
linear-gradient(#2a1f3d, #2a1f3d) padding-box,
|
| 63 |
+
linear-gradient(90deg, #ff4d6d, #ffd24a, #6df5a0, #48c9ff, #b07cff, #ff4d6d) border-box;
|
| 64 |
+
background-size:100% 100%, 320% 100%;
|
| 65 |
+
box-shadow:0 0 0 1px rgba(255,255,255,0.08) inset, 0 0 14px rgba(176,124,255,0.5);
|
| 66 |
+
animation:rg-rainbow-border 2.4s linear infinite;
|
| 67 |
+
}
|
| 68 |
+
#rg-draw-open:hover {
|
| 69 |
+
background:
|
| 70 |
+
linear-gradient(#3a2c54, #3a2c54) padding-box,
|
| 71 |
+
linear-gradient(90deg, #ff4d6d, #ffd24a, #6df5a0, #48c9ff, #b07cff, #ff4d6d) border-box;
|
| 72 |
+
background-size:100% 100%, 320% 100%;
|
| 73 |
+
box-shadow:0 0 0 1px rgba(255,255,255,0.12) inset, 0 0 18px rgba(72,201,255,0.65);
|
| 74 |
+
}
|
| 75 |
+
@keyframes rg-rainbow-border {
|
| 76 |
+
from { background-position:0 0, 0% 50%; }
|
| 77 |
+
to { background-position:0 0, 320% 50%; }
|
| 78 |
+
}
|
| 79 |
+
@media (prefers-reduced-motion: reduce) {
|
| 80 |
+
#rg-draw-open { animation:none; }
|
| 81 |
+
}
|
| 82 |
.rg-admin-goto { background:#173b2a; color:var(--green); border-color:#2f6f4d; max-width:200px; }
|
| 83 |
.rg-admin-goto:hover { background:#1d4d37; }
|
| 84 |
.rg-sel { color:var(--accent); font-size:13px; min-width:110px; }
|
|
|
|
| 128 |
.rg-draw.open { display:flex; }
|
| 129 |
.rg-draw h3 { font-family:"Press Start 2P",monospace; color:var(--accent); font-size:13px; margin:0; }
|
| 130 |
#rg-sketch { background:#efe1bd; border:3px solid #8e6e37; border-radius:8px; cursor:crosshair; touch-action:none; }
|
| 131 |
+
.rg-customize {
|
| 132 |
+
padding:6px 11px; font-size:12px;
|
| 133 |
+
}
|
| 134 |
+
.rg-draw-tools {
|
| 135 |
+
width:min(540px, calc(100vw - 28px)); display:none; grid-template-columns:1fr 1fr;
|
| 136 |
+
gap:7px; padding:8px; border:1px solid rgba(176,124,255,.35); border-radius:8px;
|
| 137 |
+
background:rgba(18,12,30,.78);
|
| 138 |
+
}
|
| 139 |
+
.rg-draw-tools.open { display:grid; }
|
| 140 |
+
.rg-tool-group { display:flex; align-items:center; flex-wrap:wrap; gap:6px; min-width:0; }
|
| 141 |
+
.rg-tool-group span {
|
| 142 |
+
flex:0 0 auto; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.04em;
|
| 143 |
+
}
|
| 144 |
+
.rg-swatch,
|
| 145 |
+
.rg-fill,
|
| 146 |
+
.rg-line-style {
|
| 147 |
+
width:26px; height:26px; border:1px solid var(--border); border-radius:6px;
|
| 148 |
+
background:#2a1f3d; color:var(--ink); cursor:pointer; font-size:11px;
|
| 149 |
+
display:inline-flex; align-items:center; justify-content:center;
|
| 150 |
+
}
|
| 151 |
+
.rg-swatch,
|
| 152 |
+
.rg-fill { background:var(--swatch); box-shadow:0 0 0 1px rgba(0,0,0,.35) inset; }
|
| 153 |
+
.rg-swatch.on,
|
| 154 |
+
.rg-fill.on,
|
| 155 |
+
.rg-line-style.on {
|
| 156 |
+
border-color:var(--accent); box-shadow:0 0 0 2px var(--accent), 0 0 0 1px rgba(0,0,0,.35) inset;
|
| 157 |
+
}
|
| 158 |
+
.rg-size-tool input { flex:1 1 150px; min-width:76px; accent-color:var(--accent); }
|
| 159 |
.rg-draw .row { display:flex; gap:8px; }
|
| 160 |
.rg-draw .tip { color:var(--muted); font-size:11px; max-width:340px; text-align:center; }
|
| 161 |
+
@media (max-width: 520px) {
|
| 162 |
+
.rg-draw-tools { grid-template-columns:1fr; }
|
| 163 |
+
}
|
| 164 |
|
| 165 |
/* end screen */
|
| 166 |
.rg-end {
|
app/rpg_static/rpg.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
| 33 |
let enemyCooldown = {};
|
| 34 |
let xpFlashUntil = 0; // HUD XP-bar pulse timer (set on every XP gain)
|
| 35 |
let masteryChoiceOpen = false;
|
|
|
|
| 36 |
let selecting = false; // title-screen character select active
|
| 37 |
let chosenClass = null; // selected goblin class id
|
| 38 |
let journalOpen = false;
|
|
@@ -42,6 +43,8 @@
|
|
| 42 |
let lastEnding = null; // {ending,title,text} from win_game action
|
| 43 |
let lastCastMetadata = null; // structured /rg/cast metadata for HUD/debug
|
| 44 |
let CLASSES = [], WEAPONS = {}, ITEMS = {}, QUESTS_META = {};
|
|
|
|
|
|
|
| 45 |
let STORY_BEATS = []; // proactive beat triggers from /rg/world
|
| 46 |
let barks = []; // live speech bubbles: {eid, area, text, until}
|
| 47 |
const CLASS_SPRITE = { // in-game sprite per class (Goblin Pack #1 hero sheets)
|
|
@@ -1532,6 +1535,12 @@
|
|
| 1532 |
}
|
| 1533 |
|
| 1534 |
function grantMasteryChoice() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1535 |
const cls = CLASSES.find((c) => c.id === P.goblin_class) || {};
|
| 1536 |
const counts = Object.entries(P.rune_mastery || {}).sort((a, b) => b[1] - a[1]).map((x) => x[0]);
|
| 1537 |
const pool = counts.concat(selected || [], cls.affinity || [], ["closed_circle", "eye", "wave", "key", "spiral"]);
|
|
@@ -1716,6 +1725,9 @@
|
|
| 1716 |
function closeDialogue() {
|
| 1717 |
if (masteryChoiceOpen) return;
|
| 1718 |
dialogueOpen = false; $("rg-dialogue").className = "rg-dialogue";
|
|
|
|
|
|
|
|
|
|
| 1719 |
}
|
| 1720 |
|
| 1721 |
// ---- story card: full-screen panel for the campaign's major beats ----
|
|
@@ -2696,13 +2708,30 @@
|
|
| 2696 |
const bagCount = Object.values(P.items || {}).reduce((a, b) => a + b, 0);
|
| 2697 |
ctx.fillStyle = "#9c8bc4"; ctx.fillText("BAG " + bagCount, hx, cy); hx += 95;
|
| 2698 |
ctx.fillStyle = "#ffd24a"; ctx.fillText("G " + (P.gold || 0), hx, cy); hx += 80;
|
| 2699 |
-
|
| 2700 |
-
|
| 2701 |
-
ctx.textAlign = "right";
|
| 2702 |
const areaLabel = A.name.toUpperCase();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2703 |
ctx.fillStyle = "#b07cff";
|
| 2704 |
ctx.fillText(areaLabel, canvas.width - 14, cy);
|
| 2705 |
-
if (
|
| 2706 |
ctx.fillStyle = "#6df5a0";
|
| 2707 |
ctx.fillText("🔓 ADMIN", canvas.width - 14 - ctx.measureText(areaLabel).width - 18, cy);
|
| 2708 |
}
|
|
@@ -3018,13 +3047,65 @@
|
|
| 3018 |
function hideReading() { const c = $("rg-reading"); if (c) c.classList.remove("open"); }
|
| 3019 |
|
| 3020 |
// ---- drawing overlay ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3021 |
function openDraw() {
|
| 3022 |
if (over) return;
|
| 3023 |
drawing = true; $("rg-draw").classList.add("open");
|
|
|
|
| 3024 |
clearSketch();
|
| 3025 |
}
|
| 3026 |
function closeDraw() { drawing = false; $("rg-draw").classList.remove("open"); }
|
| 3027 |
-
function clearSketch() { sctx.fillStyle =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3028 |
function setupSketch() {
|
| 3029 |
sketch = $("rg-sketch"); sctx = sketch.getContext("2d");
|
| 3030 |
clearSketch();
|
|
@@ -3032,16 +3113,59 @@
|
|
| 3032 |
const pos = (ev) => { const r = sketch.getBoundingClientRect();
|
| 3033 |
const t = ev.touches ? ev.touches[0] : ev;
|
| 3034 |
return [(t.clientX - r.left) * sketch.width / r.width, (t.clientY - r.top) * sketch.height / r.height]; };
|
| 3035 |
-
const start = (ev) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3036 |
const move = (ev) => { if (!down) return; const [x, y] = pos(ev);
|
| 3037 |
-
|
| 3038 |
sctx.beginPath(); sctx.moveTo(lx, ly); sctx.lineTo(x, y); sctx.stroke();
|
|
|
|
| 3039 |
lx = x; ly = y; ev.preventDefault(); };
|
| 3040 |
const end = () => { down = false; };
|
| 3041 |
sketch.addEventListener("mousedown", start); sketch.addEventListener("mousemove", move);
|
| 3042 |
window.addEventListener("mouseup", end);
|
| 3043 |
sketch.addEventListener("touchstart", start); sketch.addEventListener("touchmove", move);
|
| 3044 |
sketch.addEventListener("touchend", end);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3045 |
}
|
| 3046 |
|
| 3047 |
// ---- input ----
|
|
|
|
| 33 |
let enemyCooldown = {};
|
| 34 |
let xpFlashUntil = 0; // HUD XP-bar pulse timer (set on every XP gain)
|
| 35 |
let masteryChoiceOpen = false;
|
| 36 |
+
let pendingMasteryChoice = false; // mastery picker deferred until an open dialogue closes
|
| 37 |
let selecting = false; // title-screen character select active
|
| 38 |
let chosenClass = null; // selected goblin class id
|
| 39 |
let journalOpen = false;
|
|
|
|
| 43 |
let lastEnding = null; // {ending,title,text} from win_game action
|
| 44 |
let lastCastMetadata = null; // structured /rg/cast metadata for HUD/debug
|
| 45 |
let CLASSES = [], WEAPONS = {}, ITEMS = {}, QUESTS_META = {};
|
| 46 |
+
const SKETCH_BG = "#efe1bd";
|
| 47 |
+
const drawTool = { ink: "#17120b", fill: "#ffe7a8", size: 7, line: "round", mode: "draw" };
|
| 48 |
let STORY_BEATS = []; // proactive beat triggers from /rg/world
|
| 49 |
let barks = []; // live speech bubbles: {eid, area, text, until}
|
| 50 |
const CLASS_SPRITE = { // in-game sprite per class (Goblin Pack #1 hero sheets)
|
|
|
|
| 1535 |
}
|
| 1536 |
|
| 1537 |
function grantMasteryChoice() {
|
| 1538 |
+
// If a conversation is mid-flow (e.g. a quest turn-in that just paid XP and
|
| 1539 |
+
// bumped the level), defer the picker until that dialogue is dismissed.
|
| 1540 |
+
// Opening it now would set masteryChoiceOpen, then the caller's own
|
| 1541 |
+
// showDialogue() would clobber the mastery buttons while the flag stays
|
| 1542 |
+
// true — permanently blocking closeDialogue() and soft-locking the game.
|
| 1543 |
+
if (dialogueOpen && !masteryChoiceOpen) { pendingMasteryChoice = true; return; }
|
| 1544 |
const cls = CLASSES.find((c) => c.id === P.goblin_class) || {};
|
| 1545 |
const counts = Object.entries(P.rune_mastery || {}).sort((a, b) => b[1] - a[1]).map((x) => x[0]);
|
| 1546 |
const pool = counts.concat(selected || [], cls.affinity || [], ["closed_circle", "eye", "wave", "key", "spiral"]);
|
|
|
|
| 1725 |
function closeDialogue() {
|
| 1726 |
if (masteryChoiceOpen) return;
|
| 1727 |
dialogueOpen = false; $("rg-dialogue").className = "rg-dialogue";
|
| 1728 |
+
// A level-up that landed mid-conversation parked its mastery picker here —
|
| 1729 |
+
// now that the dialogue is dismissed, present it.
|
| 1730 |
+
if (pendingMasteryChoice) { pendingMasteryChoice = false; grantMasteryChoice(); }
|
| 1731 |
}
|
| 1732 |
|
| 1733 |
// ---- story card: full-screen panel for the campaign's major beats ----
|
|
|
|
| 2708 |
const bagCount = Object.values(P.items || {}).reduce((a, b) => a + b, 0);
|
| 2709 |
ctx.fillStyle = "#9c8bc4"; ctx.fillText("BAG " + bagCount, hx, cy); hx += 95;
|
| 2710 |
ctx.fillStyle = "#ffd24a"; ctx.fillText("G " + (P.gold || 0), hx, cy); hx += 80;
|
| 2711 |
+
// Right-aligned area title (+ optional admin badge). Compute its left edge first
|
| 2712 |
+
// so the optional SP/crit stats below can be gated on the space that's left.
|
|
|
|
| 2713 |
const areaLabel = A.name.toUpperCase();
|
| 2714 |
+
const admin = !!(W && W.admin);
|
| 2715 |
+
const rightBlockLeft = canvas.width - 14 - ctx.measureText(areaLabel).width
|
| 2716 |
+
- (admin ? ctx.measureText("🔓 ADMIN").width + 18 : 0);
|
| 2717 |
+
// Optional stats: only draw each if it fits before the area title (with padding).
|
| 2718 |
+
const GAP = 16;
|
| 2719 |
+
if (P.spell_power) {
|
| 2720 |
+
const t = "SP " + P.spell_power;
|
| 2721 |
+
if (hx + ctx.measureText(t).width <= rightBlockLeft - GAP) {
|
| 2722 |
+
ctx.fillStyle = "#ff9d5c"; ctx.fillText(t, hx, cy); hx += 70;
|
| 2723 |
+
}
|
| 2724 |
+
}
|
| 2725 |
+
if (P.crit) {
|
| 2726 |
+
const t = "✷" + P.crit + "%";
|
| 2727 |
+
if (hx + ctx.measureText(t).width <= rightBlockLeft - GAP) {
|
| 2728 |
+
ctx.fillStyle = "#ffe27a"; ctx.fillText(t, hx, cy); hx += 70;
|
| 2729 |
+
}
|
| 2730 |
+
}
|
| 2731 |
+
ctx.textAlign = "right";
|
| 2732 |
ctx.fillStyle = "#b07cff";
|
| 2733 |
ctx.fillText(areaLabel, canvas.width - 14, cy);
|
| 2734 |
+
if (admin) {
|
| 2735 |
ctx.fillStyle = "#6df5a0";
|
| 2736 |
ctx.fillText("🔓 ADMIN", canvas.width - 14 - ctx.measureText(areaLabel).width - 18, cy);
|
| 2737 |
}
|
|
|
|
| 3047 |
function hideReading() { const c = $("rg-reading"); if (c) c.classList.remove("open"); }
|
| 3048 |
|
| 3049 |
// ---- drawing overlay ----
|
| 3050 |
+
function setCustomizeOpen(open) {
|
| 3051 |
+
const tools = $("rg-draw-tools");
|
| 3052 |
+
const btn = $("rg-draw-customize");
|
| 3053 |
+
if (!tools || !btn) return;
|
| 3054 |
+
tools.classList.toggle("open", open);
|
| 3055 |
+
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
| 3056 |
+
btn.textContent = open ? "Hide customization" : "Customize";
|
| 3057 |
+
}
|
| 3058 |
function openDraw() {
|
| 3059 |
if (over) return;
|
| 3060 |
drawing = true; $("rg-draw").classList.add("open");
|
| 3061 |
+
setCustomizeOpen(false);
|
| 3062 |
clearSketch();
|
| 3063 |
}
|
| 3064 |
function closeDraw() { drawing = false; $("rg-draw").classList.remove("open"); }
|
| 3065 |
+
function clearSketch() { sctx.fillStyle = SKETCH_BG; sctx.fillRect(0, 0, sketch.width, sketch.height); }
|
| 3066 |
+
function setActiveTool(nodes, active) {
|
| 3067 |
+
nodes.forEach((node) => node.classList.toggle("on", node === active));
|
| 3068 |
+
}
|
| 3069 |
+
function hexToRgb(hex) {
|
| 3070 |
+
return hex.match(/\w\w/g).map((n) => parseInt(n, 16));
|
| 3071 |
+
}
|
| 3072 |
+
function floodFill(x, y) {
|
| 3073 |
+
const w = sketch.width, h = sketch.height;
|
| 3074 |
+
const sx = Math.max(0, Math.min(w - 1, Math.round(x)));
|
| 3075 |
+
const sy = Math.max(0, Math.min(h - 1, Math.round(y)));
|
| 3076 |
+
const img = sctx.getImageData(0, 0, w, h);
|
| 3077 |
+
const data = img.data;
|
| 3078 |
+
const start = (sy * w + sx) * 4;
|
| 3079 |
+
const target = [data[start], data[start + 1], data[start + 2], data[start + 3]];
|
| 3080 |
+
const fill = hexToRgb(drawTool.fill);
|
| 3081 |
+
if (Math.abs(target[0] - fill[0]) + Math.abs(target[1] - fill[1]) + Math.abs(target[2] - fill[2]) < 12) return;
|
| 3082 |
+
const seen = new Uint8Array(w * h);
|
| 3083 |
+
const stack = [[sx, sy]];
|
| 3084 |
+
const tolerance = 28;
|
| 3085 |
+
while (stack.length) {
|
| 3086 |
+
const p = stack.pop();
|
| 3087 |
+
const px = p[0], py = p[1], pi = py * w + px, di = pi * 4;
|
| 3088 |
+
if (seen[pi]) continue;
|
| 3089 |
+
seen[pi] = 1;
|
| 3090 |
+
const delta = Math.abs(data[di] - target[0]) + Math.abs(data[di + 1] - target[1]) +
|
| 3091 |
+
Math.abs(data[di + 2] - target[2]) + Math.abs(data[di + 3] - target[3]);
|
| 3092 |
+
if (delta > tolerance) continue;
|
| 3093 |
+
data[di] = fill[0]; data[di + 1] = fill[1]; data[di + 2] = fill[2]; data[di + 3] = 255;
|
| 3094 |
+
if (px > 0) stack.push([px - 1, py]);
|
| 3095 |
+
if (px < w - 1) stack.push([px + 1, py]);
|
| 3096 |
+
if (py > 0) stack.push([px, py - 1]);
|
| 3097 |
+
if (py < h - 1) stack.push([px, py + 1]);
|
| 3098 |
+
}
|
| 3099 |
+
sctx.putImageData(img, 0, 0);
|
| 3100 |
+
}
|
| 3101 |
+
function applyLineStyle() {
|
| 3102 |
+
sctx.strokeStyle = drawTool.ink;
|
| 3103 |
+
sctx.lineWidth = drawTool.size;
|
| 3104 |
+
sctx.lineJoin = drawTool.line === "square" ? "miter" : "round";
|
| 3105 |
+
sctx.lineCap = drawTool.line === "square" ? "butt" : "round";
|
| 3106 |
+
sctx.globalAlpha = drawTool.line === "soft" ? 0.58 : 1;
|
| 3107 |
+
sctx.setLineDash(drawTool.line === "dot" ? [Math.max(1, drawTool.size * 0.12), drawTool.size * 1.8] : []);
|
| 3108 |
+
}
|
| 3109 |
function setupSketch() {
|
| 3110 |
sketch = $("rg-sketch"); sctx = sketch.getContext("2d");
|
| 3111 |
clearSketch();
|
|
|
|
| 3113 |
const pos = (ev) => { const r = sketch.getBoundingClientRect();
|
| 3114 |
const t = ev.touches ? ev.touches[0] : ev;
|
| 3115 |
return [(t.clientX - r.left) * sketch.width / r.width, (t.clientY - r.top) * sketch.height / r.height]; };
|
| 3116 |
+
const start = (ev) => {
|
| 3117 |
+
[lx, ly] = pos(ev);
|
| 3118 |
+
if (drawTool.mode === "fill") {
|
| 3119 |
+
floodFill(lx, ly);
|
| 3120 |
+
ev.preventDefault();
|
| 3121 |
+
return;
|
| 3122 |
+
}
|
| 3123 |
+
down = true;
|
| 3124 |
+
ev.preventDefault();
|
| 3125 |
+
};
|
| 3126 |
const move = (ev) => { if (!down) return; const [x, y] = pos(ev);
|
| 3127 |
+
applyLineStyle();
|
| 3128 |
sctx.beginPath(); sctx.moveTo(lx, ly); sctx.lineTo(x, y); sctx.stroke();
|
| 3129 |
+
sctx.globalAlpha = 1; sctx.setLineDash([]);
|
| 3130 |
lx = x; ly = y; ev.preventDefault(); };
|
| 3131 |
const end = () => { down = false; };
|
| 3132 |
sketch.addEventListener("mousedown", start); sketch.addEventListener("mousemove", move);
|
| 3133 |
window.addEventListener("mouseup", end);
|
| 3134 |
sketch.addEventListener("touchstart", start); sketch.addEventListener("touchmove", move);
|
| 3135 |
sketch.addEventListener("touchend", end);
|
| 3136 |
+
const customize = $("rg-draw-customize");
|
| 3137 |
+
if (customize) {
|
| 3138 |
+
customize.onclick = () => {
|
| 3139 |
+
const tools = $("rg-draw-tools");
|
| 3140 |
+
setCustomizeOpen(!(tools && tools.classList.contains("open")));
|
| 3141 |
+
};
|
| 3142 |
+
}
|
| 3143 |
+
const inkButtons = [...document.querySelectorAll("[data-rg-ink]")];
|
| 3144 |
+
inkButtons.forEach((btn) => {
|
| 3145 |
+
btn.onclick = () => {
|
| 3146 |
+
drawTool.ink = btn.dataset.rgInk;
|
| 3147 |
+
drawTool.mode = "draw";
|
| 3148 |
+
setActiveTool(inkButtons, btn);
|
| 3149 |
+
};
|
| 3150 |
+
});
|
| 3151 |
+
const fillButtons = [...document.querySelectorAll("[data-rg-fill]")];
|
| 3152 |
+
fillButtons.forEach((btn) => {
|
| 3153 |
+
btn.onclick = () => {
|
| 3154 |
+
drawTool.fill = btn.dataset.rgFill;
|
| 3155 |
+
drawTool.mode = "fill";
|
| 3156 |
+
setActiveTool(fillButtons, btn);
|
| 3157 |
+
};
|
| 3158 |
+
});
|
| 3159 |
+
const lineButtons = [...document.querySelectorAll("[data-rg-line]")];
|
| 3160 |
+
lineButtons.forEach((btn) => {
|
| 3161 |
+
btn.onclick = () => {
|
| 3162 |
+
drawTool.line = btn.dataset.rgLine;
|
| 3163 |
+
drawTool.mode = "draw";
|
| 3164 |
+
setActiveTool(lineButtons, btn);
|
| 3165 |
+
};
|
| 3166 |
+
});
|
| 3167 |
+
const size = $("rg-line-size");
|
| 3168 |
+
if (size) size.oninput = () => { drawTool.size = Number(size.value) || 7; };
|
| 3169 |
}
|
| 3170 |
|
| 3171 |
// ---- input ----
|
src/rune_goblin/world.py
CHANGED
|
@@ -419,7 +419,9 @@ def _build_areas() -> dict[str, Area]:
|
|
| 419 |
dialogue="I lost my map. Soothe me (wave) and I'll bless your courage.",
|
| 420 |
hint="cast wave/leaf to comfort"),
|
| 421 |
_npc("toll_pixie", "Toll Pixie", 16, 3, sprite_key="fluttering_pixie",
|
| 422 |
-
dialogue="The goblin
|
|
|
|
|
|
|
| 423 |
hint="a helpful hint about the gate goblin"),
|
| 424 |
_npc("road_druid", "Road Druid", 41, 27, sprite_key="expert_druid",
|
| 425 |
dialogue="Two doors west and east. The Library hides a Calendar Key. "
|
|
@@ -444,7 +446,7 @@ def _build_areas() -> dict[str, Area]:
|
|
| 444 |
loot=["lucky coin"], hint="locked — pay with a coin rune"),
|
| 445 |
Entity("toll_shrine", "shrine", "Mile-Marker Shrine", 24, 3, sprite="⛩️",
|
| 446 |
blocking=True, tags=["holy"], state="dormant",
|
| 447 |
-
hint="cast
|
| 448 |
Entity("portal_caverns", "portal", "Cavern Mouth", 2, 14, sprite="🕳️",
|
| 449 |
blocking=False, target_area="caverns", target_x=3, target_y=2,
|
| 450 |
hint="step in → Mirror Fungus Caverns"),
|
|
@@ -454,7 +456,7 @@ def _build_areas() -> dict[str, Area]:
|
|
| 454 |
Entity("portal_market", "portal", "Bone Market Tunnel", 24, 30, sprite="🕳️",
|
| 455 |
state="locked", blocking=True, target_area="bone_market", target_x=2, target_y=2,
|
| 456 |
requires=["Debt Receipt"], tags=["shop", "shortcut"],
|
| 457 |
-
hint="sealed —
|
| 458 |
Entity("portal_frost", "portal", "Frostbite Trail", 5, 27, sprite="🌀",
|
| 459 |
blocking=False, target_area="frost_pass", target_x=3, target_y=3,
|
| 460 |
hint="step in → Frostbite Pass"),
|
|
@@ -515,7 +517,7 @@ def _build_areas() -> dict[str, Area]:
|
|
| 515 |
loot=["jar of teeth", "minor powerup"], hint="locked — flame or key"),
|
| 516 |
Entity("cavern_shrine", "shrine", "Dripping Shrine", 13, 9, sprite="⛩️",
|
| 517 |
blocking=True, tags=["holy"], state="dormant",
|
| 518 |
-
hint="cast
|
| 519 |
Entity("portal_home_c", "portal", "Cave Exit", 2, 29, sprite="🚪",
|
| 520 |
blocking=False, target_area="overworld", target_x=3, target_y=14,
|
| 521 |
hint="step out → Toll Road"),
|
|
@@ -1207,7 +1209,11 @@ def build_world(seed: int | None = None, admin: bool | None = None) -> dict:
|
|
| 1207 |
"gold": 3, "rune_mastery": {}, "story_flags": [], "ending_flags": [], "journal": [],
|
| 1208 |
"four_rune_unlocked": False,
|
| 1209 |
"inventory": ["wet candle"], "score": 0, "statuses": [],
|
| 1210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
"quest_log": ["Find the Calendar Shard in the Mirror Fungus Caverns."],
|
| 1212 |
"discoveries": [], "recent_story_events": [], "trust": {},
|
| 1213 |
"beats_seen": [],
|
|
@@ -1425,6 +1431,16 @@ def _defeat_loot_actions(player: dict, target: dict, rng: random.Random) -> list
|
|
| 1425 |
actions += _weapon_temper_actions(player)
|
| 1426 |
if "mycologist" in str(target.get("id", "")):
|
| 1427 |
actions += _flag_actions(["mycologist_defeated"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1428 |
return actions
|
| 1429 |
|
| 1430 |
|
|
@@ -1784,6 +1800,15 @@ def resolve_world_cast(
|
|
| 1784 |
# done and does NOT retaliate — paying the toll is a transaction, not a
|
| 1785 |
# fight. The spell's effect text carries the peaceful "gate opens" line.
|
| 1786 |
actions.append({"type": "defeat_entity", "target_id": tid})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1787 |
|
| 1788 |
if "bell" in runes:
|
| 1789 |
actions += _flag_actions(["queue_goblin_paid", "tollmaster_route_open"])
|
|
@@ -2091,23 +2116,38 @@ def resolve_world_cast(
|
|
| 2091 |
status_effects=["door_unlocked"], chaos=5 if forced else 4,
|
| 2092 |
)
|
| 2093 |
else:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2094 |
spell = SpellResult(
|
| 2095 |
spell_name="Polite Refusal", spell_type="unlock_emotion",
|
| 2096 |
flavor=_flavor_world(runes, target, combo),
|
| 2097 |
-
effect=
|
| 2098 |
)
|
| 2099 |
return {"spell": spell.model_dump(), "world_actions": actions, "target_id": tid, "runes": runes}
|
| 2100 |
|
| 2101 |
# --- shrine ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
| 2102 |
if ttype == "shrine":
|
| 2103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2104 |
actions.append({"type": "heal_player", "amount": heal})
|
| 2105 |
actions.append({"type": "add_courage", "amount": 1})
|
| 2106 |
-
actions.append({"type": "set_entity_state", "target_id": tid, "state": "spent"})
|
| 2107 |
spell = SpellResult(
|
| 2108 |
spell_name="Mile-Marker Blessing", spell_type="shrine",
|
| 2109 |
flavor=_flavor_world(runes, target, combo),
|
| 2110 |
-
effect=
|
| 2111 |
player_hp_delta=heal, status_effects=["player_blessed"], chaos=3,
|
| 2112 |
)
|
| 2113 |
return {"spell": spell.model_dump(), "world_actions": actions, "target_id": tid, "runes": runes}
|
|
|
|
| 419 |
dialogue="I lost my map. Soothe me (wave) and I'll bless your courage.",
|
| 420 |
hint="cast wave/leaf to comfort"),
|
| 421 |
_npc("toll_pixie", "Toll Pixie", 16, 3, sprite_key="fluttering_pixie",
|
| 422 |
+
dialogue="The gate goblin? Pay him or beat him — either way he stamps "
|
| 423 |
+
"a Debt Receipt, and that opens the Bone Market Tunnel south "
|
| 424 |
+
"of here. He hates bells and coins. Just saying.",
|
| 425 |
hint="a helpful hint about the gate goblin"),
|
| 426 |
_npc("road_druid", "Road Druid", 41, 27, sprite_key="expert_druid",
|
| 427 |
dialogue="Two doors west and east. The Library hides a Calendar Key. "
|
|
|
|
| 446 |
loot=["lucky coin"], hint="locked — pay with a coin rune"),
|
| 447 |
Entity("toll_shrine", "shrine", "Mile-Marker Shrine", 24, 3, sprite="⛩️",
|
| 448 |
blocking=True, tags=["holy"], state="dormant",
|
| 449 |
+
hint="rest stop — face it and cast closed circle (⭕) for a full heal, any time"),
|
| 450 |
Entity("portal_caverns", "portal", "Cavern Mouth", 2, 14, sprite="🕳️",
|
| 451 |
blocking=False, target_area="caverns", target_x=3, target_y=2,
|
| 452 |
hint="step in → Mirror Fungus Caverns"),
|
|
|
|
| 456 |
Entity("portal_market", "portal", "Bone Market Tunnel", 24, 30, sprite="🕳️",
|
| 457 |
state="locked", blocking=True, target_area="bone_market", target_x=2, target_y=2,
|
| 458 |
requires=["Debt Receipt"], tags=["shop", "shortcut"],
|
| 459 |
+
hint="sealed — settle with the Queue Goblin at the toll gate for a Debt Receipt"),
|
| 460 |
Entity("portal_frost", "portal", "Frostbite Trail", 5, 27, sprite="🌀",
|
| 461 |
blocking=False, target_area="frost_pass", target_x=3, target_y=3,
|
| 462 |
hint="step in → Frostbite Pass"),
|
|
|
|
| 517 |
loot=["jar of teeth", "minor powerup"], hint="locked — flame or key"),
|
| 518 |
Entity("cavern_shrine", "shrine", "Dripping Shrine", 13, 9, sprite="⛩️",
|
| 519 |
blocking=True, tags=["holy"], state="dormant",
|
| 520 |
+
hint="cast closed circle (⭕) to fully heal at the shrine"),
|
| 521 |
Entity("portal_home_c", "portal", "Cave Exit", 2, 29, sprite="🚪",
|
| 522 |
blocking=False, target_area="overworld", target_x=3, target_y=14,
|
| 523 |
hint="step out → Toll Road"),
|
|
|
|
| 1209 |
"gold": 3, "rune_mastery": {}, "story_flags": [], "ending_flags": [], "journal": [],
|
| 1210 |
"four_rune_unlocked": False,
|
| 1211 |
"inventory": ["wet candle"], "score": 0, "statuses": [],
|
| 1212 |
+
# One starter potion teaches the bag/potion loop before the first
|
| 1213 |
+
# quest reward. (Healing/NPC guidance reaches the player through the
|
| 1214 |
+
# shrine's bump-hint and the Journal's "talk to a quest-giver"
|
| 1215 |
+
# empty-state, so the quest_log stays the single main objective.)
|
| 1216 |
+
"items": {"health_potion": 1}, "quests": {},
|
| 1217 |
"quest_log": ["Find the Calendar Shard in the Mirror Fungus Caverns."],
|
| 1218 |
"discoveries": [], "recent_story_events": [], "trust": {},
|
| 1219 |
"beats_seen": [],
|
|
|
|
| 1431 |
actions += _weapon_temper_actions(player)
|
| 1432 |
if "mycologist" in str(target.get("id", "")):
|
| 1433 |
actions += _flag_actions(["mycologist_defeated"])
|
| 1434 |
+
# Beating the Queue Goblin in a straight fight settles the toll too — the
|
| 1435 |
+
# Debt Receipt (Bone Market key) must be reachable at level 1, when the
|
| 1436 |
+
# coin/bell payment runes are still locked.
|
| 1437 |
+
if str(target.get("id", "")) == "toll_goblin":
|
| 1438 |
+
actions += _flag_actions(["queue_goblin_forced"])
|
| 1439 |
+
if "Debt Receipt" not in (player.get("inventory") or []):
|
| 1440 |
+
actions.append({"type": "add_inventory", "item": "Debt Receipt"})
|
| 1441 |
+
actions.append({"type": "add_journal_entry",
|
| 1442 |
+
"text": "A Debt Receipt flutters from the Queue Goblin's "
|
| 1443 |
+
"cup. It opens the Bone Market Tunnel on the south road."})
|
| 1444 |
return actions
|
| 1445 |
|
| 1446 |
|
|
|
|
| 1800 |
# done and does NOT retaliate — paying the toll is a transaction, not a
|
| 1801 |
# fight. The spell's effect text carries the peaceful "gate opens" line.
|
| 1802 |
actions.append({"type": "defeat_entity", "target_id": tid})
|
| 1803 |
+
# Settling the toll — by bell, coin or blood — earns the Debt Receipt
|
| 1804 |
+
# that unseals the Bone Market Tunnel on the south road. Without this
|
| 1805 |
+
# the receipt only existed via a forced cursed entry of that same
|
| 1806 |
+
# portal, which was circular.
|
| 1807 |
+
if "Debt Receipt" not in inventory:
|
| 1808 |
+
actions.append({"type": "add_inventory", "item": "Debt Receipt"})
|
| 1809 |
+
actions.append({"type": "add_journal_entry",
|
| 1810 |
+
"text": "The Queue Goblin stamps you a Debt Receipt. "
|
| 1811 |
+
"It opens the Bone Market Tunnel on the south road."})
|
| 1812 |
|
| 1813 |
if "bell" in runes:
|
| 1814 |
actions += _flag_actions(["queue_goblin_paid", "tollmaster_route_open"])
|
|
|
|
| 2116 |
status_effects=["door_unlocked"], chaos=5 if forced else 4,
|
| 2117 |
)
|
| 2118 |
else:
|
| 2119 |
+
effect = f"It stays shut. It needs: {' + '.join(requires)}."
|
| 2120 |
+
if tid == "portal_market":
|
| 2121 |
+
effect += (" The Queue Goblin at the toll gate stamps one "
|
| 2122 |
+
"when you settle your toll — pay him or beat him.")
|
| 2123 |
spell = SpellResult(
|
| 2124 |
spell_name="Polite Refusal", spell_type="unlock_emotion",
|
| 2125 |
flavor=_flavor_world(runes, target, combo),
|
| 2126 |
+
effect=effect, chaos=2,
|
| 2127 |
)
|
| 2128 |
return {"spell": spell.model_dump(), "world_actions": actions, "target_id": tid, "runes": runes}
|
| 2129 |
|
| 2130 |
# --- shrine ------------------------------------------------------------
|
| 2131 |
+
# Shrines are reusable rest stops — the one reliable heal on every map.
|
| 2132 |
+
# An attuned rune (closed_circle is in the level-1 starting three; leaf
|
| 2133 |
+
# joins later) restores the player to full; anything else patches 2 HP.
|
| 2134 |
if ttype == "shrine":
|
| 2135 |
+
max_hp = int(player.get("max_hp", 10) or 10)
|
| 2136 |
+
cur_hp = max(0, int(player.get("hp", max_hp) or 0))
|
| 2137 |
+
attuned = any(r in {"leaf", "closed_circle"} for r in runes)
|
| 2138 |
+
if attuned:
|
| 2139 |
+
heal = max(0, max_hp - cur_hp)
|
| 2140 |
+
effect = ("The shrine's warmth restores you to full health."
|
| 2141 |
+
if heal else "You are already whole. The shrine hums approvingly.")
|
| 2142 |
+
else:
|
| 2143 |
+
heal = 2
|
| 2144 |
+
effect = "Warmth restores 2 HP. Cast a closed circle (⭕) here for a full heal."
|
| 2145 |
actions.append({"type": "heal_player", "amount": heal})
|
| 2146 |
actions.append({"type": "add_courage", "amount": 1})
|
|
|
|
| 2147 |
spell = SpellResult(
|
| 2148 |
spell_name="Mile-Marker Blessing", spell_type="shrine",
|
| 2149 |
flavor=_flavor_world(runes, target, combo),
|
| 2150 |
+
effect=effect,
|
| 2151 |
player_hp_delta=heal, status_effects=["player_blessed"], chaos=3,
|
| 2152 |
)
|
| 2153 |
return {"spell": spell.model_dump(), "world_actions": actions, "target_id": tid, "runes": runes}
|
tests/test_world_cast.py
CHANGED
|
@@ -465,6 +465,43 @@ def test_toll_bell_passes_free_and_opens_tollmaster_route():
|
|
| 465 |
assert "queue_goblin_paid" in flags and "tollmaster_route_open" in flags
|
| 466 |
|
| 467 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
def test_player_starts_with_spendable_coins():
|
| 469 |
assert build_world()["player"]["gold"] >= 1
|
| 470 |
|
|
@@ -483,7 +520,9 @@ def test_world_exposes_items_and_quests_metadata():
|
|
| 483 |
assert {"health_potion", "courage_draught", "monster_trophy", "fungus_spore"} <= item_ids
|
| 484 |
quest_ids = {q["id"] for q in w["quests"]}
|
| 485 |
assert {"road_patrol", "spore_sample", "quartermaster_kit"} <= quest_ids
|
| 486 |
-
|
|
|
|
|
|
|
| 487 |
|
| 488 |
|
| 489 |
def test_quest_giver_npcs_are_tagged():
|
|
|
|
| 465 |
assert "queue_goblin_paid" in flags and "tollmaster_route_open" in flags
|
| 466 |
|
| 467 |
|
| 468 |
+
def _inventory_adds(res):
|
| 469 |
+
return [a["item"] for a in res["world_actions"] if a["type"] == "add_inventory"]
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def test_settling_toll_grants_debt_receipt_every_way():
|
| 473 |
+
# Paying (coin/bell, with or without gold) stamps the Bone Market key…
|
| 474 |
+
for runes, gold in ((["coin"], 3), (["coin"], 0), (["bell"], 0)):
|
| 475 |
+
res = resolve_world_cast(runes, _player(gold=gold), dict(TOLL_GOBLIN), seed=1)
|
| 476 |
+
assert "Debt Receipt" in _inventory_adds(res)
|
| 477 |
+
assert "add_journal_entry" in _types(res)
|
| 478 |
+
# …and so does beating him in a straight fight (level-1 path: the coin and
|
| 479 |
+
# bell runes are still locked then).
|
| 480 |
+
fight = dict(TOLL_GOBLIN, hp=1, weakness=["jagged_line"], resistance=[])
|
| 481 |
+
res = resolve_world_cast(["jagged_line"], _player(), fight, seed=2)
|
| 482 |
+
assert "defeat_entity" in _types(res)
|
| 483 |
+
assert "Debt Receipt" in _inventory_adds(res)
|
| 484 |
+
# Already holding one: never duplicated.
|
| 485 |
+
res = resolve_world_cast(["coin"], _player(gold=3, inventory=["Debt Receipt"]),
|
| 486 |
+
dict(TOLL_GOBLIN), seed=1)
|
| 487 |
+
assert "Debt Receipt" not in _inventory_adds(res)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def test_shrine_fully_heals_with_starting_rune_and_is_reusable():
|
| 491 |
+
shrine = {"id": "toll_shrine", "type": "shrine", "name": "Mile-Marker Shrine",
|
| 492 |
+
"state": "dormant"}
|
| 493 |
+
# closed_circle is in the level-1 starting trio -> full heal
|
| 494 |
+
res = resolve_world_cast(["closed_circle"], _player(hp=3, max_hp=12), shrine, seed=4)
|
| 495 |
+
heals = [a["amount"] for a in res["world_actions"] if a["type"] == "heal_player"]
|
| 496 |
+
assert heals == [9] # back to full
|
| 497 |
+
# the shrine is never spent — it stays a reusable rest stop
|
| 498 |
+
assert "set_entity_state" not in _types(res)
|
| 499 |
+
# unattuned runes still patch a little
|
| 500 |
+
res2 = resolve_world_cast(["spiral"], _player(hp=3, max_hp=12), shrine, seed=4)
|
| 501 |
+
heals2 = [a["amount"] for a in res2["world_actions"] if a["type"] == "heal_player"]
|
| 502 |
+
assert heals2 == [2]
|
| 503 |
+
|
| 504 |
+
|
| 505 |
def test_player_starts_with_spendable_coins():
|
| 506 |
assert build_world()["player"]["gold"] >= 1
|
| 507 |
|
|
|
|
| 520 |
assert {"health_potion", "courage_draught", "monster_trophy", "fungus_spore"} <= item_ids
|
| 521 |
quest_ids = {q["id"] for q in w["quests"]}
|
| 522 |
assert {"road_patrol", "spore_sample", "quartermaster_kit"} <= quest_ids
|
| 523 |
+
# one starter potion so the bag/heal loop is live before the first quest
|
| 524 |
+
assert w["player"]["items"] == {"health_potion": 1}
|
| 525 |
+
assert w["player"]["quests"] == {}
|
| 526 |
|
| 527 |
|
| 528 |
def test_quest_giver_npcs_are_tagged():
|