Skip to content

Commit d191cc6

Browse files
authored
Merge PR #23: Add daily challenges and completion sharing
Add daily challenge codes, local metrics, and completion sharing
2 parents 93eb263 + 77d154f commit d191cc6

9 files changed

Lines changed: 346 additions & 16 deletions

File tree

PRIVACY.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Privacy
2+
3+
Little Android does not send gameplay analytics to a server.
4+
5+
The game stores the following data locally in the visitor's browser:
6+
7+
- discovered and collected signal IDs;
8+
- scanned resident names;
9+
- quest completion state;
10+
- sound preference;
11+
- aggregate counters such as sessions, first scan, completions, replays, and share intent.
12+
13+
These values contain no account identifier, free-form input, location, or device fingerprint. They are used only by the local experience and can be removed by clearing site data or resetting the journey where applicable. The game continues to work when browser storage is unavailable.
14+
15+
The share action uses the browser's native share sheet or clipboard only after the visitor activates it. Shared text contains the daily challenge code and the public project URL.
16+
17+
The page currently requests display fonts from Google Fonts. That external request is separate from gameplay measurement and is tracked for reliability/privacy review in issue #17.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Keep chan
5454

5555
Codex review follows the repository rules in [AGENTS.md](AGENTS.md).
5656

57+
## Privacy
58+
59+
Gameplay progress, preferences, and aggregate engagement counters remain in the visitor's browser. See [PRIVACY.md](PRIVACY.md) for the exact local data and sharing behavior.
60+
5761
## License
5862

5963
Source code and original project assets are available under the [MIT License](LICENSE).

index.html

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
#quest-reset{position:absolute;top:max(88px,calc(66px + env(safe-area-inset-top)));right:max(22px,env(safe-area-inset-right));z-index:20;display:none;width:34px;height:34px;border:1px solid #D4A030;border-radius:4px;background:rgba(10,10,10,.82);color:#E8C040;font:20px monospace;cursor:pointer}
3636
#quest-reset.show{display:block}
3737
#quest-reset:focus-visible{outline:3px solid #E8E0D0;outline-offset:2px}
38+
#share-button{position:absolute;top:max(128px,calc(106px + env(safe-area-inset-top)));right:max(22px,env(safe-area-inset-right));z-index:20;display:none;width:34px;height:34px;border:1px solid #D4A030;border-radius:4px;background:rgba(10,10,10,.82);color:#E8C040;font:18px monospace;cursor:pointer}
39+
#share-button.show{display:block}
40+
#share-button:focus-visible{outline:3px solid #E8E0D0;outline-offset:2px}
3841
#journal-button{position:absolute;top:max(48px,calc(26px + env(safe-area-inset-top)));right:max(22px,env(safe-area-inset-right));z-index:20;width:42px;height:30px;border:1px solid #5A8848;border-radius:4px;background:rgba(10,10,10,.82);color:#A8C088;font:10px 'Silkscreen',monospace;cursor:pointer}
3942
#journal-button:focus-visible,#journal-close:focus-visible{outline:3px solid #E8E0D0;outline-offset:2px}
4043
#sound-button{position:absolute;right:max(20px,env(safe-area-inset-right));bottom:max(20px,env(safe-area-inset-bottom));z-index:20;width:38px;height:34px;border:1px solid #5A8848;border-radius:4px;background:rgba(10,10,10,.82);color:#A8C088;font:18px monospace;cursor:pointer}
@@ -57,6 +60,7 @@
5760
#quest{top:calc(54px + env(safe-area-inset-top));right:12px;font-size:9px}
5861
#journal-button{top:calc(74px + env(safe-area-inset-top));right:12px}
5962
#quest-reset{top:calc(112px + env(safe-area-inset-top));right:12px}
63+
#share-button{top:calc(152px + env(safe-area-inset-top));right:12px}
6064
#hint{bottom:max(88px,calc(68px + env(safe-area-inset-bottom)));width:70vw;font-size:10px;text-align:center}
6165
#action-button{display:block}
6266
#sound-button{right:max(104px,calc(104px + env(safe-area-inset-right)));bottom:max(28px,calc(28px + env(safe-area-inset-bottom)))}
@@ -89,6 +93,7 @@
8993
<div id="quest" aria-live="polite">SIGNALS 0/3</div>
9094
<button id="journal-button" type="button" aria-label="Open signal log">LOG</button>
9195
<button id="quest-reset" type="button" aria-label="Restart signal quest" title="Restart signal quest"></button>
96+
<button id="share-button" type="button" aria-label="Share completion" title="Share completion"></button>
9297
<button id="action-button" type="button" aria-label="Scan or interact">SCAN</button>
9398
<button id="sound-button" type="button" aria-label="Mute sound" title="Toggle sound"></button>
9499
<p class="sr-only" id="game-instructions">Move with WASD, arrow keys, or by selecting a destination. Press Space or Enter, or use the Scan button, to scan nearby residents or interact with the character you face.</p>
@@ -102,6 +107,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
102107
<script src="src/game-content.js"></script>
103108
<script src="src/progress.js"></script>
104109
<script src="src/feedback.js"></script>
110+
<script src="src/engagement.js"></script>
105111
<script>
106112
// ══════════════════════════════════════════════════════
107113
// SETUP
@@ -120,6 +126,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
120126
const journalList = document.getElementById('journal-list');
121127
const journalClose = document.getElementById('journal-close');
122128
const soundButton = document.getElementById('sound-button');
129+
const shareButton = document.getElementById('share-button');
123130

