suzy-says-game-practice-kit 0.0.0-stage → 0.1.0
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 +26 -0
- package/PRIVACY.md +5 -0
- package/README.md +49 -2
- package/bin/cli.js +29 -0
- package/examples/answers.json +21 -0
- package/examples/invalid-session.json +1 -0
- package/examples/session.json +21 -0
- package/lib/core.js +45 -0
- package/package.json +35 -4
- package/web/PRIVACY.md +5 -0
- package/web/app.js +18 -0
- package/web/core.js +45 -0
- package/web/icon.svg +1 -0
- package/web/index.html +7 -0
- package/web/privacy.html +1 -0
- package/web/style.css +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Suzy Says Game Practice Kit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
Scope: only original helper code, example drills and documentation in this
|
|
24
|
+
independent repository. No game code, game artwork, recordings or game scripts
|
|
25
|
+
are included. Suzy Says! and its characters belong to MOONBIT. Neither the game
|
|
26
|
+
nor its name, trademarks or third-party assets are licensed under this MIT grant.
|
package/PRIVACY.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Privacy
|
|
2
|
+
|
|
3
|
+
The Suzy Says Game Practice Kit runs locally. It makes no network requests, includes no analytics, and requests no browser permissions or host access. Practice answers stay in the current page until you export them or close/reset the page. Import and export occur only when you choose a file or click Download. No data is sent to AFTERPLAY, MOONBIT or a third party.
|
|
4
|
+
|
|
5
|
+
Product links open the separate AFTERPLAY website only when clicked. That site includes an optional third-party game player with a separate privacy policy. Do not put names, emails, account data or other personal information into custom practice prompts. The tool is not connected to the actual game and does not read browser tabs, game progress or game saves.
|
package/README.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Suzy Says Game Practice Kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An independent, free offline helper for **Suzy Says Game** players: practise the “Suzy says” prefix rule without story spoilers, jumpscares or the full game download. [Play Suzy Says Game and read the independent player guides](https://suzysaysgame.app/).
|
|
4
|
+
|
|
5
|
+
This is an original practice aid maintained for the AFTERPLAY fan site. It is not the official game, a game solver, a MOONBIT SDK or an integration with game saves. Prompts are invented training examples, not an extracted game transcript. It does not simulate the game's timing, movement, audio recognition, strike system or endings. No improvement in game performance is promised.
|
|
6
|
+
|
|
7
|
+
## Use it
|
|
8
|
+
|
|
9
|
+
- **Browser:** serve `web/` from any static HTTP server, open `index.html`, start a drill, choose **Obey** or **Wait**, and download a JSON or CSV result. No account is needed; no network access is required after the files load.
|
|
10
|
+
- **Chrome extension:** use the separately reviewed extension ZIP or its unpacked folder. The source repository is not itself a Chrome Web Store listing.
|
|
11
|
+
- **CLI:** Node.js 22 or later; no dependencies and no install scripts. npm registry publication is pending. Until verified, use a local checkout or the reviewed tarball.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
node bin/cli.js generate --count 10 --seed 42 > session.json
|
|
15
|
+
node bin/cli.js validate --session session.json
|
|
16
|
+
node bin/cli.js grade --session examples/session.json --answers examples/answers.json
|
|
17
|
+
node bin/cli.js grade --session examples/session.json --answers examples/answers.json --format csv
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The included four-question example grades to **3/4 correct, 75%**. For the prefix rule, `Suzy says, jump on red.` means **Obey**; `Jump on red.` means **Wait**. Wait for the full sentence in the actual game.
|
|
21
|
+
|
|
22
|
+
## Bring your own drill
|
|
23
|
+
|
|
24
|
+
A session has exactly `schemaVersion: 1` and `prompts`; each prompt has only an `id` and `text`. Answers have `schemaVersion: 1` and `responses`, with an `id` and a `choice` of `obey` or `wait`. See `examples/`. Missing responses count as incorrect. Unknown or duplicate IDs, unexpected fields, empty prompts and invalid choices are rejected.
|
|
25
|
+
|
|
26
|
+
Limits: 1–1000 prompts per session, 500 characters per prompt, 64 ASCII ID characters, 1 MiB JSON input in the CLI. The classifier checks an English prefix at the start of the instruction; it does not interpret what a command asks you to do. A negative instruction with the prefix still counts as Obey.
|
|
27
|
+
|
|
28
|
+
## Automation and reproducibility
|
|
29
|
+
|
|
30
|
+
`generate --seed 42 --count 10` always produces the same drill in version 0.1.0. The generator balances prefix and non-prefix examples to within one prompt. `--session -` or `--answers -` reads stdin; both cannot use stdin simultaneously.
|
|
31
|
+
|
|
32
|
+
Exit codes: **0** successful operation, **1** score below `--fail-under`, **2** invalid input/options or I/O error. For example, the bundled answers return exit 1 with `--fail-under 80`. CSV cells that start with spreadsheet formula prefixes are escaped.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm test
|
|
36
|
+
node bin/cli.js grade --session examples/session.json --answers examples/answers.json --fail-under 80
|
|
37
|
+
# Expected exit code: 1; the report still prints.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Free conditions and privacy
|
|
41
|
+
|
|
42
|
+
This helper is free under the scoped MIT license; it has no account, credits, remote API, telemetry or usage subscription. Static hosting and package registries have their own policies. The separate Suzy Says! game and its paid Full version are controlled by MOONBIT; this helper does not unlock paid content. See [Privacy](PRIVACY.md) and [License](LICENSE).
|
|
43
|
+
|
|
44
|
+
## Troubleshooting
|
|
45
|
+
|
|
46
|
+
If ES modules do not load from `file://`, use a static HTTP server. If an import fails, check the JSON schema and the 1 MiB limit. Zero answers are valid and score 0%. If a CLI pipeline fails, inspect stderr and exit code 2 rather than treating a partial file as a report. This tool does not repair a failed game server or make the desktop game work on phones.
|
|
47
|
+
|
|
48
|
+
## Rights and AI assistance
|
|
49
|
+
|
|
50
|
+
Original helper code and examples were prepared with AI assistance and checked with the automated tests included here. No claim is made that a person has substantially edited the separate tutorial drafts; those are held privately for review. The official game is [Suzy Says! by MOONBIT](https://moonbit.itch.io/suzy-says). This helper is unaffiliated with and not endorsed by MOONBIT. No third-party game code or artwork is redistributed. Public contact email is intentionally omitted.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {readFile} from 'node:fs/promises';
|
|
3
|
+
import {generateSession,validateSession,gradeSession,reportToCsv,VERSION} from '../lib/core.js';
|
|
4
|
+
const help=`Suzy Says Game Practice Kit ${VERSION}\nIndependent offline practice; no game code, integration or official transcript.\n\n suzy-practice generate [--count 10] [--seed 42]\n suzy-practice validate --session FILE|-\n suzy-practice grade --session FILE --answers FILE|- [--format json|csv] [--fail-under 0..100]\n\n'-' reads stdin. One input may use stdin. Maximum JSON input: 1 MiB.\nExit 0: success; 1: accuracy below threshold; 2: invalid input/options or I/O error.\nMissing answers count as incorrect. Generated prompts are original examples.\n`;
|
|
5
|
+
async function jsonInput(file){
|
|
6
|
+
const limit=1048576;
|
|
7
|
+
if(file==='-'){let bytes=0;const chunks=[];for await(const chunk of process.stdin){bytes+=chunk.length;if(bytes>limit)throw new Error('input exceeds 1 MiB');chunks.push(chunk);}return JSON.parse(Buffer.concat(chunks).toString('utf8'));}
|
|
8
|
+
const data=await readFile(file);if(data.length>limit)throw new Error('input exceeds 1 MiB');return JSON.parse(data.toString('utf8'));
|
|
9
|
+
}
|
|
10
|
+
async function main(){
|
|
11
|
+
const args=process.argv.slice(2);if(args.length===0||args[0]==='--help'){process.stdout.write(help);return;}
|
|
12
|
+
if(args[0]==='--version'){process.stdout.write(VERSION+'\n');return;}
|
|
13
|
+
const command=args.shift();const allowed={generate:['count','seed'],validate:['session'],grade:['session','answers','format','fail-under']}[command];if(!allowed)throw new Error('unknown command');
|
|
14
|
+
const options={};for(let i=0;i<args.length;i+=2){const key=args[i].slice(2);if(!args[i].startsWith('--')||!allowed.includes(key)||args[i+1]===undefined||Object.hasOwn(options,key))throw new Error('unknown, duplicate or incomplete option');options[key]=args[i+1];}
|
|
15
|
+
const required=(key)=>{if(!options[key])throw new Error(`--${key} is required`);return options[key];};
|
|
16
|
+
const number=(key,fallback)=>{if(options[key]===undefined)return fallback;if(!/^\d+(?:\.\d+)?$/.test(options[key]))throw new Error(`--${key} must be numeric`);return Number(options[key]);};
|
|
17
|
+
let output;
|
|
18
|
+
if(command==='generate')output=generateSession({count:number('count',10),seed:number('seed',42)});
|
|
19
|
+
if(command==='validate'){const session=validateSession(await jsonInput(required('session')));output={valid:true,prompts:session.prompts.length};}
|
|
20
|
+
if(command==='grade'){
|
|
21
|
+
if(required('session')==='-'&&required('answers')==='-')throw new Error('only one input may use stdin');
|
|
22
|
+
if(options.format&&!['json','csv'].includes(options.format))throw new Error('--format must be json or csv');
|
|
23
|
+
const threshold=number('fail-under',0);if(threshold<0||threshold>100)throw new Error('--fail-under must be from 0 to 100');
|
|
24
|
+
const report=gradeSession(await jsonInput(options.session),await jsonInput(required('answers')));if(report.accuracyPercent<threshold)process.exitCode=1;
|
|
25
|
+
if(options.format==='csv'){process.stdout.write(reportToCsv(report));return;}output=report;
|
|
26
|
+
}
|
|
27
|
+
process.stdout.write(JSON.stringify(output,null,2)+'\n');
|
|
28
|
+
}
|
|
29
|
+
main().catch(error=>{process.stderr.write('Error: '+error.message+'\n');process.exitCode=2;});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schemaVersion":1,"prompts":[{"id":"same","text":"Jump."},{"id":"same","text":"Wait."}]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"prompts": [
|
|
4
|
+
{
|
|
5
|
+
"id": "q1",
|
|
6
|
+
"text": "Suzy says, jump on red."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "q2",
|
|
10
|
+
"text": "Look at the window."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "q3",
|
|
14
|
+
"text": "Suzy says, crouch."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "q4",
|
|
18
|
+
"text": "Step onto blue."
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
package/lib/core.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const VERSION = '0.1.0';
|
|
2
|
+
export const MAX_ITEMS = 1000;
|
|
3
|
+
const actions = ['jump on red', 'look at the television', 'crouch', 'step onto blue', 'walk to the door', 'stand on yellow', 'face the window', 'step onto green'];
|
|
4
|
+
function object(value, keys, label) {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError(`${label} must be an object`);
|
|
6
|
+
if (Object.keys(value).some(k => !keys.includes(k)) || keys.some(k => !Object.hasOwn(value,k))) throw new TypeError(`${label} has missing or unexpected fields`);
|
|
7
|
+
}
|
|
8
|
+
function id(value) { if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(value)) throw new TypeError('id must contain 1–64 letters, digits, underscores or hyphens'); }
|
|
9
|
+
export function classifyPrompt(text) {
|
|
10
|
+
if (typeof text !== 'string' || !text.trim() || text.length > 500) throw new TypeError('prompt must contain 1–500 characters');
|
|
11
|
+
const trimmed = text.trim();
|
|
12
|
+
const prefix = /^suzy\s+says\b/i.exec(trimmed);
|
|
13
|
+
const instruction = prefix ? trimmed.slice(prefix[0].length).replace(/^[\s,:.!?–—-]+/, '') : '';
|
|
14
|
+
const obey = Boolean(prefix && instruction);
|
|
15
|
+
return {expected: obey ? 'obey' : 'wait', instruction: obey ? instruction : trimmed};
|
|
16
|
+
}
|
|
17
|
+
export function validateSession(value) {
|
|
18
|
+
object(value,['schemaVersion','prompts'],'session');
|
|
19
|
+
if (value.schemaVersion !== 1 || !Array.isArray(value.prompts) || value.prompts.length < 1 || value.prompts.length > MAX_ITEMS) throw new TypeError('session requires schemaVersion 1 and 1–1000 prompts');
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
for (const item of value.prompts) {object(item,['id','text'],'prompt'); id(item.id); if(ids.has(item.id)) throw new TypeError('duplicate prompt id'); ids.add(item.id); classifyPrompt(item.text);}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function generateSession({count = 10, seed = 42} = {}) {
|
|
25
|
+
if (!Number.isInteger(count) || count < 1 || count > MAX_ITEMS) throw new RangeError('count must be an integer from 1 to 1000');
|
|
26
|
+
if (!Number.isInteger(seed) || seed < 0 || seed > 4294967295) throw new RangeError('seed must be an unsigned 32-bit integer');
|
|
27
|
+
let state = seed;
|
|
28
|
+
const random = () => {state += 0x6D2B79F5;let x=state; x=Math.imul(x ^ x>>>15,x|1);x^=x+Math.imul(x ^ x>>>7,x|61);return ((x ^ x>>>14)>>>0)/4294967296;};
|
|
29
|
+
const prompts=Array.from({length:count},(_,i)=>({id:`q${i+1}`,text:`${i % 2 === 0 ? 'Suzy says, ' : ''}${actions[Math.floor(random()*actions.length)]}.`}));
|
|
30
|
+
for(let i=prompts.length-1;i>0;i--){const j=Math.floor(random()*(i+1));[prompts[i],prompts[j]]=[prompts[j],prompts[i]];}
|
|
31
|
+
return {schemaVersion:1,prompts};
|
|
32
|
+
}
|
|
33
|
+
export function gradeSession(session, answers) {
|
|
34
|
+
validateSession(session); object(answers,['schemaVersion','responses'],'answers');
|
|
35
|
+
if(answers.schemaVersion!==1 || !Array.isArray(answers.responses) || answers.responses.length>MAX_ITEMS) throw new TypeError('answers require schemaVersion 1 and up to 1000 responses');
|
|
36
|
+
const known=new Set(session.prompts.map(p=>p.id)); const choices=new Map();
|
|
37
|
+
for(const response of answers.responses){object(response,['id','choice'],'response');id(response.id);if(!known.has(response.id)) throw new TypeError('answer contains an unknown id');if(choices.has(response.id))throw new TypeError('duplicate answer id');if(!['obey','wait'].includes(response.choice))throw new TypeError('choice must be obey or wait');choices.set(response.id,response.choice);}
|
|
38
|
+
const results=session.prompts.map(p=>{const expected=classifyPrompt(p.text).expected;const choice=choices.get(p.id)??null;return {...p,expected,choice,correct:choice===expected};});
|
|
39
|
+
const correct=results.filter(r=>r.correct).length;
|
|
40
|
+
return {schemaVersion:1,toolVersion:VERSION,total:results.length,answered:choices.size,correct,unanswered:results.length-choices.size,accuracyPercent:Math.round(correct/results.length*10000)/100,results};
|
|
41
|
+
}
|
|
42
|
+
export function reportToCsv(report){
|
|
43
|
+
const cell=value=>{let text=String(value??'');if(/^[\s]*[=+@-]/.test(text))text="'"+text;return '"'+text.replaceAll('"','""')+'"';};
|
|
44
|
+
return [['id','text','expected','choice','correct'],...report.results.map(r=>[r.id,r.text,r.expected,r.choice,r.correct])].map(row=>row.map(cell).join(',')).join('\r\n')+'\r\n';
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "suzy-says-game-practice-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Independent offline Suzy Says Game rule drills, quiz validation and JSON/CSV practice reports. No game code or assets.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://suzysaysgame.app/",
|
|
11
|
+
"exports": "./lib/core.js",
|
|
12
|
+
"bin": {
|
|
13
|
+
"suzy-practice": "./bin/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"lib/",
|
|
18
|
+
"web/",
|
|
19
|
+
"examples/",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"PRIVACY.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/*.test.js"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"suzy-says-game",
|
|
29
|
+
"practice",
|
|
30
|
+
"offline",
|
|
31
|
+
"quiz",
|
|
32
|
+
"simon-says"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/web/PRIVACY.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Privacy
|
|
2
|
+
|
|
3
|
+
The Suzy Says Game Practice Kit runs locally. It makes no network requests, includes no analytics, and requests no browser permissions or host access. Practice answers stay in the current page until you export them or close/reset the page. Import and export occur only when you choose a file or click Download. No data is sent to AFTERPLAY, MOONBIT or a third party.
|
|
4
|
+
|
|
5
|
+
Product links open the separate AFTERPLAY website only when clicked. That site includes an optional third-party game player with a separate privacy policy. Do not put names, emails, account data or other personal information into custom practice prompts. The tool is not connected to the actual game and does not read browser tabs, game progress or game saves.
|
package/web/app.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {generateSession,validateSession,gradeSession,classifyPrompt,reportToCsv} from './core.js';
|
|
2
|
+
const $=id=>document.getElementById(id);
|
|
3
|
+
let session=null,index=0,responses=[],report=null,answered=false;
|
|
4
|
+
function error(message){$('error').textContent=message;}
|
|
5
|
+
function start(value){session=validateSession(value);index=0;responses=[];report=null;$('results').hidden=true;$('drill').hidden=false;error('');show();}
|
|
6
|
+
function show(){answered=false;$('prompt').textContent=session.prompts[index].text;$('position').textContent=`${index+1} / ${session.prompts.length}`;$('feedback').textContent='';$('next').hidden=true;$('obey').disabled=false;$('wait').disabled=false;$('obey').focus();}
|
|
7
|
+
function answer(choice){if(!session||answered||$('drill').hidden)return;answered=true;const item=session.prompts[index];const expected=classifyPrompt(item.text).expected;responses.push({id:item.id,choice});$('obey').disabled=true;$('wait').disabled=true;$('feedback').textContent=(choice===expected?'Correct. ':'Try a different choice next time. ')+(expected==='obey'?'The instruction starts with “Suzy says”.':'The complete “Suzy says” prefix and instruction are missing, so wait.');$('next').hidden=false;$('next').textContent=index===session.prompts.length-1?'See my report →':'Next instruction →';$('next').focus();}
|
|
8
|
+
function finish(){report=gradeSession(session,{schemaVersion:1,responses});$('drill').hidden=true;$('results').hidden=false;$('score').textContent=`${report.correct} / ${report.total} correct · ${report.accuracyPercent}%`;$('review').replaceChildren();for(const row of report.results){const el=document.createElement('p');el.className=row.correct?'correct':'mistake';el.textContent=`${row.correct?'✓':'✕'} ${row.text} — expected ${row.expected}; you chose ${row.choice}.`;$('review').append(el);}$('json').focus();}
|
|
9
|
+
function download(name,content,type){const url=URL.createObjectURL(new Blob([content],{type}));const link=document.createElement('a');link.href=url;link.download=name;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
|
10
|
+
$('start').addEventListener('click',()=>{try{const count=Number($('count').value),seed=Number($('seed').value);if(!$('count').value.trim()||!$('seed').value.trim())throw new Error('Count and seed are required.');start(generateSession({count,seed}));}catch(e){error(e.message);}});
|
|
11
|
+
$('obey').addEventListener('click',()=>answer('obey'));$('wait').addEventListener('click',()=>answer('wait'));
|
|
12
|
+
$('next').addEventListener('click',()=>{if(!answered)return;if(index===session.prompts.length-1)finish();else{index++;show();}});
|
|
13
|
+
$('again').addEventListener('click',()=>start(session));
|
|
14
|
+
$('json').addEventListener('click',()=>download('suzy-says-game-practice-report.json',JSON.stringify(report,null,2)+'\n','application/json'));
|
|
15
|
+
$('csv').addEventListener('click',()=>download('suzy-says-game-practice-report.csv',reportToCsv(report),'text/csv'));
|
|
16
|
+
$('quiz').addEventListener('click',()=>download('suzy-says-game-practice-session.json',JSON.stringify(session,null,2)+'\n','application/json'));
|
|
17
|
+
$('import').addEventListener('change',async e=>{try{const file=e.target.files[0];if(!file)return;if(file.size>1048576)throw new Error('Session file exceeds 1 MiB.');start(JSON.parse(await file.text()));}catch(e){error(`Cannot import: ${e.message}`);}finally{$('import').value='';}});
|
|
18
|
+
document.addEventListener('keydown',e=>{if(e.ctrlKey||e.metaKey||e.altKey||/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName??''))return;if(!session||$('drill').hidden)return;const key=e.key.toLowerCase();if(key==='o'||key==='w'){e.preventDefault();answer(key==='o'?'obey':'wait');}else if(key==='enter'&&answered){e.preventDefault();$('next').click();}});
|
package/web/core.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const VERSION = '0.1.0';
|
|
2
|
+
export const MAX_ITEMS = 1000;
|
|
3
|
+
const actions = ['jump on red', 'look at the television', 'crouch', 'step onto blue', 'walk to the door', 'stand on yellow', 'face the window', 'step onto green'];
|
|
4
|
+
function object(value, keys, label) {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError(`${label} must be an object`);
|
|
6
|
+
if (Object.keys(value).some(k => !keys.includes(k)) || keys.some(k => !Object.hasOwn(value,k))) throw new TypeError(`${label} has missing or unexpected fields`);
|
|
7
|
+
}
|
|
8
|
+
function id(value) { if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,64}$/.test(value)) throw new TypeError('id must contain 1–64 letters, digits, underscores or hyphens'); }
|
|
9
|
+
export function classifyPrompt(text) {
|
|
10
|
+
if (typeof text !== 'string' || !text.trim() || text.length > 500) throw new TypeError('prompt must contain 1–500 characters');
|
|
11
|
+
const trimmed = text.trim();
|
|
12
|
+
const prefix = /^suzy\s+says\b/i.exec(trimmed);
|
|
13
|
+
const instruction = prefix ? trimmed.slice(prefix[0].length).replace(/^[\s,:.!?–—-]+/, '') : '';
|
|
14
|
+
const obey = Boolean(prefix && instruction);
|
|
15
|
+
return {expected: obey ? 'obey' : 'wait', instruction: obey ? instruction : trimmed};
|
|
16
|
+
}
|
|
17
|
+
export function validateSession(value) {
|
|
18
|
+
object(value,['schemaVersion','prompts'],'session');
|
|
19
|
+
if (value.schemaVersion !== 1 || !Array.isArray(value.prompts) || value.prompts.length < 1 || value.prompts.length > MAX_ITEMS) throw new TypeError('session requires schemaVersion 1 and 1–1000 prompts');
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
for (const item of value.prompts) {object(item,['id','text'],'prompt'); id(item.id); if(ids.has(item.id)) throw new TypeError('duplicate prompt id'); ids.add(item.id); classifyPrompt(item.text);}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function generateSession({count = 10, seed = 42} = {}) {
|
|
25
|
+
if (!Number.isInteger(count) || count < 1 || count > MAX_ITEMS) throw new RangeError('count must be an integer from 1 to 1000');
|
|
26
|
+
if (!Number.isInteger(seed) || seed < 0 || seed > 4294967295) throw new RangeError('seed must be an unsigned 32-bit integer');
|
|
27
|
+
let state = seed;
|
|
28
|
+
const random = () => {state += 0x6D2B79F5;let x=state; x=Math.imul(x ^ x>>>15,x|1);x^=x+Math.imul(x ^ x>>>7,x|61);return ((x ^ x>>>14)>>>0)/4294967296;};
|
|
29
|
+
const prompts=Array.from({length:count},(_,i)=>({id:`q${i+1}`,text:`${i % 2 === 0 ? 'Suzy says, ' : ''}${actions[Math.floor(random()*actions.length)]}.`}));
|
|
30
|
+
for(let i=prompts.length-1;i>0;i--){const j=Math.floor(random()*(i+1));[prompts[i],prompts[j]]=[prompts[j],prompts[i]];}
|
|
31
|
+
return {schemaVersion:1,prompts};
|
|
32
|
+
}
|
|
33
|
+
export function gradeSession(session, answers) {
|
|
34
|
+
validateSession(session); object(answers,['schemaVersion','responses'],'answers');
|
|
35
|
+
if(answers.schemaVersion!==1 || !Array.isArray(answers.responses) || answers.responses.length>MAX_ITEMS) throw new TypeError('answers require schemaVersion 1 and up to 1000 responses');
|
|
36
|
+
const known=new Set(session.prompts.map(p=>p.id)); const choices=new Map();
|
|
37
|
+
for(const response of answers.responses){object(response,['id','choice'],'response');id(response.id);if(!known.has(response.id)) throw new TypeError('answer contains an unknown id');if(choices.has(response.id))throw new TypeError('duplicate answer id');if(!['obey','wait'].includes(response.choice))throw new TypeError('choice must be obey or wait');choices.set(response.id,response.choice);}
|
|
38
|
+
const results=session.prompts.map(p=>{const expected=classifyPrompt(p.text).expected;const choice=choices.get(p.id)??null;return {...p,expected,choice,correct:choice===expected};});
|
|
39
|
+
const correct=results.filter(r=>r.correct).length;
|
|
40
|
+
return {schemaVersion:1,toolVersion:VERSION,total:results.length,answered:choices.size,correct,unanswered:results.length-choices.size,accuracyPercent:Math.round(correct/results.length*10000)/100,results};
|
|
41
|
+
}
|
|
42
|
+
export function reportToCsv(report){
|
|
43
|
+
const cell=value=>{let text=String(value??'');if(/^[\s]*[=+@-]/.test(text))text="'"+text;return '"'+text.replaceAll('"','""')+'"';};
|
|
44
|
+
return [['id','text','expected','choice','correct'],...report.results.map(r=>[r.id,r.text,r.expected,r.choice,r.correct])].map(row=>row.map(cell).join(',')).join('\r\n')+'\r\n';
|
|
45
|
+
}
|
package/web/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="28" fill="#77558a"/><rect x="14" y="14" width="47" height="47" rx="12" fill="#d9878b"/><rect x="67" y="14" width="47" height="47" rx="12" fill="#88abc9"/><rect x="14" y="67" width="47" height="47" rx="12" fill="#e8c86c"/><rect x="67" y="67" width="47" height="47" rx="12" fill="#94b18d"/><path d="m51 37 35 27-35 27z" fill="#fff4d7" stroke="#77558a" stroke-width="5" stroke-linejoin="round"/></svg>
|
package/web/index.html
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Practise the Suzy Says Game command rule offline with original, spoiler-free drills. Export a JSON or CSV practice report."><title>Suzy Says Game Practice Kit — Offline Rule Drills</title><link rel="stylesheet" href="style.css"><script type="module" src="app.js"></script></head>
|
|
3
|
+
<body><main><header><span class="badge">A LITTLE PRACTICE BEFORE THE SHOW</span><h1>Suzy Says Game<br><em>Practice Kit</em></h1><p>Listen to the whole instruction. Then decide: <strong>Obey or Wait?</strong></p><p class="disclosure">Independent offline helper from AFTERPLAY. Original examples, no game code, no story spoilers. This is not the official game or a proven training method.</p></header>
|
|
4
|
+
<section class="setup" aria-labelledby="setup-title"><h2 id="setup-title">Make a practice drill</h2><div class="settings"><label>Questions<input id="count" type="number" min="1" max="1000" value="10" required></label><label>Seed<input id="seed" type="number" min="0" max="4294967295" value="42" required></label></div><div class="actions"><button id="start">Start practice</button><label class="file-button">Import session JSON<input id="import" type="file" accept="application/json,.json"></label></div><p class="small">The same count and seed produce the same drill. Import limit: 1 MiB, 1–1000 prompts.</p><p id="error" role="alert"></p></section>
|
|
5
|
+
<section id="drill" hidden aria-labelledby="drill-title"><div class="progress"><h2 id="drill-title">Your practice</h2><span id="position"></span></div><div class="tv"><span class="on-air">WAIT FOR THE WHOLE SENTENCE</span><p id="prompt"></p><span class="example-label">An invented practice example — not game dialogue.</span></div><div class="answers"><button id="obey">Obey <kbd>O</kbd></button><button id="wait">Wait <kbd>W</kbd></button></div><p id="feedback" role="status" aria-live="polite"></p><button id="next" hidden>Next instruction →</button><p class="small">Keyboard: O to obey, W to wait, Enter for the next instruction. Shortcuts work only while a drill is active and focus is outside inputs.</p></section>
|
|
6
|
+
<section id="results" hidden aria-labelledby="result-title"><span class="badge">THE SHOW IS OVER</span><h2 id="result-title">Your practice report</h2><p id="score"></p><div id="review"></div><div class="actions"><button id="json">Download JSON report</button><button id="csv">Download CSV report</button><button id="quiz">Download session</button><button id="again">Practise again</button></div><p class="small">Your score describes this drill only. It does not measure progress in the real game.</p></section>
|
|
7
|
+
<footer><p>Free helper · No account · No analytics · No network requests</p><p>Files stay on your device. Do not enter personal information into prompts. <a href="privacy.html" target="_blank" rel="noopener noreferrer">Privacy details</a></p><p><a href="https://suzysaysgame.app/" target="_blank" rel="noopener noreferrer">Play Suzy Says Game and read the guides ↗</a></p><p>Suzy Says! belongs to MOONBIT. This helper is unaffiliated with the developer and does not unlock paid content. <a href="https://moonbit.itch.io/suzy-says" target="_blank" rel="noopener noreferrer">Official game ↗</a></p></footer></main></body></html>
|
package/web/privacy.html
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Privacy — Suzy Says Game Practice Kit</title><link rel="stylesheet" href="style.css"></head><body><main><h1>Privacy</h1><p>This independent helper runs locally and does not read browser tabs, game data, browsing history or account information. It requests no Chrome permissions or host access. It makes no network requests and contains no analytics.</p><p>Imported files are read only when you choose them. Answers remain in memory until you reset or close the page. Reports are downloaded only when you click a Download button. No information is sent to AFTERPLAY or MOONBIT.</p><p>Do not put personal data in custom prompts. Clicking a product link opens a separate website governed by its own privacy policy. The helper is not the actual Suzy Says! game.</p><p><a href="index.html">Back to practice</a></p></main></body></html>
|
package/web/style.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{color-scheme:light;font:16px/1.65 system-ui,sans-serif;color:#4b3b59;background:#f7efd9}*{box-sizing:border-box}body{margin:0;border-top:7px solid transparent;border-image:linear-gradient(90deg,#cf7d81 25%,#e9c965 25% 50%,#8caf8b 50% 75%,#85a9c8 75%) 1}main{max-width:780px;margin:auto;padding:38px 24px}h1,h2{line-height:1.1;letter-spacing:-.04em}h1{font-size:clamp(32px,6vw,54px);color:#77558a;margin:16px 0}h1 em{font-style:normal;color:#b76d7d}h2{font-size:26px;margin:0 0 18px}.badge,.on-air{font-size:10px;letter-spacing:2px;font-weight:750;color:#986173}.disclosure,.small,footer{font-size:12px;color:#7e6b7f}section{background:#fff9eb;border:1px solid #d7c3a4;border-bottom:4px solid #d7c3a4;border-radius:18px;padding:25px;margin-top:24px}.settings{display:grid;grid-template-columns:1fr 1fr;gap:16px}label{display:block;font-size:13px;font-weight:600}input[type=number]{display:block;width:100%;margin-top:7px;padding:9px;border:1px solid #c5a8ca;border-radius:7px;background:#fffdf7;font:inherit;color:inherit}.actions{display:flex;gap:12px;flex-wrap:wrap;margin:18px 0}button,.file-button{font:600 13px/1.4 system-ui;border:2px solid #8a679b;background:#f2ce6f;color:#563d67;box-shadow:0 3px #8a679b;border-radius:9px;padding:11px 16px;cursor:pointer}button:disabled{opacity:.5;cursor:default}.file-button{background:#ede2ef}.file-button input{display:block;margin-top:7px;font-size:11px;max-width:200px}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #6d87b8;outline-offset:4px}.progress{display:flex;justify-content:space-between;gap:15px;align-items:baseline}.progress span{font-size:13px}.tv{background:#33283c;color:#fff2d8;border:10px solid #8a739b;border-radius:20px;padding:27px;text-align:center}.tv p{font-size:clamp(22px,4vw,33px);font-weight:650;line-height:1.5;margin:25px 0}.on-air{color:#e0b6cb;font-size:9px}.example-label{color:#c6a9c1;font-size:11px}.answers{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin:20px 0}.answers button:first-child{background:#dce8d3}.answers button:last-child{background:#f4dedc}kbd{border:1px solid #ac93b3;border-radius:4px;font-size:10px;padding:2px 5px;margin-left:10px}#feedback{font-size:14px;min-height:45px}#error{color:#a73751;font-size:13px}#score{font-size:25px;color:#77558a;font-weight:700}#review p{font-size:13px;border-bottom:1px dashed #d0bfa7;padding-bottom:10px}.mistake{color:#a34058}.correct{color:#497454}footer{margin-top:34px;border-top:1px dashed #ccb99d;padding-top:18px}a{color:#77558a}a:hover{text-decoration:none}[hidden]{display:none!important}@media(max-width:460px){main{padding:25px 16px}section{padding:18px}.tv{padding:20px 13px;border-width:7px}.settings{gap:10px}.actions button{width:100%}.progress{gap:8px}.answers button{padding:12px}.badge{font-size:9px}}
|