ASHu2 commited on
Commit
718a54b
·
unverified ·
2 Parent(s): 09d75f71e6787f

Merge branch 'feat/better-maps' of github.com:ASH1998/Rune-Goblin into feat/better-maps

Browse files
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=64">
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=64"></script>
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
- if (P.spell_power) { ctx.fillStyle = "#ff9d5c"; ctx.fillText("SP " + P.spell_power, hx, cy); hx += 70; }
2700
- if (P.crit) { ctx.fillStyle = "#ffe27a"; ctx.fillText("✷" + P.crit + "%", hx, cy); hx += 70; }
2701
- ctx.textAlign = "right";
2702
  const areaLabel = A.name.toUpperCase();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2703
  ctx.fillStyle = "#b07cff";
2704
  ctx.fillText(areaLabel, canvas.width - 14, cy);
2705
- if (W && W.admin) {
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 = "#efe1bd"; sctx.fillRect(0, 0, sketch.width, sketch.height); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) => { down = true; [lx, ly] = pos(ev); ev.preventDefault(); };
 
 
 
 
 
 
 
 
 
3036
  const move = (ev) => { if (!down) return; const [x, y] = pos(ev);
3037
- sctx.strokeStyle = "#17120b"; sctx.lineWidth = 7; sctx.lineCap = "round";
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 hates bells and coins. Just saying.",
 
 
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 leaf/closed_circle to bless yourself"),
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 — needs Debt Receipt, coin+bell, or a forced cursed entrance"),
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 leaf to heal at the shrine"),
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
- "items": {}, "quests": {},
 
 
 
 
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=f"It stays shut. It needs: {' + '.join(requires)}.", chaos=2,
2098
  )
2099
  return {"spell": spell.model_dump(), "world_actions": actions, "target_id": tid, "runes": runes}
2100
 
2101
  # --- shrine ------------------------------------------------------------
 
 
 
2102
  if ttype == "shrine":
2103
- heal = 4 if any(r in {"leaf", "closed_circle"} for r in runes) else 2
 
 
 
 
 
 
 
 
 
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=f"Warmth restores {heal} HP and steadies your nerve.",
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
- assert w["player"]["items"] == {} and w["player"]["quests"] == {}
 
 
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():