lilgotchi 0.0.0-stage → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +69 -3
- package/dist/LICENSE.md +21 -0
- package/dist/README.md +69 -0
- package/dist/design/evolution/README.md +7 -0
- package/dist/design/evolution/cypunk.js +3 -0
- package/dist/design/evolution/cypunk.md +20 -0
- package/dist/design/evolution/index.html +20 -0
- package/dist/design/evolution/lilgotchi.js +3 -0
- package/dist/design/evolution/lilgotchi.md +20 -0
- package/dist/design/evolution/preview.css +3 -0
- package/dist/design/evolution/preview.js +47 -0
- package/dist/design/evolution/slice.js +3 -0
- package/dist/design/evolution/slice.md +14 -0
- package/dist/design/games/README.md +52 -0
- package/dist/design/games/bump.md +23 -0
- package/dist/design/games/catalog.md +24 -0
- package/dist/design/games/flag.md +19 -0
- package/dist/design/games/get.md +21 -0
- package/dist/design/games/heading.md +23 -0
- package/dist/design/games/hoops.md +17 -0
- package/dist/design/games/left-right.md +30 -0
- package/dist/design/games/memory.md +21 -0
- package/dist/design/games/polish-review.md +11 -0
- package/dist/design/games/release-checklist.md +52 -0
- package/dist/design/games/sprint.md +21 -0
- package/dist/docs/README.md +14 -0
- package/dist/docs/care.md +51 -0
- package/dist/docs/characters.md +51 -0
- package/dist/docs/credits.md +17 -0
- package/dist/docs/development.md +87 -0
- package/dist/docs/frame-contract.md +38 -0
- package/dist/docs/images/cypunk-family.jpg +0 -0
- package/dist/docs/images/lilgotchi-family.jpg +0 -0
- package/dist/docs/images/original-family.jpg +0 -0
- package/dist/docs/images/slice-family.jpg +0 -0
- package/dist/docs/install.md +41 -0
- package/dist/docs/release-review.md +47 -0
- package/dist/embed.js +21 -0
- package/dist/examples/cross-origin.html +7 -0
- package/dist/examples/curated.html +12 -0
- package/dist/examples/duplicate.html +3 -0
- package/dist/examples/install.html +11 -0
- package/dist/examples/launcher-review.html +23 -0
- package/dist/examples/launchers.html +10 -0
- package/dist/examples/nested/index.html +6 -0
- package/dist/examples/one-line.html +9 -0
- package/dist/index.html +100 -0
- package/dist/lilgotchi.js +437 -0
- package/dist/pets/NOTICE.md +19 -0
- package/dist/pets/README.md +7 -0
- package/dist/pets/arcade-catalog.js +52 -0
- package/dist/pets/arcade-geometry.js +18 -0
- package/dist/pets/arcade-opponents.js +16 -0
- package/dist/pets/arcade-renderer.js +116 -0
- package/dist/pets/arcade-sprites.js +64 -0
- package/dist/pets/arcade.js +393 -0
- package/dist/pets/away.js +20 -0
- package/dist/pets/boot.js +161 -0
- package/dist/pets/catalog.js +32 -0
- package/dist/pets/celebrations.js +54 -0
- package/dist/pets/classic-sprites.js +672 -0
- package/dist/pets/clock.js +3 -0
- package/dist/pets/colors.js +38 -0
- package/dist/pets/constants.js +20 -0
- package/dist/pets/custom-themes.css +80 -0
- package/dist/pets/cyberpunk-sprites.js +68 -0
- package/dist/pets/cypunk-original.js +2 -0
- package/dist/pets/embed.css +130 -0
- package/dist/pets/engine.js +38 -0
- package/dist/pets/evolution.js +46 -0
- package/dist/pets/focus.js +27 -0
- package/dist/pets/font.js +185 -0
- package/dist/pets/game.css +275 -0
- package/dist/pets/game.html +148 -0
- package/dist/pets/game.js +626 -0
- package/dist/pets/layout.js +53 -0
- package/dist/pets/lilgotchi-sprites.js +62 -0
- package/dist/pets/minigames/bump.js +52 -0
- package/dist/pets/minigames/flag.js +87 -0
- package/dist/pets/minigames/get.js +59 -0
- package/dist/pets/minigames/heading.js +117 -0
- package/dist/pets/minigames/hoops.js +96 -0
- package/dist/pets/minigames/memory.js +81 -0
- package/dist/pets/minigames/model.js +40 -0
- package/dist/pets/minigames/sprint.js +103 -0
- package/dist/pets/original-roster.js +46 -0
- package/dist/pets/original-sprites.js +3861 -0
- package/dist/pets/overlay-size.js +13 -0
- package/dist/pets/pixel-size.js +6 -0
- package/dist/pets/presentation.js +80 -0
- package/dist/pets/renderer.js +100 -0
- package/dist/pets/scenery.css +24 -0
- package/dist/pets/simulation.js +185 -0
- package/dist/pets/skins.js +14 -0
- package/dist/pets/slice-sprites.js +38 -0
- package/dist/pets/sound.js +33 -0
- package/dist/pets/status.js +28 -0
- package/dist/pets/storage.js +49 -0
- package/dist/pets/valid-state.js +26 -0
- package/dist/pets/wellbeing.js +73 -0
- package/dist/reference/source.json +8 -0
- package/dist/version.json +5 -0
- package/dist/widget/care-guide.js +50 -0
- package/dist/widget/care-monitor.js +52 -0
- package/dist/widget/color-controls.js +15 -0
- package/dist/widget/configurator.js +24 -0
- package/dist/widget/egg.svg +1 -0
- package/dist/widget/focus.js +6 -0
- package/dist/widget/gallery.js +17 -0
- package/dist/widget/inactive.js +24 -0
- package/dist/widget/install-snippet.js +14 -0
- package/dist/widget/launcher.js +63 -0
- package/dist/widget/pet-history.js +57 -0
- package/dist/widget/pet-label.js +12 -0
- package/dist/widget/save-store.js +198 -0
- package/dist/widget/scroll-lock.js +11 -0
- package/dist/widget/settings.js +48 -0
- package/dist/widget/shell.css +63 -0
- package/package.json +43 -6
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const hour = 3600000;
|
|
2
|
+
const clamp = value => Math.max(0, Math.min(100, value));
|
|
3
|
+
|
|
4
|
+
// Additive companion metrics. Original P1 care and evolution rules stay separate.
|
|
5
|
+
export function initializeWellbeing(state, now) {
|
|
6
|
+
if (!Number.isFinite(state.energy)) state.energy = 100;
|
|
7
|
+
if (!Number.isFinite(state.health)) state.health = state.isSick ? 60 : 100;
|
|
8
|
+
if (!Number.isFinite(state.wellbeingUpdatedAt)) state.wellbeingUpdatedAt = now;
|
|
9
|
+
if (!Number.isFinite(state.careTotal)) state.careTotal = 0;
|
|
10
|
+
if (!Number.isFinite(state.careDuration)) state.careDuration = 0;
|
|
11
|
+
if (!['auto', 'sleep', 'wake'].includes(state.sleepOverride)) state.sleepOverride = 'auto';
|
|
12
|
+
if (!Number.isFinite(state.sleepOverrideUntil)) state.sleepOverrideUntil = 0;
|
|
13
|
+
return state;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function advanceWellbeing(state, now) {
|
|
17
|
+
initializeWellbeing(state, now);
|
|
18
|
+
if (now <= state.wellbeingUpdatedAt) return state;
|
|
19
|
+
const elapsed = Math.min(now - state.wellbeingUpdatedAt, 25 * 24 * hour);
|
|
20
|
+
state.wellbeingUpdatedAt = now;
|
|
21
|
+
if (state.isDead || state.stage === 'egg') return state;
|
|
22
|
+
const hours = elapsed / hour;
|
|
23
|
+
const beforeEnergy = state.energy, beforeHealth = state.health;
|
|
24
|
+
state.energy = clamp(state.energy + hours * (state.isSleeping ? 20 : -5));
|
|
25
|
+
const neglected = state.hunger === 0 || state.happiness === 0 || state.poopCount >= 3;
|
|
26
|
+
state.health = clamp(state.health + hours * (state.isSick ? -8 : neglected ? -4 : 4));
|
|
27
|
+
const score = (state.hunger * 25 + state.happiness * 25 + (beforeEnergy + state.energy) / 2 + (beforeHealth + state.health) / 2) / 4;
|
|
28
|
+
state.careTotal += score * elapsed;
|
|
29
|
+
state.careDuration += elapsed;
|
|
30
|
+
return state;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function applyWellbeingAction(state, action, now) {
|
|
34
|
+
advanceWellbeing(state, now);
|
|
35
|
+
if (state.isDead || state.stage === 'egg') return;
|
|
36
|
+
if (action === 'play') state.energy = clamp(state.energy - 5);
|
|
37
|
+
if (action === 'meal') state.energy = clamp(state.energy + 2);
|
|
38
|
+
if (action === 'cured') state.health = clamp(state.health + 10);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setManualSleep(state, sleeping, now) {
|
|
42
|
+
advanceWellbeing(state, now);
|
|
43
|
+
if (state.isDead || state.stage === 'egg') return false;
|
|
44
|
+
state.sleepOverride = sleeping ? 'sleep' : 'wake';
|
|
45
|
+
state.sleepOverrideUntil = now + 2 * hour;
|
|
46
|
+
state.isSleeping = sleeping;
|
|
47
|
+
state.lightOn = !sleeping;
|
|
48
|
+
if (!sleeping) {
|
|
49
|
+
state.lastHungerDecay = now;
|
|
50
|
+
state.lastHappyDecay = now;
|
|
51
|
+
state.lastPoopTime = now;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function sleepDecision(state, scheduled, now) {
|
|
57
|
+
initializeWellbeing(state, now);
|
|
58
|
+
const sleptLongEnough = now >= state.sleepOverrideUntil - 2 * hour + 300000;
|
|
59
|
+
if (state.sleepOverride === 'sleep' && state.energy >= 100 && sleptLongEnough) {
|
|
60
|
+
state.sleepOverride = 'wake';
|
|
61
|
+
state.sleepOverrideUntil = now + 2 * hour;
|
|
62
|
+
}
|
|
63
|
+
if (now >= state.sleepOverrideUntil) state.sleepOverride = 'auto';
|
|
64
|
+
if (state.sleepOverride === 'sleep') return true;
|
|
65
|
+
if (state.sleepOverride === 'wake') return false;
|
|
66
|
+
return scheduled;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function careGrade(state) {
|
|
70
|
+
if (!state.careDuration || state.careDuration < 600000) return { label: 'Getting started', score: null };
|
|
71
|
+
const score = clamp(state.careTotal / state.careDuration);
|
|
72
|
+
return { label: score >= 90 ? 'Perfect' : score >= 75 ? 'Good' : score >= 50 ? 'Normal' : 'Bad', score: Math.round(score) };
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repository": "https://github.com/plateaukao/tamagochi",
|
|
3
|
+
"commit": "74c7a45c8ef03ac43a500db18d19b8fa7cd4ec62",
|
|
4
|
+
"file": "upstream.html",
|
|
5
|
+
"sha256": "41352311394443d519155b13c44e5d1103c9b61cc647b028b43277069d4cf650",
|
|
6
|
+
"license": "MIT as declared in upstream README",
|
|
7
|
+
"note": "Exact local source copy used for the existing adaptation. Original bytes retained."
|
|
8
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const steps=Object.freeze([
|
|
2
|
+
{title:'Meet your pet', paragraphs:[
|
|
3
|
+
'Feed offers meals for food and snacks for happiness. Too many snacks can make your pet sick. Play a guessing game to lift its mood.',
|
|
4
|
+
'Care changes with the moment. Clean waste first, or use it to sleep and wake. More care keeps medicine, discipline and the other controls within reach.'
|
|
5
|
+
]},
|
|
6
|
+
{title:'Read the little signals', paragraphs:[
|
|
7
|
+
'Food and happiness show how satisfied your pet is. Energy drops while awake and recovers during sleep. Health is an advisory wellbeing meter. It does not replace the illness icon, medicine or the original care rules.',
|
|
8
|
+
'A care grade appears after ten minutes of measured care. It averages food, happiness, energy and health over time: Perfect is at least 90, Good is at least 75, Normal is at least 50, and Bad is below 50. It describes that average, not your pet’s worth. The original evolution branches still use care mistakes and discipline.'
|
|
9
|
+
]},
|
|
10
|
+
{title:'Rest and grow', paragraphs:[
|
|
11
|
+
'Sleep and Wake control rest for a limited period before the normal schedule resumes. Lights only change the display and bedtime lighting. Turning a light off is not the same as asking an awake pet to sleep.',
|
|
12
|
+
'Use medicine when your pet is sick. Use discipline when it is misbehaving, rather than whenever it refuses something. Eggs need time to hatch. Later stages grow through care. A named starting character begins a fresh life at the stage shown, so an adult can be zero days old.'
|
|
13
|
+
]},
|
|
14
|
+
{title:'Come back when you can', paragraphs:[
|
|
15
|
+
'While you are away, progress catches up for up to eight hours. Vacation pauses care and growth when you need a longer break. The launcher counts care needs across your started pets while this page is active. Open the needs list to see who needs what. A pet active elsewhere may show its last saved needs. Closing the drawer keeps the pet active. Close the browser tab or page to release it for another window.',
|
|
16
|
+
'Your pet saves in this browser on this website. Use Export save in Settings for a backup or to move it elsewhere. Import previews a replacement first. Clearing browser data can remove progress. If storage is unavailable, progress may last only for this visit.'
|
|
17
|
+
]}
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
// All guide copy is rendered as text, never supplied as HTML.
|
|
21
|
+
export function mountCareGuide(container){
|
|
22
|
+
const document=container.ownerDocument;
|
|
23
|
+
const details=document.createElement('details');details.className='care-guide';
|
|
24
|
+
const summary=document.createElement('summary');summary.textContent='How to care for your pet';
|
|
25
|
+
const content=document.createElement('div');content.className='care-guide-content';
|
|
26
|
+
const progress=document.createElement('p');progress.className='care-guide-progress';progress.setAttribute('aria-live','polite');
|
|
27
|
+
const title=document.createElement('h3');title.tabIndex=-1;
|
|
28
|
+
const paragraphs=document.createElement('div');
|
|
29
|
+
const controls=document.createElement('div');controls.className='care-guide-controls';
|
|
30
|
+
const back=document.createElement('button');back.type='button';back.textContent='Back';
|
|
31
|
+
const next=document.createElement('button');next.type='button';
|
|
32
|
+
const replay=document.createElement('button');replay.type='button';replay.textContent='Start again';
|
|
33
|
+
let index=0;
|
|
34
|
+
const render=(focus=false)=>{
|
|
35
|
+
progress.textContent=`Step ${index+1} of ${steps.length}`;
|
|
36
|
+
title.textContent=steps[index].title;
|
|
37
|
+
paragraphs.replaceChildren(...steps[index].paragraphs.map(text=>{const p=document.createElement('p');p.textContent=text;return p;}));
|
|
38
|
+
back.disabled=index===0;next.textContent=index===steps.length-1?'Done':'Next';replay.hidden=index!==steps.length-1;
|
|
39
|
+
if(focus)title.focus();
|
|
40
|
+
};
|
|
41
|
+
back.addEventListener('click',()=>{if(index>0){index--;render(true);}});
|
|
42
|
+
next.addEventListener('click',()=>{
|
|
43
|
+
if(index===steps.length-1){details.open=false;summary.focus();}
|
|
44
|
+
else{index++;render(true);}
|
|
45
|
+
});
|
|
46
|
+
replay.addEventListener('click',()=>{index=0;render(true);});
|
|
47
|
+
controls.append(back,next,replay);content.append(progress,title,paragraphs,controls);details.append(summary,content);container.append(details);render();
|
|
48
|
+
return {element:details,reset(){index=0;render();},destroy(){details.remove();}};
|
|
49
|
+
}
|
|
50
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { inactiveSince } from './inactive.js';
|
|
2
|
+
import { PetSimulation } from '../pets/simulation.js';
|
|
3
|
+
|
|
4
|
+
export function careNeeds(state, memorialSeen) {
|
|
5
|
+
if (!state || state.vacationAt > 0) return [];
|
|
6
|
+
if (state.isDead) return String(state.bornAt) === memorialSeen ? [] : ['Remember your pet'];
|
|
7
|
+
if (state.stage === 'egg') return [];
|
|
8
|
+
return [state.hunger === 0 && 'Food', state.happiness === 0 && 'Play',
|
|
9
|
+
state.isSick && 'Medicine', state.poopCount > 0 && 'Cleaning',
|
|
10
|
+
state.isMisbehaving && 'Discipline'].filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Run the same absence simulation as the game, without mounting its UI or saving
|
|
14
|
+
// through standalone storage. The caller holds the family's save ownership.
|
|
15
|
+
export function advanceSavedPet(saved) {
|
|
16
|
+
const app = Object.create(PetSimulation.prototype);
|
|
17
|
+
app.state = structuredClone(saved);
|
|
18
|
+
Object.defineProperty(app.state, 'save', { value() {} });
|
|
19
|
+
app.sound = new Proxy({}, { get: () => () => {} });
|
|
20
|
+
app.toast = app.updateButtons = app.showDeath = app.cancelGame = () => {};
|
|
21
|
+
app.processMissedTime();
|
|
22
|
+
return JSON.parse(JSON.stringify(app.state));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function refreshCare(store, families, activeFamily, advance = advanceSavedPet) {
|
|
26
|
+
const entries = [];
|
|
27
|
+
for (const family of families) {
|
|
28
|
+
let acquired = false;
|
|
29
|
+
try {
|
|
30
|
+
// Never reload an active writer's revision or simulate its state twice.
|
|
31
|
+
if (family === activeFamily || inactiveSince(store,family)) continue;
|
|
32
|
+
const raw = store.raw(family);
|
|
33
|
+
if (!raw) continue;
|
|
34
|
+
const recent=JSON.parse(raw)?.state;
|
|
35
|
+
if(recent && (Date.now()-recent.lastProcessed<60000 || recent.vacationAt>0 || recent.isDead)){
|
|
36
|
+
const saved=store.load(family);
|
|
37
|
+
entries.push({family,state:saved.state,stale:false});continue;
|
|
38
|
+
}
|
|
39
|
+
acquired = await store.acquire(family);
|
|
40
|
+
const saved = store.load(family);
|
|
41
|
+
if (!saved) continue;
|
|
42
|
+
let state = saved.state;
|
|
43
|
+
if (acquired) { state = advance(state); store.save(family, state); }
|
|
44
|
+
entries.push({ family, state, stale: !acquired });
|
|
45
|
+
} catch {
|
|
46
|
+
// Keep the previous count if storage is unavailable or a save needs recovery.
|
|
47
|
+
} finally {
|
|
48
|
+
if (acquired) store.release(family);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return entries;
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {cleanColors,resolvedColors} from '../pets/colors.js';
|
|
2
|
+
export function mountColorControls(container,onChange){
|
|
3
|
+
const doc=container.ownerDocument,fields={};
|
|
4
|
+
const title=doc.createElement('strong');title.textContent='Pet colors';
|
|
5
|
+
const hint=doc.createElement('p');hint.textContent='Choose colors for this family.';
|
|
6
|
+
container.append(title,hint);
|
|
7
|
+
for(const [key,label] of [['shell','Handheld color'],['screen','Screen color']]){
|
|
8
|
+
const row=doc.createElement('label');row.className='color-choice';row.textContent=label;
|
|
9
|
+
const input=doc.createElement('input');input.type='color';input.setAttribute('aria-label',label);
|
|
10
|
+
input.addEventListener('input',()=>onChange(cleanColors({shell:fields.shell.value,screen:fields.screen.value})));
|
|
11
|
+
fields[key]=input;row.append(input);container.append(row);
|
|
12
|
+
}
|
|
13
|
+
const reset=doc.createElement('button');reset.type='button';reset.textContent='Return to default colors';reset.addEventListener('click',()=>onChange({}));container.append(reset);
|
|
14
|
+
return {update(family,colors){const resolved=resolvedColors(family,colors);for(const key of Object.keys(fields))fields[key].value=resolved[key];}};
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { catalog } from '../pets/catalog.js';
|
|
2
|
+
import { installSnippet } from './install-snippet.js?v=3.0.2';
|
|
3
|
+
const form=document.querySelector('#setup');
|
|
4
|
+
const list=form.querySelector('.character-options');
|
|
5
|
+
for(const choice of catalog){
|
|
6
|
+
const label=document.createElement('label');
|
|
7
|
+
const input=document.createElement('input');input.type='checkbox';input.value=choice.id;input.checked=true;
|
|
8
|
+
label.append(input,document.createTextNode(choice.name));list.append(label);
|
|
9
|
+
}
|
|
10
|
+
const first=form.querySelector('[name=first]'),code=form.querySelector('code'),copy=form.querySelector('.copy'),status=form.querySelector('.copy-status');
|
|
11
|
+
function update(){
|
|
12
|
+
const all=form.querySelector('[name=roster]').value==='all';list.hidden=all;
|
|
13
|
+
const chosen=all?catalog:catalog.filter(choice=>list.querySelector(`input[value="${choice.id}"]`).checked);
|
|
14
|
+
const previous=first.value||'slice';first.replaceChildren(...chosen.map(choice=>new Option(choice.name,choice.id)));
|
|
15
|
+
first.value=chosen.some(c=>c.id===previous)?previous:chosen[0]?.id||'';
|
|
16
|
+
try{code.textContent=installSnippet({characters:all?'all':chosen.map(c=>c.id).join(','),character:first.value,placement:form.elements.placement.value,appearance:form.elements.appearance.value,launcher:form.elements.launcher.value,notifications:form.elements.notifications.value});copy.disabled=false;status.textContent='';}
|
|
17
|
+
catch{code.textContent='Choose at least one character.';copy.disabled=true;status.textContent='Choose at least one character to make your snippet.';}
|
|
18
|
+
}
|
|
19
|
+
form.addEventListener('change',update);
|
|
20
|
+
copy.addEventListener('click',async()=>{
|
|
21
|
+
try{await navigator.clipboard.writeText(code.textContent);status.textContent='Copied. Paste this into your page. No upload needed.';}
|
|
22
|
+
catch{status.textContent='Copy is unavailable here. Select the code above and copy it manually.';}
|
|
23
|
+
});
|
|
24
|
+
update();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 28" shape-rendering="crispEdges"><path fill="#e7c88d" d="M9 1h6v2h3v3h2v4h2v10h-2v4h-4v2H8v-2H4v-4H2V10h2V6h2V3h3z"/><path fill="#fff0cc" d="M9 3h5v2h3v4h2v10h-2v4H8v-2H5V10h2V6h2z"/><path fill="#6c8060" d="M7 12h3v3H7zm7 0h3v3h-3zm-4 6h4v2h-4z"/></svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Closed details can report layout rectangles for controls they do not display.
|
|
2
|
+
export function visibleControl(node) {
|
|
3
|
+
if(node.disabled || !node.getClientRects().length)return false;
|
|
4
|
+
const closed=node.closest('details:not([open])');
|
|
5
|
+
return !closed || (node.tagName==='SUMMARY' && node.parentElement===closed);
|
|
6
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { catalog } from '../pets/catalog.js';
|
|
2
|
+
import { launcherArt } from './launcher.js';
|
|
3
|
+
const gallery=document.querySelector('.gallery');
|
|
4
|
+
const open=async id=>{
|
|
5
|
+
const pet=document.querySelector('lil-gotchi');
|
|
6
|
+
await pet.setCharacter(id);
|
|
7
|
+
await pet.open();
|
|
8
|
+
};
|
|
9
|
+
for(const choice of catalog){
|
|
10
|
+
const button=document.createElement('button');
|
|
11
|
+
button.type='button';
|
|
12
|
+
button.innerHTML=`${launcherArt(choice.id,{character:choice.id==='original2'?'mimitchi':choice.id==='default'?'kuchipatchi':choice.id==='cyberpunk'?'ginjirotchi':'mametchi'},{fit:true})}<strong>${choice.name}</strong><span>${choice.id==='slice'?'Starts as dough':choice.id==='cyberpunk'?'Starts as a binary seed':'Starts as an egg'}</span>`;
|
|
13
|
+
button.addEventListener('click',async()=>{
|
|
14
|
+
await open(choice.id);
|
|
15
|
+
});
|
|
16
|
+
gallery.append(button);
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { shiftClocks } from '../pets/away.js';
|
|
2
|
+
|
|
3
|
+
export function inactiveSince(store,family) {
|
|
4
|
+
const value=Number(store.preference(`inactive-${family}`));
|
|
5
|
+
return Number.isFinite(value)&&value>0?value:0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function hasActivePets(store,families,states=new Map()) {
|
|
9
|
+
return families.some(family=>{
|
|
10
|
+
if(inactiveSince(store,family))return false;
|
|
11
|
+
if(states.get(family)?.state)return true;
|
|
12
|
+
try{return !!store.load(family)?.state;}
|
|
13
|
+
catch{return false;}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resumeInactive(state,since,now=Date.now()) {
|
|
18
|
+
const next=structuredClone(state);
|
|
19
|
+
const elapsed=now-since;
|
|
20
|
+
shiftClocks(next,elapsed);
|
|
21
|
+
// A pet already on vacation stays on vacation, without counting this pause twice.
|
|
22
|
+
if(next.vacationAt>0)next.vacationAt=Math.max(1,next.vacationAt+elapsed);
|
|
23
|
+
return next;
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cleanColorMap } from '../pets/colors.js';
|
|
2
|
+
import { allowedCharacters } from '../pets/catalog.js';
|
|
3
|
+
export function installSnippet({characters='all',character='slice',placement='bottom-right',appearance='dark',launcher='shell',notifications='on',colors={}}={}){
|
|
4
|
+
const allowed=allowedCharacters(characters);
|
|
5
|
+
if(!allowed.includes(character))character=allowed[0];
|
|
6
|
+
if(!['bottom-right','bottom-left','top-right','top-left'].includes(placement))throw new Error('Choose a supported placement.');
|
|
7
|
+
if(!['dark','light'].includes(appearance))throw new Error('Choose a supported appearance.');
|
|
8
|
+
if(!['shell','pet','brand'].includes(launcher))throw new Error('Choose a supported launcher.');
|
|
9
|
+
if(!['on','off'].includes(notifications))throw new Error('Choose notifications on or off.');
|
|
10
|
+
const colorMap=cleanColorMap(colors);
|
|
11
|
+
const colorSetting=Object.keys(colorMap).length?" data-colors='"+JSON.stringify(colorMap)+"'":'';
|
|
12
|
+
const setting=` data-placement="${placement}"`;
|
|
13
|
+
return `<script defer src="https://www.taylordorsett.com/lilgotchi/releases/3.0.2/embed.js" data-characters="${characters==='all'?'all':allowed.join(',')}" data-character="${character}"${setting} data-appearance="${appearance}" data-launcher="${launcher}" data-notifications="${notifications}"${colorSetting} data-storage-key="my-pets"></script>`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { resolvedColors, cleanColors, shade, screenInk } from '../pets/colors.js';
|
|
2
|
+
import { getCharacter } from '../pets/catalog.js';
|
|
3
|
+
import { sliceSprites, slicePalette } from '../pets/slice-sprites.js';
|
|
4
|
+
import { cyberpunkSprites, cyberpunkPalette } from '../pets/cyberpunk-sprites.js';
|
|
5
|
+
import { classicSprites } from '../pets/classic-sprites.js';
|
|
6
|
+
import { originalSprites } from '../pets/original-sprites.js';
|
|
7
|
+
const originals={...classicSprites,...originalSprites};
|
|
8
|
+
import { lilgotchiSprites, lilgotchiPalette } from '../pets/lilgotchi-sprites.js';
|
|
9
|
+
|
|
10
|
+
const shellColors={
|
|
11
|
+
classic:{edge:'#c9a882',face:'#e8d5c4',screen:'#c4cfa1'},
|
|
12
|
+
eink:{edge:'#d8d8d8',face:'#f5f5f5',screen:'#f0f0f0'},
|
|
13
|
+
pizza:{edge:'#526f70',face:'#839c98',screen:'#648e97'},
|
|
14
|
+
cyberpunk:{edge:'#172c36',face:'#304e59',screen:'#0b1c24'},
|
|
15
|
+
lilgotchi:{edge:'#e7c88d',face:'#fff0cc',screen:'#20281d'},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function launcherArt(family, state, { fit = false } = {}) {
|
|
19
|
+
const sprites=family==='lilgotchi'?lilgotchiSprites:family==='slice'?sliceSprites:family==='cyberpunk'?cyberpunkSprites:originals;
|
|
20
|
+
const palette=family==='lilgotchi'?lilgotchiPalette:family==='slice'?slicePalette:family==='cyberpunk'?cyberpunkPalette:['','#b4ce83'];
|
|
21
|
+
const preview=family==='slice'||family==='cyberpunk'?'tamatchi':getCharacter(family)?.character||'egg';
|
|
22
|
+
const identity=state?.character && Object.hasOwn(sprites,state.character)?state.character:preview;
|
|
23
|
+
const pixels=sprites[identity].idle1;
|
|
24
|
+
const rects=pixels.flatMap((row,y)=>row.flatMap((color,x)=>color?[`<rect x="${x}" y="${y}" width="1" height="1" fill="${palette[color]||palette[1]}"/>`]:[])).join('');
|
|
25
|
+
let viewBox=`0 0 ${pixels[0].length} ${pixels.length}`;
|
|
26
|
+
if(fit){
|
|
27
|
+
const occupied=pixels.flatMap((row,y)=>row.flatMap((color,x)=>color?[[x,y]]:[]));
|
|
28
|
+
if(occupied.length){
|
|
29
|
+
const xs=occupied.map(([x])=>x),ys=occupied.map(([,y])=>y);
|
|
30
|
+
const left=Math.min(...xs),top=Math.min(...ys);
|
|
31
|
+
const width=Math.max(...xs)-left+1,height=Math.max(...ys)-top+1;
|
|
32
|
+
const side=Math.max(width,height)+2;
|
|
33
|
+
viewBox=`${left-(side-width)/2} ${top-(side-height)/2} ${side} ${side}`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return `<svg aria-hidden="true" viewBox="${viewBox}" shape-rendering="crispEdges">${rects}</svg>`;
|
|
37
|
+
}
|
|
38
|
+
export function savedCare(state) {
|
|
39
|
+
if(!state)return '';
|
|
40
|
+
if(state.vacationAt>0)return '';
|
|
41
|
+
if(state.isDead)return 'Your pet passed away';
|
|
42
|
+
if(state.stage==='egg')return '';
|
|
43
|
+
const needs=[];
|
|
44
|
+
if(state.isSick)needs.push('medicine');
|
|
45
|
+
if(state.poopCount>0)needs.push('cleaning');
|
|
46
|
+
if(state.hunger===0)needs.push('food');
|
|
47
|
+
if(state.happiness===0)needs.push('play');
|
|
48
|
+
if(state.isMisbehaving)needs.push('discipline');
|
|
49
|
+
return needs.length?`Needs ${needs.join(', ')}`:'';
|
|
50
|
+
}
|
|
51
|
+
export function launcherIcon(family,state,{shell=true,eink=false,colors:custom={}}={}) {
|
|
52
|
+
if(!shell)return `${launcherArt(family,state,{fit:true})}<span class="launcher-label">lilgotchi</span>`;
|
|
53
|
+
const skin=getCharacter(family)?.skin||'classic';
|
|
54
|
+
let colors={...shellColors[skin==='classic'&&eink?'eink':skin]||shellColors.classic};
|
|
55
|
+
if(!(skin==='classic'&&eink)){
|
|
56
|
+
const resolved=resolvedColors(family,custom),overrides=cleanColors(custom);
|
|
57
|
+
if(skin==='classic'||overrides.shell){colors.face=shade(resolved.shell,22);colors.edge=shade(resolved.shell,-24);}
|
|
58
|
+
if(skin==='classic'||overrides.screen)colors.screen=resolved.screen;
|
|
59
|
+
}
|
|
60
|
+
let pet=launcherArt(family,state,{fit:true}).replace('<svg ', '<svg x="5" y="8" width="14" height="14" ');
|
|
61
|
+
if(skin==='classic')pet=pet.replaceAll('#b4ce83',eink?'#111':screenInk(colors.screen));
|
|
62
|
+
return `<svg aria-hidden="true" viewBox="0 0 24 28" shape-rendering="crispEdges"><path fill="${colors.edge}" d="M9 1h6v2h3v3h2v4h2v10h-2v4h-4v2H8v-2H4v-4H2V10h2V6h2V3h3z"/><path fill="${colors.face}" d="M9 3h5v2h3v4h2v10h-2v4H8v-2H5V10h2V6h2z"/><path fill="${colors.screen}" d="M6 8h12v14H6z"/>${pet}</svg>`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { lilgotchiNames } from '../pets/lilgotchi-sprites.js';
|
|
2
|
+
import { characters } from '../pets/valid-state.js';
|
|
3
|
+
import { catalog, families } from '../pets/catalog.js';
|
|
4
|
+
import { validEnvelope } from './save-store.js';
|
|
5
|
+
import { sliceNames } from '../pets/slice-sprites.js';
|
|
6
|
+
import { cyberpunkNames } from '../pets/cyberpunk-sprites.js';
|
|
7
|
+
|
|
8
|
+
export const HISTORY_LIMIT=12;
|
|
9
|
+
export const historyKey=(instance,family)=>`lilgotchi:history:v1:${encodeURIComponent(instance)}:${family}`;
|
|
10
|
+
const fields=['age','bornAt','character','family','recordedAt'];
|
|
11
|
+
const identities=new Set(characters);
|
|
12
|
+
const time=value=>Number.isSafeInteger(value)&&value>=0;
|
|
13
|
+
export function validHistoryEntry(entry,family){
|
|
14
|
+
return !!entry && typeof entry==='object' && !Array.isArray(entry) &&
|
|
15
|
+
Object.keys(entry).sort().join(',')===fields.join(',') &&
|
|
16
|
+
families.includes(entry.family) && (family===undefined || entry.family===family) &&
|
|
17
|
+
identities.has(entry.character) && Number.isSafeInteger(entry.age) && entry.age>=0 && entry.age<=1000000 &&
|
|
18
|
+
time(entry.bornAt) && time(entry.recordedAt) && entry.recordedAt>=entry.bornAt;
|
|
19
|
+
}
|
|
20
|
+
const newest=(a,b)=>b.recordedAt-a.recordedAt || b.bornAt-a.bornAt || a.family.localeCompare(b.family);
|
|
21
|
+
export class PetHistory {
|
|
22
|
+
constructor(store){this.store=store;}
|
|
23
|
+
list(family){
|
|
24
|
+
if(family!==undefined && !families.includes(family))throw new Error('Unknown history character.');
|
|
25
|
+
const entries=[];
|
|
26
|
+
for(const id of family===undefined?families:[family]){
|
|
27
|
+
const raw=this.store.read(historyKey(this.store.instance,id));
|
|
28
|
+
if(raw===null)continue;
|
|
29
|
+
let data;
|
|
30
|
+
try{if(raw.length>16384)throw new Error();data=JSON.parse(raw);}catch{throw new Error('Pet history needs recovery. It has not been replaced.');}
|
|
31
|
+
if(!data || data.schema!==1 || data.instance!==this.store.instance || data.family!==id || !Array.isArray(data.entries) ||
|
|
32
|
+
data.entries.length>HISTORY_LIMIT || !data.entries.every(entry=>validHistoryEntry(entry,id)) ||
|
|
33
|
+
new Set(data.entries.map(entry=>entry.bornAt)).size!==data.entries.length)throw new Error('Pet history needs recovery. It has not been replaced.');
|
|
34
|
+
entries.push(...data.entries);
|
|
35
|
+
}
|
|
36
|
+
return entries.sort(newest);
|
|
37
|
+
}
|
|
38
|
+
recordDeath(family,state){
|
|
39
|
+
const now=this.store.now();
|
|
40
|
+
if(!families.includes(family) || !time(now) || !validEnvelope({schema:1,instance:this.store.instance,family,revision:1,savedAt:now,state}) || !time(state.bornAt) || state.bornAt>now)throw new Error('Invalid pet history.');
|
|
41
|
+
if(!this.store.owns(family))throw new Error('This pet is active in another window.');
|
|
42
|
+
if(!state.isDead)return false;
|
|
43
|
+
const entries=this.list(family);
|
|
44
|
+
if(entries.some(entry=>entry.bornAt===state.bornAt))return false;
|
|
45
|
+
const entry={family,character:state.character,bornAt:state.bornAt,age:state.age,recordedAt:now};
|
|
46
|
+
if(!validHistoryEntry(entry,family))throw new Error('Invalid pet history.');
|
|
47
|
+
entries.push(entry);entries.sort(newest);entries.length=Math.min(entries.length,HISTORY_LIMIT);
|
|
48
|
+
this.store.write(historyKey(this.store.instance,family),JSON.stringify({schema:1,instance:this.store.instance,family,entries}));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
describe(entry){
|
|
52
|
+
if(!validHistoryEntry(entry))throw new Error('Invalid pet history.');
|
|
53
|
+
const choice=catalog.find(choice=>choice.id===entry.family);
|
|
54
|
+
const identity=entry.family==='slice'?sliceNames[entry.character]:entry.family==='cyberpunk'?cyberpunkNames[entry.character]:entry.family==='lilgotchi'?lilgotchiNames[entry.character]:entry.character[0].toUpperCase()+entry.character.slice(1);
|
|
55
|
+
return `${choice.name} · ${identity} · ${entry.age} ${entry.age===1?'day':'days'} old`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { lilgotchiNames } from '../pets/lilgotchi-sprites.js';
|
|
2
|
+
import { getCharacter } from '../pets/catalog.js';
|
|
3
|
+
import { sliceNames } from '../pets/slice-sprites.js';
|
|
4
|
+
import { cyberpunkNames } from '../pets/cyberpunk-sprites.js';
|
|
5
|
+
|
|
6
|
+
export const stageLabel = stage => stage[0].toUpperCase()+stage.slice(1);
|
|
7
|
+
export function petLabel(family,state) {
|
|
8
|
+
const choice=getCharacter(family);
|
|
9
|
+
const character=state?.character||choice.character,stage=state?.stage||choice.stage;
|
|
10
|
+
const name=family==='slice'?sliceNames[character]:family==='cyberpunk'?cyberpunkNames[character]:family==='lilgotchi'?lilgotchiNames[character]:character==='egg'?choice.name:stageLabel(character);
|
|
11
|
+
return `${name} · ${stageLabel(stage)}${state?.vacationAt?' · Paused':''}`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { validState } from '../pets/valid-state.js';
|
|
2
|
+
import { stagesForFamily } from '../pets/original-roster.js';
|
|
3
|
+
|
|
4
|
+
import { families, legacyFamily, requireAllowed } from '../pets/catalog.js';
|
|
5
|
+
export { families, legacyFamily } from '../pets/catalog.js';
|
|
6
|
+
const memory = new Map();
|
|
7
|
+
const sessionOnly = new Set();
|
|
8
|
+
const localOwners = new Map();
|
|
9
|
+
const LEASE_MS = 15000;
|
|
10
|
+
export const saveKey = (instance, family) => `lilgotchi:v1:${encodeURIComponent(instance)}:${family}`;
|
|
11
|
+
const clone = value => JSON.parse(JSON.stringify(value));
|
|
12
|
+
|
|
13
|
+
export function validEnvelope(data, instance, family) {
|
|
14
|
+
if (!data || data.schema !== 1 || !families.includes(data.family) || typeof data.instance !== 'string') return false;
|
|
15
|
+
if (instance !== undefined && data.instance !== instance) return false;
|
|
16
|
+
if (family !== undefined && data.family !== family) return false;
|
|
17
|
+
if (!Number.isSafeInteger(data.revision) || data.revision < 0 || !Number.isFinite(data.savedAt) || data.savedAt < 0 || !validState(data.state)) return false;
|
|
18
|
+
const state=data.state;
|
|
19
|
+
const identities=stagesForFamily(data.family);
|
|
20
|
+
if(!Object.values(identities).flat().includes(state.character))return false;
|
|
21
|
+
if(state.stage!=='dead' && !identities[state.stage]?.includes(state.character))return false;
|
|
22
|
+
if(state.isDead !== (state.stage==='dead') || state.weight>99 || state.age>1000000)return false;
|
|
23
|
+
for(const key of ['age','weight','careMistakes','snacksInWindow','medDoses','sickCount']) if(!Number.isSafeInteger(state[key]))return false;
|
|
24
|
+
return [null,...identities.teen].includes(state.prevTeen) && [null,'hungry','sad','misbehave','sick'].includes(state.attentionReason) && (state.attentionCallTime===null || state.attentionCallTime>=0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseImport(text, allowed=families) {
|
|
28
|
+
if (typeof text !== 'string' || text.length > 32768) throw new Error('This save file is too large.');
|
|
29
|
+
let data;
|
|
30
|
+
try { data = JSON.parse(text); } catch { throw new Error('This is not a valid save file.'); }
|
|
31
|
+
if (!validEnvelope(data)) throw new Error('This save has unsupported or invalid game data.');
|
|
32
|
+
requireAllowed(data.family,allowed);
|
|
33
|
+
if (data.savedAt > Date.now() + 300000) throw new Error('This save is dated in the future.');
|
|
34
|
+
for(const key of ['bornAt','stageStartTime','lastHungerDecay','lastHappyDecay','lastPoopTime','lastAgeInc','lastProcessed','snackWindowStart','attentionCallTime']) {
|
|
35
|
+
if(data.state[key]!==null && data.state[key]>Date.now()+300000)throw new Error('This save contains a future game clock.');
|
|
36
|
+
}
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class SaveStore {
|
|
41
|
+
constructor(instance = 'default', options = {}) {
|
|
42
|
+
this.instance = instance;
|
|
43
|
+
this.owner = options.owner || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`;
|
|
44
|
+
this.now = options.now || Date.now;
|
|
45
|
+
this.locks = options.locks === undefined ? globalThis.navigator?.locks : options.locks;
|
|
46
|
+
this.persistent = true;
|
|
47
|
+
this.revisions = new Map();
|
|
48
|
+
this.releases = new Map();
|
|
49
|
+
this.lockRequests = new Map();
|
|
50
|
+
this.pendingReleases = new Map();
|
|
51
|
+
try { this.storage = options.storage === undefined ? globalThis.localStorage : options.storage; }
|
|
52
|
+
catch { this.storage = null; }
|
|
53
|
+
if (!this.storage) this.persistent = false;
|
|
54
|
+
}
|
|
55
|
+
read(key) {
|
|
56
|
+
if(sessionOnly.has(key)){this.persistent=false;return memory.get(key)??null;}
|
|
57
|
+
if (!this.persistent && memory.has(key)) return memory.get(key);
|
|
58
|
+
try {
|
|
59
|
+
if (this.storage) {
|
|
60
|
+
const raw = this.storage.getItem(key);
|
|
61
|
+
if (raw !== null) { memory.set(key,raw); return raw; }
|
|
62
|
+
if (this.persistent) return null;
|
|
63
|
+
}
|
|
64
|
+
} catch { this.persistent = false; }
|
|
65
|
+
return memory.get(key) ?? null;
|
|
66
|
+
}
|
|
67
|
+
write(key, value) {
|
|
68
|
+
memory.set(key,value);
|
|
69
|
+
try { if (!this.storage) throw new Error(); this.storage.setItem(key,value); }
|
|
70
|
+
catch { this.persistent = false; sessionOnly.add(key); }
|
|
71
|
+
}
|
|
72
|
+
load(family) {
|
|
73
|
+
const key = saveKey(this.instance,family);
|
|
74
|
+
const raw = this.read(key);
|
|
75
|
+
if (raw===null) { this.revisions.set(family,0); return null; }
|
|
76
|
+
let data;
|
|
77
|
+
try { data=JSON.parse(raw); } catch { throw new Error('Saved progress is damaged. Export it before starting again.'); }
|
|
78
|
+
if (!validEnvelope(data,this.instance,family)) throw new Error('Saved progress needs recovery. It has not been replaced.');
|
|
79
|
+
this.revisions.set(family,data.revision);
|
|
80
|
+
return clone(data);
|
|
81
|
+
}
|
|
82
|
+
migrateLegacy(family) {
|
|
83
|
+
if (!families.includes(family)) throw new Error('Unknown character');
|
|
84
|
+
if (!['default','slice','cyberpunk'].includes(family)) return false;
|
|
85
|
+
if (!this.owns(family)) throw new Error('This pet is active in another window.');
|
|
86
|
+
const key=saveKey(this.instance,family);
|
|
87
|
+
// Check durable storage directly. A failed migration must remain retryable.
|
|
88
|
+
if (this.read(key)!==null) return false;
|
|
89
|
+
if (!this.storage) return false;
|
|
90
|
+
let destination;
|
|
91
|
+
try { destination=this.storage.getItem(key); } catch { this.persistent=false;return false; }
|
|
92
|
+
if (destination!==null) return false;
|
|
93
|
+
const source=`taylor-pocket-pets-p1-v1-${legacyFamily[family]}`;
|
|
94
|
+
const raw=this.storage.getItem(source);
|
|
95
|
+
if (raw===null) return false;
|
|
96
|
+
let state;
|
|
97
|
+
try { state=JSON.parse(raw); } catch { throw new Error('Your old pet needs recovery. Its save has not been changed.'); }
|
|
98
|
+
const envelope={schema:1,instance:this.instance,family,revision:1,savedAt:this.now(),state};
|
|
99
|
+
parseImport(JSON.stringify(envelope));
|
|
100
|
+
const serialized=JSON.stringify(envelope);
|
|
101
|
+
if (!this.owns(family)) throw new Error('This pet is active in another window.');
|
|
102
|
+
this.storage.setItem(key,serialized);
|
|
103
|
+
if (this.storage.getItem(key)!==serialized) throw new Error('Your old pet could not be copied. Try again.');
|
|
104
|
+
memory.set(key,serialized);
|
|
105
|
+
sessionOnly.delete(key);
|
|
106
|
+
this.revisions.set(family,1);
|
|
107
|
+
// The destination itself prevents repeat copies, even if this receipt fails.
|
|
108
|
+
try { this.storage.setItem(`${key}:migration`,JSON.stringify({source,completedAt:this.now()})); } catch {}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
preference(name, value) {
|
|
112
|
+
const key=`lilgotchi:preferences:${encodeURIComponent(this.instance)}:${name}`;
|
|
113
|
+
if (value !== undefined) this.write(key,String(value));
|
|
114
|
+
return this.read(key);
|
|
115
|
+
}
|
|
116
|
+
lease(family) {
|
|
117
|
+
try { return JSON.parse(this.read(saveKey(this.instance,family)+':lease') || 'null'); } catch { return null; }
|
|
118
|
+
}
|
|
119
|
+
async acquire(family) {
|
|
120
|
+
// Resolving the callback releases ownership locally before the browser has
|
|
121
|
+
// finished releasing its Web Lock. Wait only for locks we already released.
|
|
122
|
+
if(this.pendingReleases.has(family))await this.pendingReleases.get(family);
|
|
123
|
+
const key=saveKey(this.instance,family);
|
|
124
|
+
if (localOwners.has(key) && localOwners.get(key)!==this.owner && (this.locks || this.lease(family)?.expires>this.now())) return false;
|
|
125
|
+
if (this.releases.has(family)) return this.owns(family);
|
|
126
|
+
if (this.locks) {
|
|
127
|
+
let answer;
|
|
128
|
+
const acquired=new Promise(resolve=>{answer=resolve;});
|
|
129
|
+
const request=this.locks.request(key,{ifAvailable:true},async lock=>{
|
|
130
|
+
if (!lock) { answer(false); return; }
|
|
131
|
+
localOwners.set(key,this.owner);
|
|
132
|
+
await new Promise(resolve=>{this.releases.set(family,resolve); answer(true);});
|
|
133
|
+
}).catch(()=>answer(false));
|
|
134
|
+
this.lockRequests.set(family,request);
|
|
135
|
+
request.then(()=>{if(this.lockRequests.get(family)===request)this.lockRequests.delete(family);});
|
|
136
|
+
return acquired;
|
|
137
|
+
}
|
|
138
|
+
const lease=this.lease(family);
|
|
139
|
+
if (lease && lease.owner!==this.owner && lease.expires>this.now()) return false;
|
|
140
|
+
localOwners.set(key,this.owner);
|
|
141
|
+
this.write(key+':lease',JSON.stringify({owner:this.owner,expires:this.now()+LEASE_MS}));
|
|
142
|
+
this.releases.set(family,()=>{});
|
|
143
|
+
return this.owns(family);
|
|
144
|
+
}
|
|
145
|
+
owns(family) {
|
|
146
|
+
const key=saveKey(this.instance,family);
|
|
147
|
+
if (localOwners.get(key)!==this.owner || !this.releases.has(family)) return false;
|
|
148
|
+
if (this.locks) return true;
|
|
149
|
+
const lease=this.lease(family);
|
|
150
|
+
return lease?.owner===this.owner && lease.expires>this.now();
|
|
151
|
+
}
|
|
152
|
+
renew(family) {
|
|
153
|
+
if (!this.owns(family)) return false;
|
|
154
|
+
if (!this.locks) this.write(saveKey(this.instance,family)+':lease',JSON.stringify({owner:this.owner,expires:this.now()+LEASE_MS}));
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
save(family,state) {
|
|
158
|
+
if (!families.includes(family) || !validState(state)) throw new Error('Invalid game state. Progress was not replaced.');
|
|
159
|
+
if (!this.owns(family)) throw new Error('This pet is active in another window.');
|
|
160
|
+
const key=saveKey(this.instance,family);
|
|
161
|
+
const expected=this.revisions.get(family) || 0;
|
|
162
|
+
const current=this.read(key);
|
|
163
|
+
if (current!==null) {
|
|
164
|
+
let data;
|
|
165
|
+
try { data=JSON.parse(current); } catch { throw new Error('Saved progress needs recovery.'); }
|
|
166
|
+
if (!validEnvelope(data,this.instance,family) || data.revision!==expected) throw new Error('Newer progress exists. Reopen this pet to continue.');
|
|
167
|
+
} else if (expected!==0) throw new Error('Progress changed in another window.');
|
|
168
|
+
const envelope={schema:1,instance:this.instance,family,revision:expected+1,savedAt:this.now(),state:clone(state)};
|
|
169
|
+
if(!validEnvelope(envelope,this.instance,family))throw new Error('Invalid game state. Progress was not replaced.');
|
|
170
|
+
this.write(key,JSON.stringify(envelope));
|
|
171
|
+
this.revisions.set(family,envelope.revision);
|
|
172
|
+
this.renew(family);
|
|
173
|
+
return envelope;
|
|
174
|
+
}
|
|
175
|
+
replace(family,state) {
|
|
176
|
+
if(!this.owns(family))throw new Error('This pet is active in another window.');
|
|
177
|
+
const key=saveKey(this.instance,family);
|
|
178
|
+
const raw=this.read(key);
|
|
179
|
+
if(raw!==null)this.write(`${key}:recovery`,raw);
|
|
180
|
+
const envelope={schema:1,instance:this.instance,family,revision:1,savedAt:this.now(),state:clone(state)};
|
|
181
|
+
if(!validEnvelope(envelope,this.instance,family))throw new Error('Invalid imported state.');
|
|
182
|
+
this.write(key,JSON.stringify(envelope));this.revisions.set(family,1);return envelope;
|
|
183
|
+
}
|
|
184
|
+
raw(family){return this.read(saveKey(this.instance,family));}
|
|
185
|
+
release(family) {
|
|
186
|
+
const key=saveKey(this.instance,family);
|
|
187
|
+
if (!this.locks && this.lease(family)?.owner===this.owner) this.write(key+':lease',JSON.stringify({owner:this.owner,expires:0}));
|
|
188
|
+
if(localOwners.get(key)===this.owner) localOwners.delete(key);
|
|
189
|
+
const request=this.lockRequests.get(family);
|
|
190
|
+
if(this.releases.has(family) && request){
|
|
191
|
+
this.pendingReleases.set(family,request);
|
|
192
|
+
request.then(()=>{if(this.pendingReleases.get(family)===request)this.pendingReleases.delete(family);});
|
|
193
|
+
}
|
|
194
|
+
this.releases.get(family)?.();
|
|
195
|
+
this.releases.delete(family);
|
|
196
|
+
}
|
|
197
|
+
dispose() { for(const family of [...this.releases.keys()]) this.release(family); }
|
|
198
|
+
}
|