den.js 1.0.0 — he exists
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
19
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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
232
README.md
Normal 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 8–220px wide and 8–90px 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
70
build.fish
Executable 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
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
2
build/den.obf.js
Normal file
File diff suppressed because one or more lines are too long
126
example/index.html
Normal file
126
example/index.html
Normal 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>>_ 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><script src="/den.js"></script></code></pre>
|
||||
<p>Optional markup:</p>
|
||||
<pre><code><div data-den-poi="yip (sign it)"> <!-- he visits and comments -->
|
||||
<span data-den-steal> <!-- he may take this -->
|
||||
<span data-den-others></span> <!-- how many others are here --></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
17
package.json
Normal 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
873
src/den.js
Normal 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
52
src/den.php
Normal 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
82
test/sim.js
Normal 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);
|
||||
Reference in New Issue
Block a user