den.js 1.0.0 — he exists

This commit is contained in:
2026-08-14 15:56:15 -04:00
commit c0a634cab5
12 changed files with 1511 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
node_modules/
package-lock.json
# runtime data written by den.php (lives above docroot in deployment,
# but ignore it here in case anyone tests with it alongside)
*_presence.txt
*_guestbook.txt
*_poll.txt
*_pilgrims.txt
counter.txt
status.txt
.DS_Store
*.swp
*~

19
CHANGELOG.md Normal file
View File

@@ -0,0 +1,19 @@
# Changelog
## 1.0.0
First release. He exists.
- Autonomous critter: chase / rest / wander / errand / mischief states, turn-rate
limited movement, stride-based paw prints that rotate to face travel direction
- Burrow spawn animation, ten-minute idle sleep cycle with bed and circling ritual
- Emote bubbles and speech; `denSay` / `denYip` helpers
- `localStorage` memory: visits, lifetime pets, name. Pet milestones at 10/25/50/100,
behaviour changes with trust
- Portable shell tab with critter commands
- POI system via `data-den-poi`
- Mischief system via `data-den-steal` — steals below 25 pets, gifts at and above
- Event API: `celebrate`, `investigate`, `alarm`, `mischief`, `sleepy`, `zoomies`
- Optional PHP presence endpoint with HMAC'd fingerprints and per-IP caps
- Secret typed codes and the Konami code
- Respects `prefers-reduced-motion`

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 owo.ing
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

232
README.md Normal file
View File