124131
const PX = 3;
125132
const TILE = 16;
@@ -173,15 +180,29 @@ <h2 id="journal-title">SIGNAL LOG</h2>
173180
} = LittleAndroidLogic;
174181
const world = createWorldMap(77);
175182
const MW = world.width, MH = world.height, map = world.map;
176-
const signalFragments = LittleAndroidContent.SIGNAL_FRAGMENTS.map(fragment => ({
177-
...fragment,
183+
const dailyChallenge = LittleAndroidEngagement.createDailyChallenge(
184+
new Date(),
185+
LittleAndroidContent.SIGNAL_FRAGMENTS.map(fragment => fragment.id),
186+
);
187+
const fragmentContent = new Map(LittleAndroidContent.SIGNAL_FRAGMENTS.map(fragment => [fragment.id, fragment]));
188+
const signalFragments = dailyChallenge.order.map(id => ({
189+
...fragmentContent.get(id),
178190
discovered: false,
179191
collected: false,
180192
}));
181193
let progressStorage = null;
182194
try { progressStorage = window.localStorage; } catch {}
183-
const savedProgress = LittleAndroidProgress.loadProgress(progressStorage);
195+
const loadedProgress = LittleAndroidProgress.loadProgress(progressStorage);
196+
const savedProgress = LittleAndroidProgress.scopeProgressToChallenge(
197+
loadedProgress,
198+
dailyChallenge.dateKey,
199+
);
200+
if (loadedProgress.challengeDate !== savedProgress.challengeDate) {
201+
LittleAndroidProgress.saveProgress(progressStorage, savedProgress);
202+
}
184203
const feedback = LittleAndroidFeedback.createFeedbackController(window, progressStorage);
204+
const metrics = LittleAndroidEngagement.createLocalMetrics(progressStorage);
205+
metrics.increment('sessions');
185206
let progressGeneration = savedProgress.generation;
186207
for (const fragment of signalFragments) {
187208
fragment.discovered = savedProgress.discoveredFragments.includes(fragment.id);
@@ -274,6 +295,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
274295
player.moveToX = tx; player.moveToY = ty;
275296
player.moveProgress = 0;
276297
player.idleTimer = 0;
298+
metrics.markOnce('first_move');
277299
}
278300

279301
function drawUnit01(sx, sy, facing, walkFrame, moving, idleCoreOn, interacting) {
@@ -660,6 +682,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
660682
triggerNPCInteraction(npc);
661683
rememberNPC(npc);
662684
feedback.play('interact');
685+
metrics.markOnce('first_interaction');
663686
const dialogue = LittleAndroidContent.NPC_DIALOGUE[npc.type];
664687
showStatus(dialogue ? (quest.complete ? dialogue.complete : dialogue.active) : `LINK: ${npc.scanLabel || npc.type.toUpperCase()}`, 3.5);
665688
}
@@ -974,13 +997,15 @@ <h2 id="journal-title">SIGNAL LOG</h2>
974997

975998
function updateQuestUI() {
976999
const collected = signalFragments.filter(fragment => fragment.collected).length;
977-
questEl.textContent = quest.complete ? 'FACTORY ONLINE' : `SIGNALS ${collected}/${signalFragments.length}`;
1000+
questEl.textContent = quest.complete ? `FACTORY ONLINE · ${dailyChallenge.code}` : `SIGNALS ${collected}/${signalFragments.length} · ${dailyChallenge.code}`;
9781001
questResetButton.classList.toggle('show', quest.complete);
1002+
shareButton.classList.toggle('show', quest.complete);
9791003
}
9801004

9811005
function currentProgress() {
9821006
return {
9831007
version: LittleAndroidProgress.SAVE_VERSION,
1008+
challengeDate: dailyChallenge.dateKey,
9841009
generation: progressGeneration,
9851010
discoveredFragments: signalFragments.filter(fragment => fragment.discovered).map(fragment => fragment.id),
9861011
collectedFragments: signalFragments.filter(fragment => fragment.collected).map(fragment => fragment.id),
@@ -990,7 +1015,9 @@ <h2 id="journal-title">SIGNAL LOG</h2>
9901015
}
9911016

9921017
function persistProgress() {
993-
const latest = LittleAndroidProgress.loadProgress(progressStorage);
1018+
const stored = LittleAndroidProgress.loadProgress(progressStorage);
1019+
if (stored.challengeDate && stored.challengeDate !== dailyChallenge.dateKey) return false;
1020+
const latest = LittleAndroidProgress.scopeProgressToChallenge(stored, dailyChallenge.dateKey);
9941021
const merged = LittleAndroidProgress.mergeProgress(
9951022
latest,
9961023
currentProgress(),
@@ -1039,6 +1066,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
10391066
showStatus('FACTORY LINK RESTORED', 4);
10401067
feedback.play('complete');
10411068
feedback.haptic([25, 35, 50]);
1069+
metrics.increment('completions');
10421070
} else {
10431071
showStatus(`${fragment.name} RECOVERED · ${collected}/${signalFragments.length}`, 2.5);
10441072
feedback.play('collect');
@@ -1051,6 +1079,11 @@ <h2 id="journal-title">SIGNAL LOG</h2>
10511079
}
10521080

10531081
function resetQuest() {
1082+
const storedProgress = LittleAndroidProgress.loadProgress(progressStorage);
1083+
if (storedProgress.challengeDate && storedProgress.challengeDate !== dailyChallenge.dateKey) {
1084+
showStatus('NEW DAILY CHALLENGE ACTIVE IN ANOTHER TAB', 3);
1085+
return false;
1086+
}
10541087
const occupiedSignalSite = signalFragments.some(fragment => (
10551088
entityOccupiesTile(player, fragment.x, fragment.y) ||
10561089
isTileReserved(npcs, fragment.x, fragment.y, null)
@@ -1069,12 +1102,13 @@ <h2 id="journal-title">SIGNAL LOG</h2>
10691102
discoveredNPCs.clear();
10701103
quest.complete = false;
10711104
document.body.classList.remove('world-restored');
1072-
const storedGeneration = LittleAndroidProgress.loadProgress(progressStorage).generation;
1105+
const storedGeneration = storedProgress.generation;
10731106
progressGeneration = Math.max(progressGeneration, storedGeneration) + 1;
10741107
LittleAndroidProgress.saveProgress(progressStorage, currentProgress());
10751108
updateQuestUI();
10761109
updateJournal();
10771110
showStatus('SIGNAL SEARCH RESTARTED', 2);
1111+
metrics.increment('replays');
10781112
return true;
10791113
}
10801114

@@ -1123,6 +1157,30 @@ <h2 id="journal-title">SIGNAL LOG</h2>
11231157
if (!muted) feedback.play('interact');
11241158
});
11251159

1160+
async function shareCompletion() {
1161+
const text = LittleAndroidEngagement.completionText(dailyChallenge.code);
1162+
metrics.increment('share_intent');
1163+
if (window.navigator && typeof window.navigator.share === 'function') {
1164+
try {
1165+
await window.navigator.share({ title: 'Little Android', text, url: 'https://littleandroid.com' });
1166+
showStatus('COMPLETION SHARED', 2);
1167+
return;
1168+
} catch {
1169+
showStatus('SHARE CANCELLED', 2);
1170+
return;
1171+
}
1172+
}
1173+
if (window.navigator && window.navigator.clipboard) {
1174+
try {
1175+
await window.navigator.clipboard.writeText(text);
1176+
showStatus('RESULT COPIED', 2);
1177+
return;
1178+
} catch {}
1179+
}
1180+
showStatus(text, 4);
1181+
}
1182+
shareButton.addEventListener('click', shareCompletion);
1183+
11261184
let statusTimer = 0;
11271185
function showStatus(message, duration = 1.5) {
11281186
statusEl.textContent = message;
@@ -1168,6 +1226,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
11681226
player.idleTimer = 0;
11691227
showStatus('SCANNING...');
11701228
feedback.play('scan');
1229+
metrics.markOnce('first_scan');
11711230
return true;
11721231
}
11731232

scripts/check.cjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ if (!html.includes('src/progress.js')) {
2020
if (!html.includes('src/feedback.js')) {
2121
throw new Error('index.html must load the feedback module');
2222
}
23+
if (!html.includes('src/engagement.js')) {
24+
throw new Error('index.html must load the engagement module');
25+
}
2326
if (!html.includes('#ui-overlay button{pointer-events:auto}')) {
2427
throw new Error('Overlay controls must accept pointer input');
2528
}
@@ -32,5 +35,6 @@ require('../src/game-logic.js');
3235
require('../src/game-content.js');
3336
require('../src/progress.js');
3437
require('../src/feedback.js');
38+
require('../src/engagement.js');
3539

3640
console.log('Static checks passed.');

src/engagement.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
(function exposeEngagement(root, factory) {
2+
const api = factory();
3+
if (typeof module === 'object' && module.exports) module.exports = api;
4+
if (root) root.LittleAndroidEngagement = api;
5+
})(typeof globalThis !== 'undefined' ? globalThis : this, function createEngagementApi() {
6+
'use strict';
7+
8+
const METRICS_KEY = 'littleandroid.metrics';
9+
10+
function hashString(value) {
11+
let hash = 2166136261;
12+
for (let index = 0; index < value.length; index++) {
13+
hash ^= value.charCodeAt(index);
14+
hash = Math.imul(hash, 16777619);
15+
}
16+
return hash >>> 0;
17+
}
18+
19+
function seededRandom(seed) {
20+
return function random() {
21+
seed |= 0;
22+
seed = seed + 0x6D2B79F5 | 0;
23+
let value = Math.imul(seed ^ seed >>> 15, 1 | seed);
24+
value = value + Math.imul(value ^ value >>> 7, 61 | value) ^ value;
25+
return ((value ^ value >>> 14) >>> 0) / 4294967296;
26+
};
27+
}
28+
29+
function createDailyChallenge(date, fragmentIds) {
30+
const dateKey = typeof date === 'string' ? date : date.toISOString().slice(0, 10);
31+
const seed = hashString(dateKey);
32+
const random = seededRandom(seed);
33+
const order = [...fragmentIds];
34+
for (let index = order.length - 1; index > 0; index--) {
35+
const swapIndex = Math.floor(random() * (index + 1));
36+
[order[index], order[swapIndex]] = [order[swapIndex], order[index]];
37+
}
38+
return {
39+
dateKey,
40+
code: `D${dateKey.replace(/-/g, '')}-${String(seed % 10000).padStart(4, '0')}`,
41+
order,
42+
};
43+
}
44+
45+
function parseMetrics(raw) {
46+
try {
47+
const parsed = JSON.parse(raw || '{}');
48+
const counters = {};
49+
for (const [name, value] of Object.entries(parsed.counters || {})) {
50+
if (Number.isFinite(value) && value >= 0) counters[name] = value;
51+
}
52+
return { counters };
53+
} catch {
54+
return { counters: {} };
55+
}
56+
}
57+
58+
function createLocalMetrics(storage) {
59+
let state;
60+
try { state = parseMetrics(storage && storage.getItem(METRICS_KEY)); }
61+
catch { state = { counters: {} }; }
62+
63+
function reload() {
64+
try { state = parseMetrics(storage && storage.getItem(METRICS_KEY)); }
65+
catch { state = { counters: {} }; }
66+
return state;
67+
}
68+
69+
function persist() {
70+
try {
71+
if (storage) storage.setItem(METRICS_KEY, JSON.stringify(state));
72+
} catch {}
73+
}
74+
75+
function increment(name) {
76+
reload();
77+
state.counters[name] = (state.counters[name] || 0) + 1;
78+
persist();
79+
return state.counters[name];
80+
}
81+
82+
function markOnce(name) {
83+
reload();
84+
if (state.counters[name]) return false;
85+
state.counters[name] = 1;
86+
persist();
87+
return true;
88+
}
89+
90+
return Object.freeze({
91+
increment,
92+
markOnce,
93+
snapshot: () => JSON.parse(JSON.stringify(reload())),
94+
});
95+
}
96+
97+
function completionText(challengeCode) {
98+
return `I restored the Little Android factory (${challengeCode}). Explore it at https://littleandroid.com`;
99+
}
100+
101+
return Object.freeze({
102+
METRICS_KEY,
103+
hashString,
104+
createDailyChallenge,
105+
parseMetrics,
106+
createLocalMetrics,
107+
completionText,
108+
});
109+
});

0 commit comments

Comments
 (0)