@@ -0,0 +1,232 @@
# den.js
An invisible kobold lives on your website.
You never see him. You see his paw prints, his little dialogue, and — after ten
minutes of quiet — the bed he drags out to sleep in. He walks to your cursor,
gets bored and wanders off to sniff at things, remembers you between visits, and
depending on how well he knows you he will either steal your buttons and run, or
bring them to you as gifts.
One script tag. No dependencies. No build step required.
```html
<script src="/den.js"></script>
```
That's it. That's the integration.
---
## What he does
**Movement.** He has a position, a heading, and a turn rate, so he can't snap
around instantly — chase him with your cursor and the prints carve an arc as he
wheels to follow. He trots when you're close, sprints when you're far, and lays
prints by distance travelled rather than on a timer, so his stride stays
physically consistent at any framerate.
**Moods.** `chase` when your cursor moves, `rest` when he arrives, `wander` when
he gets bored, `errand` when he's been sent somewhere, `mischief` when he's
committing a crime. Emote bubbles float off him — `❗` when he notices you,
`👃` while sniffing, `💤` while dozing, hearts when he's fond of you.
**Sleep.** Ten minutes with no input and he yawns, walks the tight circle every
canid walks before lying down, and a bed fades in with a breathing lump under
the blanket. Any keypress, click, scroll, or mouse twitch wakes him — groggily.
**Memory.** Visit count, lifetime pets, and his name persist in `localStorage`.
Return after a week and he says so. Pet him ten times and he decides you're
friends; at twenty-five he starts lingering near your cursor instead of
wandering off, and his stealing turns into gift-giving.
**A shell.** A `>_ den` tab sits in the corner of every page. Open it and you can
`come`, `sit`, `pet`, `fetch`, `sleep`, `where`, `status`, or `name` him.
**Presence.** Optional. With `den.php` on the server he'll notice how many other
people are on the site and mention that he can smell them.
---
## Install
Copy `src/den.js` (or a build from `build/`) to your web root and add the script
tag. Nothing else is required — he injects his own CSS and brings his own audio.
For the presence counter, also copy `src/den.php` to your web root, change the
secret at the top, and make sure PHP can write one file next to it:
```sh
touch ../owo_presence.txt
chown www-data:www-data ../owo_presence.txt
```
The storage file lives one level *above* the document root so it can never be
fetched over HTTP.
---
## Page integration
Everything beyond the script tag is declarative markup.
### Points of interest
Mark anything you want him to visit and comment on. Pipe-separate alternate
lines and he'll pick one at random:
```html
<div class="panel" data-den-poi="yip (sign it)|yip yip (write something)">
```
When he decides to wander, there's a 45% chance he heads for a POI that's
currently on screen instead of a random point. He skips whichever one he visited
last, so he explores rather than fixating.
### Stealable things
Nothing is stealable unless you say so:
```html
<span class="badge" data-den-steal>free gifs</span>
```
He self-limits to elements between 8220px wide and 890px tall, and only ones
currently visible. The original element is hidden and a fixed-position clone
follows him, so the page layout doesn't reflow while he's got it. **Everything
always comes back** — there are timeouts on every phase, and clicking him
returns the item instantly.
### Showing the presence count
```html
<span data-den-others></span>
```
Filled in on load and refreshed every 60 seconds.
---
## Events
Pages tell him what happened; he decides how to feel about it.
```js
denEvent('celebrate', { text: 'YIP!! (a new signature!!)' });
denEvent('investigate', { el: '#guestbook', text: 'yip? (what is this)' });
denEvent('investigate', { x: 400, y: 300, run: true });
denEvent('alarm', { text: 'YIP?! (INTRUDER)' });
denEvent('mischief');
denEvent('sleepy');
denEvent('zoomies');
```
`celebrate` is a happy spin with a heart burst. `investigate` sends him on an
errand — while on one he ignores your cursor, because he's busy. `alarm` startles
him into a panicked sprint.
Lower-level helpers, if you want to drive him directly:
```js
denSay('yip!!'); // speech bubble at his current position
denYip(); // the synthesized squeak
denSetName('crumb'); // returns the sanitized name, or null
denGetName();
denOthers; // number, current presence count
```
### A worked example
Redirect after a successful form post with a marker, then:
```js
const q = new URLSearchParams(location.search);
if (q.get('signed') === '1') {
history.replaceState(null, '', location.pathname);
setTimeout(() => denEvent('celebrate', { text: 'YIP!! (for the hoard!!)' }), 3400);
}
```
The delay lets him finish climbing out of his burrow first.
---
## Secret codes
Typed anywhere outside a text field:
| type | he says |
| --- | --- |
| `yip` | `yip!!` |
| `owo` | `*notices u*` |
| `uwu` | `yip (no)` |
| `kobold` | `yip!! (that is me)` |
| `shrine` | `YIP?! (how do u know about that)` |
| `zoomies` | ten seconds of chaos |
| `sleep` | bedtime |
| ↑↑↓↓←→←→BA | same as `zoomies` |
---
## Building
Optional — `src/den.js` runs fine as-is. Builds are for size and, if you want it,
hiding the surprises above from anyone who opens view-source.
```sh
npm install -g terser javascript-obfuscator
./build.fish
```
| file | size | notes |
| --- | --- | --- |
| `src/den.js` | 33 KB | readable source, the one you edit |
| `build/den.min.js` | 20 KB | minified; recommended |
| `build/den.obf.js` | 88 KB | string-encoded and control-flow flattened |
The obfuscated build hides all his dialogue and secrets from casual reading, at
4x the file size. Nothing stops a determined reader — this is spoiler protection,
not security.
`build.fish` also bumps the `?v=N` cache-buster in any `.php` file that
references a build, because forgetting that will cost you an afternoon
convincing yourself the code is broken when the browser is simply serving you
last week's kobold.
---
## Testing
`test/sim.js` runs him headless in jsdom with a mocked clock, so you can step
through behaviour that would otherwise take ten minutes of sitting still:
```sh
npm install jsdom
node test/sim.js # readable source
node test/sim.js build/den.obf.js # verify a build still works
```
It drives a full mischief cycle and asserts the stolen element is restored.
---
## Notes
- Respects `prefers-reduced-motion` — under it he doesn't spawn at all, and the
API becomes no-ops so your page code never errors.
- Everything is `position: fixed` with `pointer-events: none`, so he can't
intercept clicks or affect layout.
- Petting ignores clicks on links, buttons, and form fields.
- If `den.php` is missing, presence silently no-ops.
- He injects `text-align: left` on his own UI, because inheritable properties
leak in from host pages. If something of his looks off on a new page, check
what the host's `body` is setting.
---
## License
MIT — see [LICENSE](LICENSE).
Built for [owo.ing](https://owo.ing). If you put him on your site, he's yours
now. Name him something good.

70
build.fish Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env fish
# den.js build: minify, obfuscate, syntax-check, bump cache version.
# needs: npm install -g terser javascript-obfuscator
set -l src src/den.js
set -l min build/den.min.js
set -l obf build/den.obf.js
set -l banner "/* den.js - an invisible kobold. (c) 2026 owo.ing. hands off the hoard. */"
if not test -f $src
echo "no $src here. run this from the repo root."
exit 1
end
mkdir -p build
echo ":: minifying"
terser $src --compress passes=2 --mangle --format ascii_only=true -o $min
or begin
echo "terser failed"
exit 1
end
echo ":: obfuscating"
javascript-obfuscator $src --output $obf \
--compact true \
--self-defending true \
--string-array true --string-array-encoding base64 --string-array-threshold 0.9 \
--string-array-rotate true --string-array-shuffle true \
--control-flow-flattening true --control-flow-flattening-threshold 0.4 \
--dead-code-injection true --dead-code-injection-threshold 0.2 \
--unicode-escape-sequence false
or begin
echo "obfuscator failed"
exit 1
end
echo ":: syntax check"
node --check $min; and node --check $obf
or begin
echo "SYNTAX CHECK FAILED - not touching your pages"
exit 1
end
# banner goes on after the tools run, or they'd strip it
for f in $min $obf
set -l tmp (mktemp)
echo $banner > $tmp
cat $f >> $tmp
mv $tmp $f
end
# bump ?v=N wherever a build is referenced, so browsers actually refetch
set -l bumped 0
for f in *.php example/*.html example/*.php
test -f $f; or continue
set -l cur (grep -oP 'den\.(min|obf)\.js\?v=\K\d+' $f | head -1)
if test -n "$cur"
set -l new (math $cur + 1)
sed -i "s/\(den\.\(min\|obf\)\.js?v=\)$cur/\1$new/g" $f
echo " $f: v$cur -> v$new"
set bumped (math $bumped + 1)
end
end
if test $bumped -eq 0
echo " (no pages with ?v= found - remember to bust cache yourself)"
end
echo ":: done"
ls -la $src $min $obf | awk '{printf " %8s %s\n", $5, $9}'

2
build/den.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
build/den.obf.js Normal file

File diff suppressed because one or more lines are too long

126
example/index.html Normal file
View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>den.js — there is a kobold on this page</title>
<style>
:root{
--bg1:#2a1e3d; --bg2:#3d2a55; --panel:#241a36; --panel2:#2f2145;
--pink:#ffb3de; --mint:#a8ffe0; --lav:#c9b8ff; --hot:#ff6ec7;
--ink:#f2e9ff; --dim:#9d8bc4;
}
*{box-sizing:border-box;}
body{
margin:0; padding:24px 16px 80px;
color:var(--ink);
font-family:"Comic Sans MS","Comic Sans","Segoe UI",sans-serif;
background:
radial-gradient(1.5px 1.5px at 30px 40px,#ffffffcc,transparent),
radial-gradient(1px 1px at 120px 90px,#ffb3decc,transparent),
radial-gradient(2px 2px at 200px 30px,#a8ffe0aa,transparent),
linear-gradient(160deg,var(--bg1),var(--bg2) 60%,#1e1530);
background-size:260px 200px,260px 200px,260px 200px,cover;
background-attachment:fixed;
}
.wrap{max-width:720px;margin:0 auto;}
h1{
font-size:clamp(34px,8vw,58px); margin:0; text-align:center;
background:linear-gradient(90deg,var(--pink),var(--mint),var(--lav),var(--pink));
-webkit-background-clip:text; background-clip:text; color:transparent;
filter:drop-shadow(3px 3px 0 #00000088);
}
.sub{text-align:center;color:var(--dim);font-size:13px;margin:6px 0 20px;}
.panel{
border:3px ridge var(--lav); border-radius:12px;
background:var(--panel); padding:14px 16px; margin-bottom:12px;
}
.panel.alt{background:var(--panel2);}
h2{font-size:16px;margin:0 0 8px;color:var(--mint);border-bottom:2px dashed var(--pink);padding-bottom:4px;}
h2::before{content:"🐾 ";}
p{font-size:14px;line-height:1.5;margin:6px 0;}
code{background:#1b1229;color:var(--mint);padding:1px 5px;border-radius:4px;font-size:12px;}
pre{background:#0d0818;border:2px inset #6b5a8a;border-radius:8px;padding:10px;overflow-x:auto;}
pre code{background:none;padding:0;}
.loot{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin-top:8px;}
.badge{
width:88px;height:31px;display:flex;align-items:center;justify-content:center;
font-size:9px;font-weight:bold;text-align:center;font-family:Verdana,Arial,sans-serif;
border:1px solid #fff;line-height:1.1;
}
.b1{background:#000;color:#0f0;} .b2{background:linear-gradient(#37276b,#1e1443);color:var(--mint);}
.b3{background:#fff3a8;color:#1e1530;} .b4{background:linear-gradient(90deg,var(--hot),var(--mint));color:#1e1530;}
button{
font-family:inherit;font-size:13px;font-weight:bold;color:#1e1530;
background:linear-gradient(180deg,var(--pink),var(--hot));
border:3px outset #ffd6ef;border-radius:10px;padding:5px 12px;cursor:pointer;margin:3px;
}
button:active{border-style:inset;}
kbd{background:#1b1229;border:1px solid #6b5a8a;border-radius:4px;padding:1px 5px;font-size:11px;}
.hint{font-size:12px;color:var(--dim);}
</style>
</head>
<body>
<div class="wrap">
<h1>den.js</h1>
<p class="sub">there is a kobold on this page. you cannot see him. move your mouse.</p>
<div class="panel" data-den-poi="yip (this is the part about me)|yip!!">
<h2>hello</h2>
<p>He is invisible. What you can see is his paw prints, his little comments,
and — if you leave him alone long enough — the bed he drags out.</p>
<p class="hint">Give him a moment to climb out of his burrow.</p>
</div>
<div class="panel alt" data-den-poi="yip? (are they talking about me)">
<h2>try this</h2>
<p><b>Move your cursor</b> — he comes to it. Stop, and he waits, then gets bored and wanders off.</p>
<p><b>Click him</b> — where the prints are, not where your cursor is. He yips and spins. Do it ten times and he decides you're friends.</p>
<p><b>Type <code>yip</code></b> — anywhere on the page. He answers.</p>
<p><b>Open the <code>&gt;_ den</code> tab</b> in the corner, then try <code>come</code>, <code>fetch</code>, <code>status</code>, or <code>name crumb</code>.</p>
<p><b>Press</b> <kbd></kbd><kbd></kbd><kbd></kbd><kbd></kbd><kbd></kbd><kbd></kbd><kbd></kbd><kbd></kbd><kbd>B</kbd><kbd>A</kbd> — or just type <code>zoomies</code>.</p>
<p><b>Wait ten minutes</b> without touching anything. He goes to bed.</p>
</div>
<div class="panel" data-den-poi="yip (mine)|yip!! (do not touch)">
<h2>things he can steal</h2>
<p>These are marked <code>data-den-steal</code>. Nothing is stealable unless
you say so. Hit <code>fetch</code> in the shell to make him take one now —
he always brings it back.</p>
<div class="loot">
<span class="badge b1" data-den-steal>NOTEPAD.EXE</span>
<span class="badge b2" data-den-steal>apache 4 life</span>
<span class="badge b3" data-den-steal>Y2K COMPLIANT</span>
<span class="badge b4" data-den-steal>free gifs</span>
</div>
</div>
<div class="panel alt" data-den-poi="yip? (what does this one do)">
<h2>events</h2>
<p>Pages tell him what happened; he decides how to feel about it.</p>
<p style="text-align:center;">
<button onclick="denEvent('celebrate',{text:'YIP!! (something good happened)'})">celebrate</button>
<button onclick="denEvent('investigate',{el:'#thing',text:'yip? (what is this)'})">investigate</button>
<button onclick="denEvent('alarm',{text:'YIP?! (INTRUDER)'})">alarm</button>
<button onclick="denEvent('mischief')">mischief</button>
<button onclick="denEvent('sleepy')">sleepy</button>
</p>
<p id="thing" class="hint" style="text-align:center;">↑ "investigate" sends him to this line, specifically.</p>
</div>
<div class="panel">
<h2>the whole integration</h2>
<pre><code>&lt;script src="/den.js"&gt;&lt;/script&gt;</code></pre>
<p>Optional markup:</p>
<pre><code>&lt;div data-den-poi="yip (sign it)"&gt; &lt;!-- he visits and comments --&gt;
&lt;span data-den-steal&gt; &lt;!-- he may take this --&gt;
&lt;span data-den-others&gt;&lt;/span&gt; &lt;!-- how many others are here --&gt;</code></pre>
</div>
<p class="sub">MIT licensed. if you put him on your site, he's yours now.</p>
</div>
<script src="../src/den.js"></script>
</body>
</html>

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "den.js",
"version": "1.0.0",
"description": "An invisible kobold lives on your website.",
"main": "src/den.js",
"files": ["src/", "build/", "README.md", "LICENSE"],
"scripts": {
"test": "node test/sim.js",
"test:min": "node test/sim.js build/den.min.js",
"test:obf": "node test/sim.js build/den.obf.js"
},
"keywords": ["cursor", "mascot", "kobold", "indieweb", "neocities", "vanilla-js"],
"license": "MIT",
"devDependencies": {
"jsdom": "^24.0.0"
}
}

873
src/den.js Normal file
View File

@@ -0,0 +1,873 @@
(function(){
if (window.__denCritter) return;
window.__denCritter = true;
var st = document.createElement('style');
st.textContent =
'.paw-trail{position:fixed;pointer-events:none;z-index:9999;animation:pawfade 1s linear forwards;filter:drop-shadow(0 0 3px currentColor);}' +
'@keyframes pawfade{0%{opacity:.9;transform:scale(1);}100%{opacity:0;transform:scale(.5) translateY(10px);}}' +
'.emote{position:fixed;pointer-events:none;z-index:9999;font-size:15px;animation:emoterise 1.2s ease-out forwards;}' +
'.emote.say{font-size:12px;font-weight:bold;color:#c9b8ff;text-shadow:0 0 6px #8f7bff,1px 1px 0 #120a20;white-space:nowrap;font-family:"Comic Sans MS","Comic Sans",cursive;}' +
'@keyframes emoterise{0%{opacity:0;transform:translateY(0) scale(.6);}15%{opacity:1;}100%{opacity:0;transform:translateY(-34px) scale(1.15);}}' +
'.denbed{position:fixed;z-index:9998;pointer-events:none;animation:bedin .6s ease-out;transition:opacity .45s ease;}' +
'@keyframes bedin{from{opacity:0;transform:translateY(10px);}to{opacity:1;transform:translateY(0);}}' +
'.denbed .lump{animation:breathe 2.8s ease-in-out infinite;transform-origin:38px 24px;}' +
'@keyframes breathe{0%,100%{transform:scaleY(1);}50%{transform:scaleY(1.17);}}' +
'body.denzoom *,body.denzoom *::before,body.denzoom *::after{animation-duration:.22s !important;}' +
'.denhole{position:fixed;z-index:9997;pointer-events:none;transition:opacity .55s ease;animation:holein .5s ease-out;}' +
'@keyframes holein{from{opacity:0;transform:scale(.6);}to{opacity:1;transform:scale(1);}}' +
'.dentab{position:fixed;right:14px;bottom:0;text-align:left;z-index:10000;background:#1b1229;color:#8f7bff;border:2px ridge #6b5a8a;border-bottom:none;border-radius:8px 8px 0 0;font-family:"Courier New",monospace;font-size:11px;padding:3px 10px;cursor:pointer;opacity:.55;transition:opacity .2s;user-select:none;}' +
'.dentab:hover{opacity:1;}' +
'.denterm{position:fixed;right:8px;bottom:0;z-index:10000;width:300px;max-width:86vw;background:#0d0818;border:3px ridge #6b5a8a;border-bottom:none;border-radius:10px 10px 0 0;font-family:"Courier New",monospace;font-size:12px;padding:8px;display:none;text-align:left;}' +
'.denterm.open{display:block;}' +
'.denterm .dout{height:170px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;color:#c9b8ff;}' +
'.denterm .dline{display:flex;gap:6px;margin-top:5px;align-items:center;}' +
'.denterm .dprompt{color:#ff6ec7;white-space:nowrap;}' +
'.denterm input{flex:1;background:transparent;border:none;outline:none;color:#a8ffe0;font-family:inherit;font-size:12px;}';
document.head.appendChild(st);
var actx = null;
function yipSound(){
try {
if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
if (actx.state === 'suspended') actx.resume();
var o = actx.createOscillator(), g = actx.createGain();
var t = actx.currentTime;
o.type = 'sine';
o.frequency.setValueAtTime(700 + Math.random() * 200, t);
o.frequency.exponentialRampToValueAtTime(1400 + Math.random() * 300, t + 0.07);
o.frequency.exponentialRampToValueAtTime(600, t + 0.14);
g.gain.setValueAtTime(0.12, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
o.connect(g); g.connect(actx.destination);
o.start(t); o.stop(t + 0.18);
} catch(e){}
}
window.denYip = yipSound;
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
window.denSay = function(){};
return;
}
var PAW = '<svg width="14" height="15" viewBox="0 0 20 21" xmlns="http://www.w3.org/2000/svg">' +
'<ellipse cx="10" cy="14.5" rx="5.2" ry="4.6"/>' +
'<ellipse cx="3.2" cy="8.6" rx="2.1" ry="2.7"/>' +
'<ellipse cx="7.6" cy="5.4" rx="2.1" ry="2.8"/>' +
'<ellipse cx="12.4" cy="5.4" rx="2.1" ry="2.8"/>' +
'<ellipse cx="16.8" cy="8.6" rx="2.1" ry="2.7"/>' +
'</svg>';
var colors = ['#8f7bff', '#4fc3ff'];
var spawnEdge = Math.floor(Math.random() * 4);
var sx, sy;
if (spawnEdge === 0) { sx = 70; sy = 80 + Math.random() * Math.max(window.innerHeight - 160, 1); }
else if (spawnEdge === 1) { sx = window.innerWidth - 70; sy = 80 + Math.random() * Math.max(window.innerHeight - 160, 1); }
else if (spawnEdge === 2) { sx = 80 + Math.random() * Math.max(window.innerWidth - 160, 1); sy = 70; }
else { sx = 80 + Math.random() * Math.max(window.innerWidth - 160, 1); sy = window.innerHeight - 70; }
var pos = { x: sx, y: sy };
var heading = Math.random() * Math.PI * 2;
var target = { x: pos.x, y: pos.y };
var mode = 'rest';
var restUntil = performance.now() + 800;
var petUntil = 0;
var nextZzz = performance.now() + 99999;
var lastEmote = 0;
var side = 1, distSince = 0;
var lastT = performance.now();
var WALK = 70, TROT = 150, RUN = 340;
var STRIDE_WALK = 24, STRIDE_RUN = 38;
var TURN = 4.0;
var SLEEPY_AFTER = 600000;
var lastActivity = performance.now();
var sleepPrepUntil = 0, wakeUntil = 0, suppressBumpUntil = 0;
var asleep = false, bedEl = null, nextSleepZ = 0;
var speedMult = 1;
var poiSay = null, lastPoi = null;
var errandTarget = null, errandSay = null, errandRun = false, errandDeadline = 0;
var mis = null, lastMischief = 0;
var dout = null, dinp = null, dpr = null;
var mem = { v: 0, pets: 0, name: null, last: 0, id: null };
try {
var m0 = JSON.parse(localStorage.getItem('denmem'));
if (m0 && typeof m0 === 'object') {
mem.v = m0.v | 0;
mem.pets = m0.pets | 0;
mem.name = (typeof m0.name === 'string' && m0.name) ? m0.name.slice(0, 16) : null;
mem.last = m0.last || 0;
if (typeof m0.id === 'string' && /^[a-f0-9]{16}$/.test(m0.id)) mem.id = m0.id;
}
} catch(e){}
var prevLast = mem.last;
mem.v++;
mem.last = Date.now();
if (!mem.id) {
var idb = '';
try {
var ida = new Uint8Array(8);
crypto.getRandomValues(ida);
for (var ii = 0; ii < 8; ii++) idb += ('0' + ida[ii].toString(16)).slice(-2);
} catch(e) {
for (var ij = 0; ij < 16; ij++) idb += Math.floor(Math.random() * 16).toString(16);
}
mem.id = idb;
}
function saveMem(){
try { localStorage.setItem('denmem', JSON.stringify(mem)); } catch(e){}
}
saveMem();
function greeting(){
var away = prevLast ? (Date.now() - prevLast) : 0;
if (mem.v <= 1) return '*yip* (hello)';
if (away > 604800000) return 'YIP!! (where were u)';
if (mem.name) return '*yip* (' + mem.name + ' missed u)';
if (mem.v >= 8) return 'yip (welcome home)';
return 'yip!! (u came back)';
}
window.denSetName = function(n){
n = String(n || '').replace(/[^\w \-]/g, '').trim().slice(0, 16);
if (!n) return null;
mem.name = n;
saveMem();
sayText('yip!! (i am ' + n + ')');
emote('\uD83D\uDC96', true);
if (dpr) dpr.textContent = promptTxt();
return n;
};
window.denGetName = function(){ return mem.name; };
function promptTxt(){ return (mem.name || 'guest') + '@den ~>'; }
function denPhrase(n){
if (n <= 0) return 'just u + the kobold rn';
if (n === 1) return '1 other is here rn';
return n + ' others are here rn';
}
function checkPresence(){
if (!window.fetch) return;
fetch('/den.php?id=' + mem.id, { cache: 'no-store' }).then(function(r){
if (!r.ok) return null;
return r.json();
}).then(function(j){
if (!j) return;
var n = j.others | 0;
window.denOthers = n;
var els = document.querySelectorAll('[data-den-others]');
for (var i = 0; i < els.length; i++) els[i].textContent = denPhrase(n);
}).catch(function(){});
}
function pois(){
var out = [];
var els = document.querySelectorAll('[data-den-poi]');
for (var i = 0; i < els.length; i++) {
var r = els[i].getBoundingClientRect();
if (r.width < 10 || r.height < 10) continue;
if (r.bottom < 40 || r.top > window.innerHeight - 40) continue;
if (r.right < 40 || r.left > window.innerWidth - 40) continue;
out.push({ el: els[i], r: r });
}
return out;
}
function emote(ch, force){
var now = performance.now();
if (!force && now - lastEmote < 500) return;
lastEmote = now;
var el = document.createElement('span');
el.className = 'emote';
el.textContent = ch;
el.style.left = (pos.x + (Math.random() * 16 - 8)) + 'px';
el.style.top = (pos.y - 18) + 'px';
document.body.appendChild(el);
setTimeout(function(){ el.remove(); }, 1250);
}
function pick(arr){ return arr[Math.floor(Math.random() * arr.length)]; }
function sayText(t){
var el = document.createElement('span');
el.className = 'emote say';
el.textContent = t;
el.style.left = (pos.x - 10) + 'px';
el.style.top = (pos.y - 22) + 'px';
document.body.appendChild(el);
setTimeout(function(){ el.remove(); }, 1250);
}
window.denSay = sayText;
function makeBed(){
bedEl = document.createElement('div');
bedEl.className = 'denbed';
bedEl.innerHTML = '<svg width="64" height="42" viewBox="0 0 64 42" xmlns="http://www.w3.org/2000/svg">' +
'<rect x="2" y="24" width="60" height="11" rx="5" fill="#3d2a55" stroke="#8f7bff" stroke-width="2"/>' +
'<rect x="3" y="33" width="4" height="8" rx="1" fill="#8f7bff"/>' +
'<rect x="57" y="33" width="4" height="8" rx="1" fill="#8f7bff"/>' +
'<ellipse cx="13" cy="22" rx="9" ry="5" fill="#c9b8ff"/>' +
'<path class="lump" d="M23 26 Q33 10 53 23 L53 26 Z" fill="#4fc3ff" stroke="#8f7bff" stroke-width="1.5"/>' +
'</svg>';
bedEl.style.left = (pos.x - 32) + 'px';
bedEl.style.top = (pos.y - 26) + 'px';
document.body.appendChild(bedEl);
}
function bump(){
var now = performance.now();
if (now < suppressBumpUntil) return;
lastActivity = now;
if (sleepPrepUntil) sleepPrepUntil = 0;
if (asleep) {
asleep = false;
if (bedEl) {
var b = bedEl; bedEl = null;
b.style.opacity = '0';
setTimeout(function(){ b.remove(); }, 500);
}
emote('\u2728', true);
sayText('...yip?');
wakeUntil = now + 900;
}
}
document.addEventListener('keydown', bump);
document.addEventListener('scroll', bump, { passive: true });
document.addEventListener('touchstart', bump, { passive: true });
document.addEventListener('mousemove', function(e){
target.x = e.clientX; target.y = e.clientY;
bump();
if (mode === 'errand' || mode === 'mischief') return;
var now = performance.now();
if (now < petUntil || now < wakeUntil) return;
if (mode !== 'chase') emote('\u2757');
mode = 'chase';
});
function petHim(){
var now = performance.now();
if (mode === 'mischief') returnItem();
petUntil = now + 800;
mode = 'rest';
restUntil = now + 2000;
nextZzz = now + 99999;
emote(pick(['\uD83D\uDC95','\uD83D\uDC96','\uD83D\uDC97']), true);
setTimeout(function(){ emote('\uD83D\uDC95', true); }, 240);
yipSound();
mem.pets++;
saveMem();
if (mem.pets === 10) setTimeout(function(){ sayText('yip!! (we are friends now)'); }, 420);
else if (mem.pets === 25) setTimeout(function(){ sayText('yip (best friend)'); }, 420);
else if (mem.pets === 50) setTimeout(function(){ sayText('yip!! (bonded 4 life)'); }, 420);
else if (mem.pets === 100) setTimeout(function(){ sayText('yip (soulmates)'); }, 420);
}
document.addEventListener('click', function(e){
bump();
if (e.target.closest && e.target.closest('a,button,input,textarea,label,select')) return;
if (Math.hypot(e.clientX - pos.x, e.clientY - pos.y) > 48) return;
petHim();
});
function stealables(){
var out = [];
var els = document.querySelectorAll('[data-den-steal]');
for (var i = 0; i < els.length; i++) {
var r = els[i].getBoundingClientRect();
if (r.width < 8 || r.width > 220 || r.height < 8 || r.height > 90) continue;
if (r.bottom < 40 || r.top > window.innerHeight - 60) continue;
if (r.right < 40 || r.left > window.innerWidth - 60) continue;
if (els[i].style.visibility === 'hidden') continue;
out.push(els[i]);
}
return out;
}
function returnItem(){
if (mis && mis.clone) {
mis.el.style.visibility = '';
mis.clone.remove();
}
if (mis) {
mis = null;
mode = 'wander';
target = pickWander();
}
}
function startMischief(force){
var now = performance.now();
if (mis) return false;
if (!force && now - lastMischief < 150000) return false;
var cands = stealables();
if (!cands.length) return false;
if (!force && Math.random() > 0.07) return false;
var el = cands[Math.floor(Math.random() * cands.length)];
var r = el.getBoundingClientRect();
mis = {
el: el,
phase: 'togo',
pt: clampPt(r.left + r.width / 2, r.top + r.height / 2),
deadline: now + 18000,
gift: mem.pets >= 25
};
lastMischief = now;
mode = 'mischief';
return true;
}
function grabItem(now){
var r = mis.el.getBoundingClientRect();
mis.clone = document.createElement('div');
mis.clone.style.cssText = 'position:fixed;z-index:9998;pointer-events:none;margin:0;left:' + r.left + 'px;top:' + r.top + 'px;width:' + r.width + 'px;height:' + r.height + 'px;';
var inner = mis.el.cloneNode(true);
inner.style.margin = '0';
mis.clone.appendChild(inner);
document.body.appendChild(mis.clone);
mis.el.style.visibility = 'hidden';
mis.cw = r.width / 2;
mis.ch = r.height;
if (mis.gift) {
sayText('yip!');
} else {
var aa = Math.random() * Math.PI * 2;
mis.dropPt = clampPt(pos.x + Math.cos(aa) * 300, pos.y + Math.sin(aa) * 300);
sayText(pick(['yip yip yip (mine now)', '(catch me)', 'yip!! (tax)']));
}
mis.phase = 'carry';
}
function clampPt(x, y){
return {
x: Math.min(Math.max(x, 20), window.innerWidth - 20),
y: Math.min(Math.max(y, 20), window.innerHeight - 20)
};
}
function resolvePoint(opts){
var el = opts.el;
if (typeof el === 'string') el = document.querySelector(el);
if (el && el.getBoundingClientRect) {
var r = el.getBoundingClientRect();
if (r.width > 0) return clampPt(r.left + r.width * 0.5, r.top + Math.min(r.height * 0.4, 50));
}
if (typeof opts.x === 'number' && typeof opts.y === 'number') return clampPt(opts.x, opts.y);
return null;
}
window.denEvent = function(type, opts){
opts = opts || {};
var now = performance.now();
lastActivity = now;
if (asleep) bump();
if (mode === 'mischief' && type !== 'mischief') returnItem();
if (type === 'mischief') {
startMischief(true);
return;
}
if (type === 'celebrate') {
petUntil = now + 1100;
mode = 'rest';
restUntil = now + 2800;
nextZzz = now + 99999;
emote('\u2728', true);
setTimeout(function(){ emote('\uD83D\uDC95', true); }, 300);
setTimeout(function(){ emote('\u2728', true); }, 650);
setTimeout(function(){ sayText(opts.text || 'YIP!!'); }, 180);
yipSound();
} else if (type === 'investigate') {
var pt = resolvePoint(opts);
if (!pt) return;
errandTarget = pt;
errandSay = opts.text || null;
errandRun = !!opts.run;
errandDeadline = now + 9000;
mode = 'errand';
} else if (type === 'alarm') {
emote('\u26A0\uFE0F', true);
sayText(opts.text || 'YIP?!');
var aa = Math.random() * Math.PI * 2;
errandTarget = clampPt(pos.x + Math.cos(aa) * 260, pos.y + Math.sin(aa) * 260);
errandSay = null;
errandRun = true;
errandDeadline = now + 4000;
mode = 'errand';
} else if (type === 'sleepy') {
suppressBumpUntil = now + 800;
sleepPrepUntil = now + 1400;
sayText('*yawn*');
} else if (type === 'zoomies') {
zoomies();
} else if (opts.text) {
sayText(opts.text);
}
};
function zoomies(){
document.body.classList.add('denzoom');
speedMult = 7.5;
var old = document.title;
document.title = 'YIP YIP YIP YIP YIP';
sayText('YIP YIP YIP YIP');
setTimeout(function(){
document.body.classList.remove('denzoom');
speedMult = 1;
document.title = old;
}, 10000);
}
var kseq = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
var kpos = 0;
document.addEventListener('keydown', function(e){
var k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
kpos = (k === kseq[kpos]) ? kpos + 1 : (k === kseq[0] ? 1 : 0);
if (kpos !== kseq.length) return;
kpos = 0;
zoomies();
});
var buf = '';
document.addEventListener('keydown', function(e){
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key.length !== 1) return;
buf = (buf + e.key.toLowerCase()).slice(-12);
if (buf.slice(-3) === 'yip') { sayText('yip!!'); buf = ''; }
else if (buf.slice(-3) === 'owo') { sayText('*notices u*'); buf = ''; }
else if (buf.slice(-3) === 'uwu') { sayText('yip (no)'); buf = ''; }
else if (buf.slice(-6) === 'kobold') { emote('\uD83D\uDC95', true); sayText('yip!! (that is me)'); buf = ''; }
else if (buf.slice(-6) === 'shrine') { sayText('YIP?! (how do u know about that)'); buf = ''; }
else if (buf.slice(-7) === 'zoomies') { zoomies(); buf = ''; }
else if (buf.slice(-5) === 'sleep') {
suppressBumpUntil = performance.now() + 800;
sleepPrepUntil = performance.now() + 1400;
sayText('*yawn*');
buf = '';
}
});
function angDiff(a, b){
var d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return d;
}
function pickWander(){
poiSay = null;
if (Math.random() < 0.45) {
var ps = pois().filter(function(p){ return p.el !== lastPoi; });
if (ps.length) {
var p = ps[Math.floor(Math.random() * ps.length)];
lastPoi = p.el;
var txts = (p.el.getAttribute('data-den-poi') || '').split('|').filter(Boolean);
if (txts.length) poiSay = txts[Math.floor(Math.random() * txts.length)];
return {
x: Math.min(Math.max(p.r.left + p.r.width * (0.25 + Math.random() * 0.5), 20), window.innerWidth - 20),
y: Math.min(Math.max(p.r.top + Math.min(p.r.height * 0.4, 60) + Math.random() * 20, 20), window.innerHeight - 20)
};
}
}
var r = 60 + Math.random() * 120;
var a = heading + (Math.random() - 0.5) * 2.6;
return {
x: Math.min(Math.max(pos.x + Math.cos(a) * r, 20), window.innerWidth - 20),
y: Math.min(Math.max(pos.y + Math.sin(a) * r, 20), window.innerHeight - 20)
};
}
function drop(){
side = -side;
var p = document.createElement('span');
p.className = 'paw-trail';
p.innerHTML = PAW;
var c = colors[side > 0 ? 0 : 1];
p.querySelector('svg').style.fill = c;
p.style.color = c;
var deg = heading * 180 / Math.PI + 90;
var ox = -Math.sin(heading) * 7 * side;
var oy = Math.cos(heading) * 7 * side;
p.style.left = (pos.x + ox - 7) + 'px';
p.style.top = (pos.y + oy - 7) + 'px';
p.querySelector('svg').style.transform = 'rotate(' + deg + 'deg) scaleX(' + side + ')';
p.querySelector('svg').style.display = 'block';
document.body.appendChild(p);
setTimeout(function(){ p.remove(); }, 1300);
}
function tick(now){
var dt = Math.min((now - lastT) / 1000, 0.05);
lastT = now;
var zoom = speedMult;
if (asleep) {
if (now > nextSleepZ) { emote('\uD83D\uDCA4', true); nextSleepZ = now + 2600; }
requestAnimationFrame(tick);
return;
}
if (sleepPrepUntil) {
if (now < sleepPrepUntil) {
heading += 7 * dt;
var bs = WALK * 0.7 * dt;
pos.x += Math.cos(heading) * bs;
pos.y += Math.sin(heading) * bs;
distSince += bs;
if (distSince >= STRIDE_WALK * 0.8) { distSince = 0; drop(); }
} else {
sleepPrepUntil = 0;
asleep = true;
pos.x = Math.min(Math.max(pos.x, 44), window.innerWidth - 44);
pos.y = Math.min(Math.max(pos.y, 40), window.innerHeight - 30);
makeBed();
sayText('...zzz');
nextSleepZ = now + 1800;
}
requestAnimationFrame(tick);
return;
}
if (now - lastActivity > SLEEPY_AFTER && now > petUntil) {
sleepPrepUntil = now + 1400;
sayText('*yawn*');
requestAnimationFrame(tick);
return;
}
if (now < wakeUntil) {
requestAnimationFrame(tick);
return;
}
if (now < petUntil) {
heading += 9 * dt;
var st2 = WALK * 0.9 * zoom * dt;
pos.x += Math.cos(heading) * st2;
pos.y += Math.sin(heading) * st2;
distSince += st2;
if (distSince >= STRIDE_WALK * 0.8) { distSince = 0; drop(); }
requestAnimationFrame(tick);
return;
}
if (mode === 'mischief') {
if (!mis) {
mode = 'wander';
target = pickWander();
requestAnimationFrame(tick);
return;
}
if (now > mis.deadline) {
if (mis.phase === 'carry') {
mis.phase = 'dropped';
mis.dropTime = now;
mis.deadline = now + 12000;
sayText(mis.gift ? '*wheeze* (u are fast. here)' : '*wheeze* (fine)');
} else {
returnItem();
requestAnimationFrame(tick);
return;
}
}
if (mis.phase === 'togo' || mis.phase === 'carry') {
var mdest;
if (mis.phase === 'togo') mdest = mis.pt;
else mdest = mis.gift ? clampPt(target.x, target.y) : mis.dropPt;
var mx = mdest.x - pos.x, my = mdest.y - pos.y;
var md = Math.hypot(mx, my);
if (md < 16) {
if (mis.phase === 'togo') {
grabItem(now);
} else {
mis.phase = 'dropped';
mis.dropTime = now;
if (mis.gift) {
sayText(pick(['uwu (for u)', 'yip (i brought u this)', '(i love u)']));
emote('\uD83D\uDC95', true);
setTimeout(function(){ emote('\uD83D\uDC96', true); }, 300);
} else {
sayText(pick(['yip (u can have it back)', '(hehe)', 'yip yip (a good crime)']));
}
}
} else {
var mw = Math.atan2(my, mx);
var mmx = TURN * dt * 2.2;
heading += Math.max(-mmx, Math.min(mmx, angDiff(heading, mw)));
var msp = (mis.phase === 'carry' && !mis.gift ? RUN : TROT) * zoom;
var mst = (mis.phase === 'carry' && !mis.gift ? STRIDE_RUN : STRIDE_WALK);
var mstep = Math.min(msp * dt, md);
pos.x += Math.cos(heading) * mstep;
pos.y += Math.sin(heading) * mstep;
distSince += mstep;
if (distSince >= mst) { distSince = 0; drop(); }
}
if (mis && mis.clone && mis.phase === 'carry') {
mis.clone.style.left = (pos.x - mis.cw) + 'px';
mis.clone.style.top = (pos.y - mis.ch - 14) + 'px';
}
} else if (mis.phase === 'dropped') {
if (now > mis.dropTime + (mis.gift ? 4200 : 5200)) {
var hr = mis.el.getBoundingClientRect();
mis.clone.style.transition = 'left .6s ease, top .6s ease, opacity .6s ease';
mis.clone.style.left = hr.left + 'px';
mis.clone.style.top = hr.top + 'px';
mis.phase = 'returning';
mis.retTime = now;
if (!mis.gift) sayText('yip (fine. take it back)');
}
} else if (mis.phase === 'returning') {
if (now > mis.retTime + 700) {
emote('\u2728', true);
returnItem();
}
}
requestAnimationFrame(tick);
return;
}
if (mode === 'errand') {
if (!errandTarget || now > errandDeadline) {
errandTarget = null;
mode = 'wander';
target = pickWander();
} else {
var ex = errandTarget.x - pos.x, ey = errandTarget.y - pos.y;
var ed = Math.hypot(ex, ey);
if (ed < 16) {
if (errandSay) sayText(errandSay);
else emote(pick(['\uD83D\uDC43','\u2753']));
errandTarget = null;
errandSay = null;
mode = 'rest';
restUntil = now + 1500;
nextZzz = now + 99999;
} else {
var ew = Math.atan2(ey, ex);
var emx = TURN * dt * 2.2;
heading += Math.max(-emx, Math.min(emx, angDiff(heading, ew)));
var esp = (errandRun ? RUN : TROT) * zoom;
var est = (errandRun ? STRIDE_RUN : STRIDE_WALK);
var estep = Math.min(esp * dt, ed);
pos.x += Math.cos(heading) * estep;
pos.y += Math.sin(heading) * estep;
distSince += estep;
if (distSince >= est) { distSince = 0; drop(); }
}
}
requestAnimationFrame(tick);
return;
}
var dx = target.x - pos.x, dy = target.y - pos.y;
var dist = Math.hypot(dx, dy);
if (mode === 'chase') {
if (dist < 26) {
mode = 'rest';
restUntil = now + (mem.pets >= 25 ? 4200 : 2500);
nextZzz = now + 1700;
if (Math.random() < 0.5) emote(mem.pets >= 10 ? '\uD83D\uDC95' : '\u2728');
}
} else if (mode === 'rest') {
if (now > nextZzz) { emote('\uD83D\uDCA4'); nextZzz = now + 1600; }
if (now > restUntil) {
if (!startMischief(false)) { target = pickWander(); mode = 'wander'; }
}
} else {
if (dist < 14) {
mode = 'rest'; nextZzz = now + 99999;
if (poiSay) {
restUntil = now + 1200 + Math.random() * 1600;
sayText(poiSay);
poiSay = null;
} else {
restUntil = now + 600 + Math.random() * 1600;
emote(pick(['\uD83D\uDC43','\u2753','\uD83C\uDF38','\u2728','\uD83D\uDC3E']));
}
}
}
if (mode !== 'rest' && dist > 1) {
var want = Math.atan2(dy, dx);
var maxTurn = TURN * dt * (mode === 'chase' ? 2.2 : 1);
heading += Math.max(-maxTurn, Math.min(maxTurn, angDiff(heading, want)));
var running = (mode === 'chase' && dist > 160);
var speed = (mode === 'chase' ? (running ? RUN : TROT) : WALK) * zoom;
var stride = running ? STRIDE_RUN : STRIDE_WALK;
var step = Math.min(speed * dt, dist);
pos.x += Math.cos(heading) * step;
pos.y += Math.sin(heading) * step;
distSince += step;
if (distSince >= stride) { distSince = 0; drop(); }
}
requestAnimationFrame(tick);
}
var tab = document.createElement('div');
tab.className = 'dentab';
tab.textContent = '>_ den';
var term = document.createElement('div');
term.className = 'denterm';
term.innerHTML = '<div class="dout">den shell v2 (now portable)\ntype "help"\n</div>' +
'<div class="dline"><span class="dprompt"></span><input autocomplete="off" spellcheck="false" aria-label="den shell input"></div>';
function dprint(t){ dout.textContent += t + '\n'; dout.scrollTop = dout.scrollHeight; }
function openTerm(){ term.classList.add('open'); tab.style.display = 'none'; dinp.focus(); }
function closeTerm(){ term.classList.remove('open'); tab.style.display = ''; }
var petSeen = 0;
function runCmd(raw){
var lc = raw.toLowerCase();
if (lc.indexOf('name ') === 0) {
var nn = window.denSetName(raw.slice(5));
dprint(nn ? 'the kobold accepts the name "' + nn + '". this is legally binding' : 'that name did not survive processing. try letters');
return;
}
if (lc === 'name') {
dprint(mem.name ? 'the kobold is named "' + mem.name + '"' : 'the kobold has no name yet. try: name <something>');
return;
}
if (lc.indexOf('sudo') === 0) { dprint('no.'); return; }
if (lc.indexOf('rm ') === 0 || lc === 'rm') { dprint('rm: the hoard is load-bearing. request denied'); return; }
switch (lc) {
case 'help':
dprint('commands: pet, come, sit, fetch, sleep, wake, zoomies, where, status, name <x>, ls, whoami, pwd, neofetch, yip, clear, exit');
break;
case 'pet':
case 'pet kobold':
petSeen++;
petHim();
dprint(petSeen >= 5 ? '*yip yip yip* (maximum happiness. further pets stored for later)' : '*pets dispensed remotely* yip!');
break;
case 'fetch': {
if (startMischief(true)) {
dprint(mem.pets >= 25 ? 'he goes to find u something' : 'oh no');
} else {
dprint(mis ? 'his paws are full' : 'nothing here worth taking. sad');
}
break;
}
case 'come': {
var r = term.getBoundingClientRect();
target = {
x: Math.min(Math.max(r.left + 40, 20), window.innerWidth - 20),
y: Math.min(Math.max(r.top - 40, 20), window.innerHeight - 20)
};
if (asleep) bump();
mode = 'chase';
dprint('he is on his way');
break;
}
case 'sit':
mode = 'rest';
restUntil = performance.now() + 6000;
nextZzz = performance.now() + 99999;
sayText('yip (sitting)');
dprint('he sits. a good boy');
break;
case 'sleep':
suppressBumpUntil = performance.now() + 800;
sleepPrepUntil = performance.now() + 1400;
sayText('*yawn*');
dprint('bedtime initiated');
break;
case 'wake':
if (asleep) { bump(); dprint('he stirs'); }
else dprint('he is already awake and probably watching u');
break;
case 'zoomies':
zoomies();
dprint('oh no');
break;
case 'where':
sayText('yip!! (here)');
emote('\u2728', true);
dprint('listen for the yip');
break;
case 'status':
dprint('mode: ' + (asleep ? 'asleep' : mode) + ' | pets: ' + mem.pets + ' | name: ' + (mem.name || '(unnamed)') + ' | visits: ' + mem.v);
break;
case 'ls':
dprint('den/ hoard/ guestbook.txt do_not_open/ retopo_FINAL_v2/');
break;
case 'whoami':
dprint('a guest in the den');
break;
case 'pwd':
dprint('/home/den/u');
break;
case 'yip':
sayText('yip!!');
dprint('yip!!');
break;
case 'neofetch':
dprint(' /\\_/\\ guest@den\n ( o.o ) ----------\n > ^ < OS: DenOS (yes it is just apache)\n kobold btw Shell: den shell 2.0\n Uptime: emotionally? unclear');
break;
case 'clear':
dout.textContent = '';
break;
case 'exit':
dprint('u can check out any time u like...');
setTimeout(closeTerm, 600);
break;
default:
dprint("fish: Unknown command: '" + raw.split(' ')[0].replace(/[^\w.-]/g, '') + "'");
}
}
function mountShell(){
document.body.appendChild(tab);
document.body.appendChild(term);
dout = term.querySelector('.dout');
dinp = term.querySelector('input');
dpr = term.querySelector('.dprompt');
dpr.textContent = promptTxt();
tab.addEventListener('click', openTerm);
dinp.addEventListener('keydown', function(e){
if (e.key === 'Escape') { closeTerm(); return; }
if (e.key !== 'Enter') return;
var raw = dinp.value.trim();
dinp.value = '';
if (!raw) return;
dprint(promptTxt() + ' ' + raw);
runCmd(raw);
});
}
var hole = document.createElement('div');
hole.className = 'denhole';
hole.innerHTML = '<svg width="56" height="34" viewBox="0 0 56 34" xmlns="http://www.w3.org/2000/svg">' +
'<ellipse cx="28" cy="17" rx="26" ry="14" fill="#0d0818" stroke="#8f7bff" stroke-width="2.5"/>' +
'<ellipse cx="28" cy="19" rx="18" ry="8" fill="#000"/>' +
'<ellipse cx="20" cy="10" rx="4" ry="1.6" fill="#3d2a55"/>' +
'</svg>';
hole.style.left = (pos.x - 28) + 'px';
hole.style.top = (pos.y - 17) + 'px';
function beginLife(){
mountShell();
checkPresence();
setInterval(checkPresence, 60000);
document.body.appendChild(hole);
setTimeout(function(){
emote('\u2728', true);
sayText(greeting());
var cx = window.innerWidth / 2, cy = window.innerHeight / 2;
var ang = Math.atan2(cy - pos.y, cx - pos.x);
heading = ang;
var r2 = 160 + Math.random() * 120;
target = {
x: Math.min(Math.max(pos.x + Math.cos(ang) * r2, 20), window.innerWidth - 20),
y: Math.min(Math.max(pos.y + Math.sin(ang) * r2, 20), window.innerHeight - 20)
};
poiSay = null;
mode = 'wander';
}, 700);
setTimeout(function(){
hole.style.opacity = '0';
setTimeout(function(){ hole.remove(); }, 600);
}, 2600);
setTimeout(function(){
var n = window.denOthers | 0;
if (n > 0) sayText('yip (' + n + (n === 1 ? ' other is' : ' others are') + ' here... i can smell them)');
}, 9000);
}
if (document.body) beginLife();
else document.addEventListener('DOMContentLoaded', beginLife);
requestAnimationFrame(tick);
})();

52
src/den.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
// presence endpoint for den.js
// counts client ids (js-running visitors), capped per ip to prevent inflation
// stores hmac'd fingerprints only, never raw ips or ids. entries expire after 5 min
// one line per active visitor: latest_timestamp|fingerprint|iphash
// change this secret, keep it private
define('DEN_SECRET', 'NowmIO4VXYYF6cmg6hJRw3P4ZEd4PIpylnmpKhj8QeW9fe7wfJ94lwTGw8PfwRgRB2MlZEqaIBftH9L3twsccucwq6gFKnmqLpXsTS0PqN1Glwi6tRYtH3hPKm0VD3U');
$f = __DIR__ . '/../owo_presence.txt';
$now = time();
$iph = substr(hash_hmac('sha1', $_SERVER['REMOTE_ADDR'], DEN_SECRET), 0, 12);
$id = isset($_GET['id']) ? $_GET['id'] : '';
if (preg_match('/^[a-f0-9]{16}$/', $id)) {
$me = substr(hash_hmac('sha1', 'id:' . $id, DEN_SECRET), 0, 12);
} else {
$me = $iph;
}
$roster = [];
if (file_exists($f)) {
foreach (file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $l) {
$p = explode('|', $l, 3);
if (count($p) < 2) continue;
$ts = (int)$p[0];
if ($ts < $now - 300) continue;
$fp = $p[1];
$rih = isset($p[2]) ? $p[2] : '';
if (!isset($roster[$fp]) || $ts > $roster[$fp][0]) {
$roster[$fp] = [$ts, $rih];
}
}
}
$from_my_ip = 0;
foreach ($roster as $fp => $e) {
if ($e[1] === $iph && $fp !== $me) $from_my_ip++;
}
if (isset($roster[$me]) || $from_my_ip < 3) {
$roster[$me] = [$now, $iph];
}
$others = count($roster) - (isset($roster[$me]) ? 1 : 0);
$out = '';
foreach ($roster as $fp => $e) {
$out .= $e[0] . '|' . $fp . '|' . $e[1] . "\n";
}
@file_put_contents($f, $out, LOCK_EX);
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode(['others' => $others]);

82
test/sim.js Normal file
View File

@@ -0,0 +1,82 @@
const { JSDOM } = require('jsdom');
const fs = require('fs');
const dom = new JSDOM(`<!DOCTYPE html><html><head></head><body>
<span id="loot">LOOT</span>
</body></html>`, { runScripts: 'outside-only', pretendToBeVisual: false });
const w = dom.window;
const doc = w.document;
// viewport
Object.defineProperty(w, 'innerWidth', { value: 1200 });
Object.defineProperty(w, 'innerHeight', { value: 800 });
// clock + rAF pump
let NOW = 0;
let rafQ = [];
w.performance.now = () => NOW;
w.requestAnimationFrame = (cb) => { rafQ.push(cb); return rafQ.length; };
function pump(ms, step = 16) {
const end = NOW + ms;
while (NOW < end) {
NOW += step;
// run any pending timeouts jsdom scheduled? jsdom timers use real time.
const q = rafQ; rafQ = [];
q.forEach(cb => cb(NOW));
}
}
// matchMedia
w.matchMedia = () => ({ matches: false });
// loot element rect
const loot = doc.getElementById('loot');
loot.setAttribute('data-den-steal', '');
loot.getBoundingClientRect = () => ({ left: 300, top: 300, right: 400, bottom: 330, width: 100, height: 30 });
// generic rects so pois()/terminal don't explode
const origGBCR = w.Element.prototype.getBoundingClientRect;
w.Element.prototype.getBoundingClientRect = function(){
if (this === loot) return loot.getBoundingClientRect();
return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
};
// run den.js
const src = fs.readFileSync(process.argv[2] || 'src/den.js', 'utf8');
w.eval(src);
// jsdom setTimeout is real-time; the spawn sequence uses setTimeout(700/2600).
// We can't fast-forward those, but they don't gate mischief. Wait them out for real.
function log(label){
const clone = [...doc.querySelectorAll('div')].find(d => (d.getAttribute('style')||'').includes('pointer-events:none') && d.textContent.includes('LOOT'));
const says = [...doc.querySelectorAll('.emote.say')].map(e => e.textContent);
console.log(`[t=${NOW}ms] ${label} | loot.visibility='${loot.style.visibility}' | clone=${clone ? 'YES@'+clone.style.left+','+clone.style.top : 'no'} | says=${JSON.stringify(says)}`);
}
setTimeout(() => {
pump(4000); // past spawn
log('before mischief');
w.denEvent('mischief');
log('event fired');
pump(100); log('after 100ms');
pump(1000); log('after ~1.1s (should be walking to loot)');
pump(2000); log('after ~3.1s (grabbed? carrying?)');
// simulate the user moving the mouse mid-carry
const ev = new w.MouseEvent('mousemove', { clientX: 900, clientY: 200 });
doc.dispatchEvent(ev);
log('mouse moved');
pump(3000); log('after ~6.1s');
pump(6000); log('after ~12.1s (dropped by now?)');
pump(8000); log('after ~20.1s (returned by now?)');
pump(10000); log('after ~30.1s (MUST be returned)');
const restored = loot.style.visibility !== 'hidden';
const noOrphan = ![...doc.querySelectorAll('div')]
.some(d => (d.getAttribute('style') || '').includes('pointer-events:none')
&& d.textContent.includes('LOOT'));
console.log('');
console.log(restored ? 'PASS: stolen element restored' : 'FAIL: element still hidden');
console.log(noOrphan ? 'PASS: no orphaned clone left behind' : 'FAIL: clone leaked');
process.exit(restored && noOrphan ? 0 : 1);
}, 3500);