'','faces'=>[],'complete'=>false];
if (!is_file($path) || is_link($path)) return $out;
$raw = @file_get_contents($path);
if (!is_string($raw) || $raw === '' || strlen($raw) > 262144) return $out;
if (preg_match('/^EMPIRE_FINGERPRINT\s+([0-9a-f]{64})\s*$/mi', $raw, $m)) $out['fingerprint'] = strtolower($m[1]);
if (preg_match_all('/^([0-9a-f]{64})\s+nosignup\.(com|org|net|trade|chat|work|market|date|fun|info)\s*$/mi', $raw, $mm, PREG_SET_ORDER)) {
foreach ($mm as $x) $out['faces']['nosignup.'.strtolower($x[2])] = strtolower($x[1]);
}
$need = ['nosignup.com','nosignup.org','nosignup.net','nosignup.trade','nosignup.chat','nosignup.work','nosignup.market','nosignup.date','nosignup.fun','nosignup.info'];
$out['complete'] = ($out['fingerprint'] !== '');
foreach ($need as $k) if (!isset($out['faces'][$k])) { $out['complete']=false; break; }
return $out;
}
function nsu_mirror_hello_payload(): array {
$mf = nsu_mirror_manifest_state();
$actual = strtolower((string)@hash_file('sha256', __FILE__));
$keys = ['nosignup.com','nosignup.org','nosignup.net'];
$expected = '';
foreach ($keys as $k) { if (isset($mf['faces'][$k])) { $expected=(string)$mf['faces'][$k]; break; } }
$faceMatch = ($actual !== '' && $expected !== '' && hash_equals($expected, $actual));
$manual = ((string)getenv('NSU_MIRROR_OVERLOAD') === '1') || is_file(__DIR__ . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'mirror.pause');
$loadRatio = null; $over = $manual;
if (function_exists('sys_getloadavg')) {
$la = @sys_getloadavg();
if (is_array($la) && isset($la[0])) {
$cpus = 1;
if (is_file('/proc/cpuinfo')) { $c=@file_get_contents('/proc/cpuinfo'); if (is_string($c)) { $n=preg_match_all('/^processor\s*:/m',$c); if ($n>0) $cpus=$n; } }
$loadRatio = round(((float)$la[0]) / max(1,$cpus), 3);
$mx = (float)(getenv('NSU_MIRROR_LOAD_RATIO_MAX') ?: 1.50);
if ($mx > 0 && $loadRatio >= $mx) $over = true;
}
}
$compatible = !empty($mf['complete']) && $faceMatch;
return [
'ok'=>true,'schema'=>'nsu-mirror-hello-v1','node_kind'=>'mirror','face'=>NSU_MIRROR_FACE,
'face_hash'=>$actual,'empire_fingerprint'=>(string)$mf['fingerprint'],
'complete_organism'=>(bool)$mf['complete'],'manifest_face_match'=>$faceMatch,
'compatible_for_service'=>$compatible,'accepting_traffic'=>$compatible && !$over,
'overloaded'=>$over,'load_ratio'=>$loadRatio,
'economy_mesh'=>false,
'economy_note'=>'Service mirror only. Decentralized NSU consensus is not claimed by this build.',
'capacity_authority'=>false,
];
}
if ((string)($_GET['api'] ?? '') === 'mirror_hello') {
if (PHP_SAPI !== 'cli') {
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store, max-age=0');
header('Access-Control-Allow-Origin: *');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
}
echo json_encode(nsu_mirror_hello_payload(), JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); exit;
}
/* === /NSU V8 MIRROR SERVICE SUBSTRATE ===================================== */
if (preg_match('/^www\.(.+)$/i', preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? '')), $m)) { header('Location: //' . strtolower($m[1]) . (string)($_SERVER['REQUEST_URI'] ?? '/'), true, 301); exit; }
/* Stock PHP 7.4 compatibility for the small PHP 8 string helper surface. */
if (!function_exists('str_contains')) {
function str_contains(string $haystack, string $needle): bool { return $needle === '' || strpos($haystack, $needle) !== false; }
}
if (!function_exists('str_starts_with')) {
function str_starts_with(string $haystack, string $needle): bool { return $needle === '' || strncmp($haystack, $needle, strlen($needle)) === 0; }
}
if (!function_exists('str_ends_with')) {
function str_ends_with(string $haystack, string $needle): bool {
if ($needle === '') return true;
$length = strlen($needle);
return $length <= strlen($haystack) && substr($haystack, -$length) === $needle;
}
}
/**
* LORD rent credit (placeholder units) when rent/claim is ever wired for this crop.
* Placeholder — tune to a reality-scaled optimum later.
* Hub crop has no visitor ledger / mint path this ship; define only.
*/
const LORD_RENT_CREDIT = 10000;
/** AB hub domain tag: com | org | net (from Host header; default com). */
function nsp_hub_variant(): string {
$h = strtolower((string)($_SERVER['HTTP_HOST'] ?? ''));
$h = preg_replace('/:\d+$/', '', $h) ?? $h;
$h = preg_replace('/^www\./', '', $h) ?? $h;
if (str_contains($h, 'nosignup.org') || $h === 'org') return 'org';
if (str_contains($h, 'nosignup.net') || $h === 'net') return 'net';
return 'com';
}
function nsp_hub_label(): string {
$v = nsp_hub_variant();
return 'Nosignup.' . strtoupper($v === 'com' ? 'Com' : ($v === 'org' ? 'Org' : 'Net'));
}
/**
* Hardcoded website / sketch memory for center "?".
* Equal-weight random with product site URLs (see front-end pool).
* kind sketch → same origin ?sketch=id | kind url → external.
* Tack projects here — the PHP file is the repository.
*/
/**
* Legacy sketch catalog — retained for spoke memory / docs only.
* Center "?" is Omniteacher exclusively (no random HUB_POOL selection).
*/
function nsp_sketch_catalog(): array {
return [
['id' => 'teacher', 'kind' => 'sketch', 'label' => 'Omniteacher', 'href' => '?sketch=teacher'],
['id' => 'morse', 'kind' => 'sketch', 'label' => 'Omniteacher · Morse course', 'href' => '?sketch=morse'],
['id' => 'asl', 'kind' => 'sketch', 'label' => 'Omniteacher · ASL text course', 'href' => '?sketch=asl'],
['id' => 'train', 'kind' => 'sketch', 'label' => 'Omniteacher', 'href' => '?sketch=train'],
];
}
/** Sister product sites (pinwheel spokes). Not used as a center random pool. */
function nsp_product_pool(): array {
return [
['id' => 'trade', 'kind' => 'url', 'label' => 'Trade (Trade)', 'href' => '//nosignup.trade/'],
['id' => 'chat', 'kind' => 'url', 'label' => 'Chat (Comms)', 'href' => '//nosignup.chat/'],
['id' => 'work', 'kind' => 'url', 'label' => 'Work (Jobs)', 'href' => '//nosignup.work/'],
['id' => 'market', 'kind' => 'url', 'label' => 'Market (Classifieds)', 'href' => '//nosignup.market/'],
['id' => 'date', 'kind' => 'url', 'label' => 'Dating (Personals)', 'href' => '//nosignup.date/'],
['id' => 'fun', 'kind' => 'url', 'label' => 'Fun (Media)', 'href' => '//nosignup.fun/'],
['id' => 'info', 'kind' => 'url', 'label' => 'Info (Oracle)', 'href' => '//nosignup.info/'],
];
}
/** @deprecated Center does not randomize. Kept empty so any residual HUB_POOL reference is inert. */
function nsp_hub_random_pool(): array {
return [];
}
/* ---- SITE-LOCAL CONTROL PANEL (renter key; not OS root) ---- */
function nsp_vault_dir(): string {
/* Sibling vault is optional; one public_html is enough. Local fallback is ./vault. */
$sib = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'vault';
$loc = __DIR__ . DIRECTORY_SEPARATOR . 'vault';
foreach ([$sib, $loc] as $d) {
if (is_dir($d) || @mkdir($d, 0700, true)) {
if (is_dir($d) && is_writable($d)) return $d;
}
}
return $sib;
}
function nsp_data_dir(): string {
/* Disk fallback is ./data. Sibling vault is optional; one public_html is enough. */
$d = __DIR__ . DIRECTORY_SEPARATOR . 'data';
if (!is_dir($d)) @mkdir($d, 0755, true);
$ht = $d . DIRECTORY_SEPARATOR . '.htaccess';
if (!is_file($ht)) @file_put_contents($ht, "Require all denied\nDeny from all\n");
return $d;
}
/** One RAM worktable. Morse stays one-file on DISK. Fallback data/ephemeral (not a third root). */
function nsp_ram_dir(): string {
if (DIRECTORY_SEPARATOR === '/' && is_dir('/dev/shm') && is_writable('/dev/shm')) {
$d = '/dev/shm/nosignup_map';
if ((is_dir($d) || @mkdir($d, 0700, true)) && is_writable($d)) return $d;
}
$d = nsp_data_dir() . DIRECTORY_SEPARATOR . 'ephemeral';
if (!is_dir($d)) @mkdir($d, 0700, true);
/* not-ok if returned dir is missing or not writable — ns_worktable_banner reports it */
return $d;
}
function ns_real_ram_root(): string {
return '/dev/shm/nosignup_map';
}
function ns_real_ram_ok(): bool {
/* REAL RAM only. Disk fallback may still run; the RAM banner still shows. */
if (DIRECTORY_SEPARATOR !== '/' || !is_dir('/dev/shm') || !@is_writable('/dev/shm')) return false;
$d = ns_real_ram_root();
if (!is_dir($d)) @mkdir($d, 0700, true);
return is_dir($d) && @is_writable($d);
}
/** Worktable health. REAL /dev/shm root and ./data independently. Fail-open. Loud banners. */
function ns_worktable_problems(): array {
$disk = nsp_data_dir();
if ($disk !== '' && !is_dir($disk)) @mkdir($disk, 0755, true);
$out = [];
if (!ns_real_ram_ok()) $out[] = ['kind' => 'ram', 'msg' => 'No RAM folder. This host has no memory worktable.', 'path' => ns_real_ram_root()];
if (!ns_worktable_folder_ok($disk)) $out[] = ['kind' => 'disk', 'msg' => 'No disk folder. This host cannot save.', 'path' => $disk];
return $out;
}
function ns_worktable_folder_ok(string $d): bool {
return $d !== '' && is_dir($d) && @is_writable($d);
}
function ns_worktable_banner(bool $public = true): string {
$probs = ns_worktable_problems();
if (!$probs) return '';
$lines = '';
foreach ($probs as $p) {
$extra = '';
if (!$public && !empty($p['path'])) {
$extra = ' ' . htmlspecialchars((string)$p['path'], ENT_QUOTES) . '';
}
$lines .= '
' . htmlspecialchars((string)$p['msg'], ENT_QUOTES) . $extra . '
';
}
return ''
. '' . $lines . '
';
}
/** Deny /data/ or /vault/ if a request path already hit this php file. No rewrite required. */
function nsp_reject_private_http_path(): void {
if (PHP_SAPI === 'cli') return;
$uri = (string)($_SERVER['REQUEST_URI'] ?? '');
$path = (string)(@parse_url($uri, PHP_URL_PATH) ?? '');
for ($i = 0; $i < 2; $i++) {
$decoded = rawurldecode($path);
if ($decoded === $path) break;
$path = $decoded;
}
$path = str_replace('\\', '/', $path);
if (strpos($path, "\0") !== false || preg_match('~(?:^|/)(?:vault|data)(?:/|$)~i', $path)) {
http_response_code(404);
header('Content-Type: text/plain; charset=UTF-8');
header('Cache-Control: private, no-store, max-age=0, must-revalidate');
echo "Not found\n";
exit;
}
}
nsp_reject_private_http_path();
nsp_data_dir();
nsp_ram_dir();
function nsp_hash_file(): string { return nsp_data_dir() . DIRECTORY_SEPARATOR . 'admin.pass.hash'; }
function nsp_seed_file(): string { return nsp_data_dir() . DIRECTORY_SEPARATOR . 'site.seed'; }
function nsp_norm_seed(string $s): string {
return strtolower(trim(preg_replace('/\s+/', ' ', $s) ?? ''));
}
/** Identity surface only (same scheme as trade); hub has no visitor spend ledger.
* LEFT IN PLACE — economy+identity telomere IDENTITY slot points here (owner panel, not visitor mill). */
function nsp_addr_from_seed(string $seed): string {
return hash('sha256', 'nsu-addr-v1|' . nsp_norm_seed($seed));
}
/**
* 12-word site seed (not BIP39). CSPRNG into fixed word pool.
* Panel unlock for THIS host only — not a faucet mint (hub has no visitor wallet).
*/
function nsp_generate_site_seed(): string {
static $wl = [
'able','acid','aged','also','aqua','arch','area','army','atom','aunt','auto','avoid',
'axis','baby','band','bank','bare','barn','base','bean','bear','belt','bike','bind',
'bird','bite','blue','boat','body','bold','bolt','bone','book','boot','born','bowl',
'brass','brave','bread','brick','brief','bring','broad','broke','brown','brush','build','bulk',
'burn','burst','bush','busy','cable','cage','cake','calm','camp','cane','cape','card',
'care','cart','case','cash','cast','cave','cell','cent','chat','chef','chin','chip',
'city','clap','clay','clip','club','coal','coat','code','coil','coin','cold','come',
'cook','cool','cope','copy','cord','core','corn','cost','cove','crab','crew','crop',
'crow','cube','cult','curb','cure','curl','dark','dart','dash','data','dawn','deal',
'dear','deck','deep','deer','desk','dial','dice','diet','dine','dirt','disc','dock',
'dome','done','door','dose','down','draw','drip','drop','drum','dual','duck','dune',
'dusk','dust','duty','each','earn','east','easy','echo','edge','edit','else','emit',
'epic','even','ever','evil','exit','face','fact','fade','fail','fair','fall','fame',
'farm','fast','fate','fear','feed','feel','fern','file','fill','film','find','fine',
'fire','firm','fish','flag','flat','flee','flip','flow','foam','foil','fold','font',
'food','fool','foot','ford','fork','form','fort','foul','four','free','frog','from',
'fuel','full','fund','fuse','gain','game','gate','gear','gene','gift','girl','give',
'glad','glow','glue','goal','goat','gold','golf','good','grab','grad','gram','gray',
'grid','grim','grin','grip','grow','gulf','guru','hail','hair','half','hall','hand',
'hang','hard','harm','harp','hate','have','hawk','haze','head','heal','heap','heat',
'heed','heel','held','help','herb','here','hero','hide','high','hill','hint','hire',
'hold','hole','home','hood','hook','hope','horn','host','hour','huge','hull','hung',
'hunt','hurt','icon','idea','idle','inch','info','into','iron','item','jade','jail',
'jazz','join','joke','jump','june','jury','just','keen','keep','kept','kick','kind',
'king','kite','knee','knew','knit','knot','know','lace','lack','lady','lake','lamp',
'land','lane','last','late','lava','lawn','lead','leaf','lean','left','lend','lens',
];
$n = count($wl);
$bytes = random_bytes(12);
$out = [];
for ($i = 0; $i < 12; $i++) {
$out[] = $wl[ord($bytes[$i]) % $n];
}
return implode(' ', $out);
}
/** Owner-only vault note: site seed = panel unlock for THIS host. Never to renters. */
function nsp_vault_site_seed_note(string $seed): void {
$d = nsp_vault_dir();
if (!is_dir($d) && !@mkdir($d, 0700, true)) {
return;
}
@chmod($d, 0700);
$v = nsp_hub_variant();
$body = 'NOSIGNUP.' . strtoupper($v) . " HUB SITE WALLET SEED (OWNER ONLY)\n"
. "This seed unlocks /controlpanel for THIS host only (com/org/net each own vault+data).\n"
. "Hub has no visitor ledger or mint on this crop. Money/buy: nosignup.trade. NO RECOVERY.\n"
. "Renters must NOT receive this secret.\n"
. "Generated: " . gmdate('c') . "\n\n"
. trim($seed) . "\n";
@file_put_contents($d . DIRECTORY_SEPARATOR . 'SITE_WALLET_SEED.txt', $body, LOCK_EX);
@chmod($d . DIRECTORY_SEPARATOR . 'SITE_WALLET_SEED.txt', 0600);
}
/** True if $seed matches data/site.seed (normalized). */
function nsp_panel_seed_ok(string $seed): bool {
$seed = nsp_norm_seed($seed);
if ($seed === '') {
return false;
}
$path = nsp_seed_file();
if (!is_file($path)) {
return false;
}
$have = nsp_norm_seed((string)@file_get_contents($path));
if ($have === '') {
return false;
}
return hash_equals($have, $seed);
}
/**
* Ensure data/site.seed exists; mirror to vault SITE_WALLET_SEED.txt on first write.
* Idempotent. Call before admin API so setup is never land-grabable.
* NOT a treasury faucet mint — hub has no visitor ledger this crop.
*/
function nsp_ensure_site_seed(): void {
nsp_pass_burn();
$path = nsp_seed_file();
if (is_file($path) && nsp_norm_seed((string)@file_get_contents($path)) !== '') {
$vd = nsp_vault_dir();
$note = $vd . DIRECTORY_SEPARATOR . 'SITE_WALLET_SEED.txt';
if (!is_file($note) || trim((string)@file_get_contents($note)) === '') {
nsp_vault_site_seed_note(nsp_norm_seed((string)@file_get_contents($path)));
}
return;
}
$seed = nsp_generate_site_seed();
if (@file_put_contents($path, $seed . "\n", LOCK_EX) === false) return;
@chmod($path, 0600);
nsp_vault_site_seed_note($seed);
}
function nsp_json(array $x, int $c = 200): void {
http_response_code($c);
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store');
echo json_encode($x, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
/* ---- MORSE-v2 organism telemetry (ethos §11–12; NOT accounts) ---- */
const NSP_MORSE_MAX_POST = 1048576;
const NSP_MORSE_MASTERY_EXPOSURE_BUDGET = 50;
const NSP_MORSE_MASTERY_ATTEMPT_BUDGET = 12;
const NSP_MORSE_MASTERY_WINDOW = 5;
const NSP_MORSE_MASTERY_REQUIRED = 4;
const NSP_MORSE_MASTERY_MAX_MEDIAN_MS = 2500;
const NSP_MORSE_MAX_RECENT = 800;
function nsp_morse_data_dir(): string {
$d = nsp_data_dir() . DIRECTORY_SEPARATOR . 'morse';
if (!is_dir($d)) {
@mkdir($d, 0700, true);
}
if (is_dir($d)) {
@chmod($d, 0700);
$ht = $d . DIRECTORY_SEPARATOR . '.htaccess';
if (!is_file($ht)) {
@file_put_contents($ht, "Require all denied\nDeny from all\n", LOCK_EX);
}
$idx = $d . DIRECTORY_SEPARATOR . 'index.html';
if (!is_file($idx)) {
@file_put_contents($idx, "", LOCK_EX);
}
}
return $d;
}
function nsp_morse_salt(): string {
$f = nsp_morse_data_dir() . DIRECTORY_SEPARATOR . '.salt';
if (!is_file($f)) {
@file_put_contents($f, bin2hex(random_bytes(32)), LOCK_EX);
@chmod($f, 0600);
}
$s = trim((string)@file_get_contents($f));
if ($s === '') {
$s = bin2hex(random_bytes(32));
@file_put_contents($f, $s, LOCK_EX);
@chmod($f, 0600);
}
return $s;
}
/** HMAC(IP|UA, salt) — raw IP never written. */
function nsp_morse_visitor_hash(): string {
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
return substr(hash_hmac('sha256', $ip . '|' . $ua, nsp_morse_salt()), 0, 32);
}
function nsp_morse_ua_hash(): string {
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
return substr(hash_hmac('sha256', $ua, nsp_morse_salt()), 0, 16);
}
function nsp_morse_clean($v, int $max = 200): string {
$s = is_scalar($v) ? (string)$v : '';
$s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $s) ?? '';
return substr($s, 0, $max);
}
function nsp_morse_rows_file(): string {
return nsp_morse_data_dir() . DIRECTORY_SEPARATOR . 'visitor_stats.jsonl';
}
function nsp_morse_read_rows($fh): array {
rewind($fh);
$rows = [];
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\r\n");
if ($line === '' || !str_contains($line, "\t")) continue;
[$id, $json] = explode("\t", $line, 2);
$id = preg_replace('/[^a-f0-9]/', '', $id) ?? '';
if ($id === '') continue;
$decoded = json_decode($json, true);
if (is_array($decoded)) $rows[$id] = $decoded;
}
return $rows;
}
/** Drop Morse visitor_stats rows whose updatedAt is older than 32 days. Hashed id only. */
function nsp_morse_prune_stale_rows(array $rows): array {
$cutoff = time() - (32 * 86400);
$kept = [];
foreach ($rows as $id => $row) {
if (!is_array($row)) continue;
$at = (string)($row['updatedAt'] ?? '');
$ts = $at !== '' ? strtotime($at) : false;
if (!is_int($ts) || $ts < $cutoff) continue;
$kept[$id] = $row;
}
return $kept;
}
function nsp_morse_write_rows($fh, array $rows): void {
ftruncate($fh, 0);
rewind($fh);
foreach ($rows as $id => $row) {
$id = preg_replace('/[^a-f0-9]/', '', (string)$id) ?? '';
if ($id === '') continue;
fwrite($fh, $id . "\t" . json_encode($row, JSON_UNESCAPED_SLASHES) . "\n");
}
fflush($fh);
@chmod(nsp_morse_rows_file(), 0600);
}
/**
* @param bool $hard if true, API-style 500 exit on open/write failure; if false, return null (fail-open for page render).
*/
function nsp_morse_with_rows(callable $fn, bool $hard = true) {
nsp_morse_data_dir();
$fh = @fopen(nsp_morse_rows_file(), 'c+');
if (!$fh) {
if ($hard) nsp_json(['ok' => false, 'err' => 'morse stats open fail'], 500);
return null;
}
flock($fh, LOCK_EX);
try {
$rows = nsp_morse_read_rows($fh);
$result = $fn($rows);
if (is_array($result) && array_key_exists('__rows', $result)) {
nsp_morse_write_rows($fh, nsp_morse_prune_stale_rows($result['__rows']));
unset($result['__rows']);
}
flock($fh, LOCK_UN);
fclose($fh);
return $result;
} catch (Throwable $e) {
flock($fh, LOCK_UN);
fclose($fh);
if ($hard) nsp_json(['ok' => false, 'err' => 'morse stats write fail'], 500);
return null;
}
}
function nsp_morse_safe_snapshot(array $s): array {
return [
'assignedCondition' => nsp_morse_clean($s['assignedCondition'] ?? '', 8),
'morseWpm' => max(5, min(40, (float)($s['morseWpm'] ?? 18))),
'spinEnabled' => !empty($s['spinEnabled']),
'spinSweepEnabled' => !empty($s['spinSweepEnabled']),
'binauralEnabled' => !empty($s['binauralEnabled']),
'spreadSweepEnabled' => !empty($s['spreadSweepEnabled']),
'lowOscillatorEnabled' => !empty($s['lowOscillatorEnabled']),
'lowOscillatorSweepEnabled' => !empty($s['lowOscillatorSweepEnabled']),
'spinHz' => round(max(0, min(20, (float)($s['spinHz'] ?? 0))), 3),
'spreadHz' => round(max(0, min(40, (float)($s['spreadHz'] ?? 0))), 3),
'lowOscHz' => round(max(0, min(20, (float)($s['lowOscHz'] ?? 0))), 3),
'fieldVolume' => round(max(0, min(100, (float)($s['fieldVolume'] ?? 0))), 3),
'movementDepth' => round(max(0, min(100, (float)($s['movementDepth'] ?? 0))), 3),
];
}
function nsp_morse_safe_event(array $e): ?array {
$event = nsp_morse_clean($e['event'] ?? '', 40);
if (!in_array($event, ['heard', 'test-answer', 'mastery'], true)) return null;
$char = strtolower(nsp_morse_clean($e['char'] ?? '', 1));
if (!preg_match('/^[a-z0-9]$/', $char)) return null;
$out = [
'at' => nsp_morse_clean($e['at'] ?? '', 40),
'event' => $event,
'mode' => nsp_morse_clean($e['mode'] ?? '', 20),
'char' => $char,
'snapshot' => nsp_morse_safe_snapshot(is_array($e['snapshot'] ?? null) ? $e['snapshot'] : []),
];
if ($event === 'test-answer') {
$answer = strtolower(nsp_morse_clean($e['answer'] ?? '', 1));
$out['answer'] = preg_match('/^[a-z0-9]$/', $answer) ? $answer : '';
$out['correct'] = !empty($e['correct']);
$out['responseMs'] = max(0, min(600000, (int)($e['responseMs'] ?? 0)));
}
if ($event === 'mastery') {
$out['exposuresToMastery'] = max(0, min(100000, (int)($e['exposuresToMastery'] ?? 0)));
$out['attemptsToMastery'] = max(0, min(100000, (int)($e['attemptsToMastery'] ?? 0)));
$out['msToMastery'] = max(0, min(31536000000, (int)($e['msToMastery'] ?? 0)));
$out['medianResponseMsAtMastery'] = max(0, min(600000, (int)($e['medianResponseMsAtMastery'] ?? 0)));
$out['successWithinBudget'] = !empty($e['successWithinBudget']);
}
return $out;
}
function nsp_morse_safe_chars(array $stats): array {
$chars = is_array($stats['chars'] ?? null) ? $stats['chars'] : [];
$out = [];
foreach ($chars as $char => $c) {
$char = strtolower(nsp_morse_clean($char, 1));
if (!preg_match('/^[a-z0-9]$/', $char) || !is_array($c)) continue;
$heard = max(0, min(1000000, (int)($c['heard'] ?? 0)));
$testAttempts = max(0, min(1000000, (int)($c['testAttempts'] ?? 0)));
$mastered = !empty($c['mastered']);
$out[$char] = [
'heard' => $heard,
'practiceHeard' => max(0, min(1000000, (int)($c['practiceHeard'] ?? 0))),
'testAttempts' => $testAttempts,
'correct' => max(0, min(1000000, (int)($c['correct'] ?? 0))),
'wrong' => max(0, min(1000000, (int)($c['wrong'] ?? 0))),
'mastered' => $mastered,
'eligibleForSuccess' => $mastered || $heard >= NSP_MORSE_MASTERY_EXPOSURE_BUDGET || $testAttempts >= NSP_MORSE_MASTERY_ATTEMPT_BUDGET,
'successWithinBudget' => !empty($c['successWithinBudget']),
'exposuresToMastery' => $mastered ? max(0, min(1000000, (int)($c['exposuresToMastery'] ?? 0))) : null,
'attemptsToMastery' => $mastered ? max(0, min(1000000, (int)($c['attemptsToMastery'] ?? 0))) : null,
'msToMastery' => $mastered ? max(0, min(31536000000, (int)($c['msToMastery'] ?? 0))) : null,
'medianResponseMsAtMastery' => $mastered ? max(0, min(600000, (int)($c['medianResponseMsAtMastery'] ?? 0))) : null,
'masteryCondition' => nsp_morse_clean($c['masteryCondition'] ?? ($stats['assignedCondition'] ?? ''), 8),
];
}
return $out;
}
function nsp_morse_safe_payload(array $payload): array {
$stats = is_array($payload['stats'] ?? null) ? $payload['stats'] : [];
$recent = is_array($stats['recent'] ?? null) ? $stats['recent'] : [];
$safeRecent = [];
foreach (array_slice($recent, -NSP_MORSE_MAX_RECENT) as $e) {
if (is_array($e)) {
$safe = nsp_morse_safe_event($e);
if ($safe) $safeRecent[] = $safe;
}
}
return [
'clientVersion' => nsp_morse_clean($payload['clientVersion'] ?? 'hub-morse-v2', 40),
'event' => nsp_morse_clean($payload['event'] ?? '', 40),
'assignedCondition' => nsp_morse_clean($payload['assignedCondition'] ?? '', 8),
'localCreatedAt' => nsp_morse_clean($stats['createdAt'] ?? '', 40),
'localUpdatedAt' => nsp_morse_clean($stats['updatedAt'] ?? '', 40),
'totals' => [
'totalHeard' => max(0, (int)($stats['totalHeard'] ?? 0)),
'testPrompts' => max(0, (int)($stats['testPrompts'] ?? 0)),
'testAttempts' => max(0, (int)($stats['testAttempts'] ?? 0)),
'testCorrect' => max(0, (int)($stats['testCorrect'] ?? 0)),
'testWrong' => max(0, (int)($stats['testWrong'] ?? 0)),
],
'charSummaries' => nsp_morse_safe_chars($stats),
'recent' => $safeRecent,
];
}
function nsp_morse_save_telemetry(): void {
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
/* RR361 P1-HUB-TELEMETRY-JSON-PLAIN: visitor plain English (not bare invalid json / payload too large) */
if ($len > NSP_MORSE_MAX_POST) nsp_json(['ok' => false, 'err' => 'Telemetry body is too large — send a smaller JSON update'], 413);
$raw = file_get_contents('php://input');
$payload = json_decode((string)$raw, true);
if (!is_array($payload)) nsp_json(['ok' => false, 'err' => 'Telemetry body must be JSON (object)'], 400);
/* Omniteacher privacy: progression is local; aggregate telemetry is explicit opt-in only. */
if (empty($payload['opt_in'])) {
nsp_json(['ok' => true, 'stored' => false, 'note' => 'telemetry opt-in required; local Omniteacher progress is enough']);
}
$visitor = nsp_morse_visitor_hash();
$row = [
'visitor' => $visitor,
'updatedAt' => gmdate('c'),
'uaHash' => nsp_morse_ua_hash(),
'payload' => nsp_morse_safe_payload($payload),
];
// Explicit: never store raw IP or UA string
unset($row['ip'], $row['remote_addr'], $row['user_agent']);
$result = nsp_morse_with_rows(function (array $rows) use ($visitor, $row) {
$rows[$visitor] = $row; // ONE latest row per hash (non-accumulation)
return ['__rows' => $rows, 'count' => count($rows)];
}, false);
if (!is_array($result)) nsp_json(['ok' => false, 'err' => 'Telemetry skipped (no disk)'], 200);
nsp_json(['ok' => true, 'visitorHash' => $visitor, 'storedVisitors' => $result['count'] ?? null]);
}
function nsp_morse_bucket_num(float $value, float $step): string {
if ($value <= 0) return 'off';
$rounded = round($value / $step) * $step;
return number_format($rounded, $step < 1 ? 1 : 0, '.', '');
}
function nsp_morse_median(array $values): ?float {
$values = array_values(array_filter($values, 'is_numeric'));
$n = count($values);
if ($n === 0) return null;
sort($values, SORT_NUMERIC);
$mid = intdiv($n, 2);
return $n % 2 === 1 ? (float)$values[$mid] : (((float)$values[$mid - 1] + (float)$values[$mid]) / 2);
}
function nsp_morse_aggregate(array $rows): array {
$summary = [
'visitors' => count($rows),
'heard' => 0,
'testAttempts' => 0,
'testCorrect' => 0,
'masteredChars' => 0,
'successWithinBudget' => 0,
'conditions' => [],
'note' => 'Anonymous aggregate by audio profile — learning-speed research, not identity.',
];
foreach ($rows as $row) {
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
$recent = is_array($payload['recent'] ?? null) ? $payload['recent'] : [];
$charSummaries = is_array($payload['charSummaries'] ?? null) ? $payload['charSummaries'] : [];
$rowCond = nsp_morse_clean($payload['assignedCondition'] ?? '?', 8) ?: '?';
if (!isset($summary['conditions'][$rowCond])) {
$summary['conditions'][$rowCond] = [
'heard' => 0, 'attempts' => 0, 'correct' => 0,
'masteredChars' => 0, 'successWithinBudget' => 0,
'_exposures' => [],
];
}
foreach ($recent as $e) {
if (!is_array($e)) continue;
$snap = is_array($e['snapshot'] ?? null) ? $e['snapshot'] : [];
$cond = nsp_morse_clean($snap['assignedCondition'] ?? $rowCond, 8) ?: $rowCond;
if (!isset($summary['conditions'][$cond])) {
$summary['conditions'][$cond] = [
'heard' => 0, 'attempts' => 0, 'correct' => 0,
'masteredChars' => 0, 'successWithinBudget' => 0,
'_exposures' => [],
];
}
if (($e['event'] ?? '') === 'heard') {
$summary['heard']++;
$summary['conditions'][$cond]['heard']++;
}
if (($e['event'] ?? '') === 'test-answer') {
$summary['testAttempts']++;
$summary['conditions'][$cond]['attempts']++;
if (!empty($e['correct'])) {
$summary['testCorrect']++;
$summary['conditions'][$cond]['correct']++;
}
}
}
foreach ($charSummaries as $c) {
if (!is_array($c)) continue;
$cond = nsp_morse_clean($c['masteryCondition'] ?? $rowCond, 8) ?: $rowCond;
if (!isset($summary['conditions'][$cond])) {
$summary['conditions'][$cond] = [
'heard' => 0, 'attempts' => 0, 'correct' => 0,
'masteredChars' => 0, 'successWithinBudget' => 0,
'_exposures' => [],
];
}
if (!empty($c['mastered'])) {
$summary['masteredChars']++;
$summary['conditions'][$cond]['masteredChars']++;
$ex = (int)($c['exposuresToMastery'] ?? 0);
if ($ex > 0) $summary['conditions'][$cond]['_exposures'][] = $ex;
}
if (!empty($c['successWithinBudget'])) {
$summary['successWithinBudget']++;
$summary['conditions'][$cond]['successWithinBudget']++;
}
}
}
$bestSpeed = null;
foreach ($summary['conditions'] as $name => &$c) {
$c['medianExposuresToMastery'] = nsp_morse_median($c['_exposures']);
unset($c['_exposures']);
if ($c['masteredChars'] >= 1 && $c['medianExposuresToMastery'] !== null) {
if (!$bestSpeed || $c['medianExposuresToMastery'] < $bestSpeed['medianExposuresToMastery']) {
$bestSpeed = ['condition' => $name, 'medianExposuresToMastery' => $c['medianExposuresToMastery']];
}
}
}
unset($c);
$summary['bestSpeed'] = $bestSpeed;
return $summary;
}
function nsp_morse_summary_api(): void {
$f = __DIR__ . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'morse' . DIRECTORY_SEPARATOR . 'visitor_stats.jsonl';
if (!is_file($f)) {
nsp_json(['ok' => true, 'aggregate' => nsp_morse_aggregate([])]);
return;
}
$agg = nsp_morse_with_rows(function (array $rows) {
return nsp_morse_aggregate($rows);
});
nsp_json(['ok' => true, 'aggregate' => $agg]);
}
/** Per-visit Audio Profile A–E biasing field sweep bands (ref-v3). */
function nsp_morse_random_condition(array $summary = []): array {
$base = [
['id' => 'A', 'spinMinHz' => 0.5, 'spinMaxHz' => 1.5, 'spreadMaxHz' => 12, 'lowOscMinHz' => 0.5, 'lowOscMaxHz' => 2.0, 'fieldVolume' => 4, 'movementDepth' => 55],
['id' => 'B', 'spinMinHz' => 1.5, 'spinMaxHz' => 2.5, 'spreadMaxHz' => 16, 'lowOscMinHz' => 1.0, 'lowOscMaxHz' => 3.0, 'fieldVolume' => 5, 'movementDepth' => 65],
['id' => 'C', 'spinMinHz' => 2.5, 'spinMaxHz' => 3.5, 'spreadMaxHz' => 20, 'lowOscMinHz' => 2.0, 'lowOscMaxHz' => 4.0, 'fieldVolume' => 5, 'movementDepth' => 70],
['id' => 'D', 'spinMinHz' => 0.5, 'spinMaxHz' => 4.0, 'spreadMaxHz' => 20, 'lowOscMinHz' => 0.5, 'lowOscMaxHz' => 4.0, 'fieldVolume' => 5, 'movementDepth' => 70],
['id' => 'E', 'spinMinHz' => 3.0, 'spinMaxHz' => 4.0, 'spreadMaxHz' => 20, 'lowOscMinHz' => 3.0, 'lowOscMaxHz' => 4.0, 'fieldVolume' => 6, 'movementDepth' => 75],
];
$best = is_array($summary['bestSpeed'] ?? null) ? (string)($summary['bestSpeed']['condition'] ?? '') : '';
$chosen = null;
if ($best !== '' && random_int(1, 100) <= 55) {
foreach ($base as $candidate) {
if ($candidate['id'] === $best) { $chosen = $candidate; break; }
}
}
if (!$chosen) $chosen = $base[random_int(0, count($base) - 1)];
$chosen['spinMinHz'] = max(0.5, min(4.0, $chosen['spinMinHz'] + random_int(-10, 10) / 100));
$chosen['spinMaxHz'] = max($chosen['spinMinHz'] + 0.2, min(4.0, $chosen['spinMaxHz'] + random_int(-10, 10) / 100));
$chosen['spreadMaxHz'] = max(6, min(24, $chosen['spreadMaxHz'] + random_int(-10, 10) / 10));
$chosen['lowOscMinHz'] = max(0.5, min(4.0, $chosen['lowOscMinHz'] + random_int(-10, 10) / 100));
$chosen['lowOscMaxHz'] = max($chosen['lowOscMinHz'] + 0.2, min(4.0, $chosen['lowOscMaxHz'] + random_int(-10, 10) / 100));
$chosen['fieldVolume'] = max(2, min(10, $chosen['fieldVolume'] + random_int(-1, 1)));
$chosen['movementDepth'] = max(35, min(90, $chosen['movementDepth'] + random_int(-5, 5)));
$chosen['token'] = bin2hex(random_bytes(12));
$chosen['label'] = 'Audio Profile ' . $chosen['id'];
return $chosen;
}
/* Omniteacher — MAP center ? face (universal courses; Morse remains one course).
Telemetry is opt-in only. Local progress key: ns_omniteacher_v1.
Legacy Morse aggregate helpers below remain for optional opt-in / summary.
*/
const NSM21_APP_VERSION = '2.1.0';
const OMNITEACHER_VERSION = '1.0.0';
const MAX_POST_BYTES = 1048576;
const MAX_RECENT_EVENTS = 2000;
const MASTERY_EXPOSURE_BUDGET = 50;
const MASTERY_ATTEMPT_BUDGET = 12;
const MASTERY_WINDOW = 5;
const MASTERY_REQUIRED_CORRECT = 4;
const MASTERY_MAX_MEDIAN_RESPONSE_MS = 2500;
function ensure_data_dir(): void {
if (!is_dir(nsp_morse_data_dir())) {
@mkdir(nsp_morse_data_dir(), 0700, true);
}
}
function app_salt(): string {
ensure_data_dir();
if (!is_file((nsp_morse_data_dir().'/.salt'))) {
@file_put_contents((nsp_morse_data_dir().'/.salt'), bin2hex(random_bytes(32)), LOCK_EX);
@chmod((nsp_morse_data_dir().'/.salt'), 0600);
}
$salt = trim((string)@file_get_contents((nsp_morse_data_dir().'/.salt')));
if ($salt === '') {
$salt = bin2hex(random_bytes(32));
@file_put_contents((nsp_morse_data_dir().'/.salt'), $salt, LOCK_EX);
@chmod((nsp_morse_data_dir().'/.salt'), 0600);
}
return $salt;
}
function client_ip(): string {
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
function visitor_hash(): string {
return substr(hash_hmac('sha256', client_ip(), app_salt()), 0, 32);
}
function user_agent_hash(): string {
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
return substr(hash_hmac('sha256', $ua, app_salt()), 0, 16);
}
function json_response(array $data, int $status = 200): never {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($data, JSON_UNESCAPED_SLASHES);
exit;
}
function clean_scalar($value, int $maxLen = 200): string {
$s = is_scalar($value) ? (string)$value : '';
$s = preg_replace('/[^\P{C}\t\r\n]/u', '', $s) ?? '';
return mb_substr($s, 0, $maxLen);
}
function read_rows_locked($fh): array {
rewind($fh);
$rows = [];
while (($line = fgets($fh)) !== false) {
$line = rtrim($line, "\r\n");
if ($line === '' || !str_contains($line, "\t")) {
continue;
}
[$id, $json] = explode("\t", $line, 2);
$id = preg_replace('/[^a-f0-9]/', '', $id) ?? '';
if ($id === '') {
continue;
}
$decoded = json_decode($json, true);
if (is_array($decoded)) {
$rows[$id] = $decoded;
}
}
return $rows;
}
function write_rows_locked($fh, array $rows): void {
ftruncate($fh, 0);
rewind($fh);
foreach ($rows as $id => $row) {
$id = preg_replace('/[^a-f0-9]/', '', (string)$id) ?? '';
if ($id === '') {
continue;
}
fwrite($fh, $id . "\t" . json_encode($row, JSON_UNESCAPED_SLASHES) . "\n");
}
fflush($fh);
}
function with_rows_lock(callable $fn) {
ensure_data_dir();
$fh = @fopen((nsp_morse_data_dir().'/visitor_stats.jsonl'), 'c+');
if (!$fh) {
json_response(['ok' => false, 'error' => 'Stats skipped (no disk).'], 200);
}
flock($fh, LOCK_EX);
try {
$rows = read_rows_locked($fh);
$result = $fn($rows);
if (is_array($result) && array_key_exists('__rows', $result)) {
write_rows_locked($fh, $result['__rows']);
unset($result['__rows']);
}
flock($fh, LOCK_UN);
fclose($fh);
return $result;
} catch (Throwable $e) {
flock($fh, LOCK_UN);
fclose($fh);
json_response(['ok' => false, 'error' => 'Stats skipped (no disk).'], 200);
}
}
function safe_snapshot(array $s): array {
return [
'assignedCondition' => clean_scalar($s['assignedCondition'] ?? '', 64),
'morseWpm' => (float)($s['morseWpm'] ?? 0),
'spinEnabled' => !empty($s['spinEnabled']),
'spinSweepEnabled' => !empty($s['spinSweepEnabled']),
'binauralEnabled' => !empty($s['binauralEnabled']),
'spreadSweepEnabled' => !empty($s['spreadSweepEnabled']),
'spinHz' => round((float)($s['spinHz'] ?? 0), 3),
'spreadHz' => round((float)($s['spreadHz'] ?? 0), 3),
'fieldVolume' => round((float)($s['fieldVolume'] ?? 0), 3),
'movementDepth' => round((float)($s['movementDepth'] ?? 0), 3),
];
}
function safe_event(array $e): ?array {
$event = clean_scalar($e['event'] ?? '', 40);
if (!in_array($event, ['heard', 'test-answer', 'mastery'], true)) {
return null;
}
$char = strtolower(clean_scalar($e['char'] ?? '', 1));
if (!preg_match('/^[a-z0-9]$/', $char)) {
return null;
}
$out = [
'at' => clean_scalar($e['at'] ?? '', 40),
'event' => $event,
'mode' => clean_scalar($e['mode'] ?? '', 20),
'char' => $char,
'snapshot' => safe_snapshot(is_array($e['snapshot'] ?? null) ? $e['snapshot'] : []),
];
if ($event === 'test-answer') {
$answer = strtolower(clean_scalar($e['answer'] ?? '', 1));
$out['answer'] = preg_match('/^[a-z0-9]$/', $answer) ? $answer : '';
$out['correct'] = !empty($e['correct']);
$out['responseMs'] = max(0, min(600000, (int)($e['responseMs'] ?? 0)));
}
if ($event === 'mastery') {
$out['exposuresToMastery'] = max(0, min(100000, (int)($e['exposuresToMastery'] ?? 0)));
$out['attemptsToMastery'] = max(0, min(100000, (int)($e['attemptsToMastery'] ?? 0)));
$out['msToMastery'] = max(0, min(31536000000, (int)($e['msToMastery'] ?? 0)));
$out['medianResponseMsAtMastery'] = max(0, min(600000, (int)($e['medianResponseMsAtMastery'] ?? 0)));
$out['successWithinBudget'] = !empty($e['successWithinBudget']);
}
return $out;
}
function safe_char_summaries(array $stats): array {
$chars = is_array($stats['chars'] ?? null) ? $stats['chars'] : [];
$out = [];
foreach ($chars as $char => $c) {
$char = strtolower(clean_scalar($char, 1));
if (!preg_match('/^[a-z0-9]$/', $char) || !is_array($c)) {
continue;
}
$heard = max(0, min(1000000, (int)($c['heard'] ?? 0)));
$testAttempts = max(0, min(1000000, (int)($c['testAttempts'] ?? 0)));
$mastered = !empty($c['mastered']);
$exposuresToMastery = $mastered ? max(0, min(1000000, (int)($c['exposuresToMastery'] ?? 0))) : null;
$attemptsToMastery = $mastered ? max(0, min(1000000, (int)($c['attemptsToMastery'] ?? 0))) : null;
$msToMastery = $mastered ? max(0, min(31536000000, (int)($c['msToMastery'] ?? 0))) : null;
$medianResponse = $mastered ? max(0, min(600000, (int)($c['medianResponseMsAtMastery'] ?? 0))) : null;
$out[$char] = [
'heard' => $heard,
'practiceHeard' => max(0, min(1000000, (int)($c['practiceHeard'] ?? 0))),
'testAttempts' => $testAttempts,
'correct' => max(0, min(1000000, (int)($c['correct'] ?? 0))),
'wrong' => max(0, min(1000000, (int)($c['wrong'] ?? 0))),
'mastered' => $mastered,
'eligibleForSuccess' => $mastered || $heard >= MASTERY_EXPOSURE_BUDGET || $testAttempts >= MASTERY_ATTEMPT_BUDGET,
'successWithinBudget' => !empty($c['successWithinBudget']),
'exposuresToMastery' => $exposuresToMastery,
'attemptsToMastery' => $attemptsToMastery,
'msToMastery' => $msToMastery,
'medianResponseMsAtMastery' => $medianResponse,
'masteryCondition' => clean_scalar($c['masteryCondition'] ?? ($stats['assignedCondition'] ?? ''), 64),
];
}
return $out;
}
function safe_payload(array $payload): array {
$stats = is_array($payload['stats'] ?? null) ? $payload['stats'] : [];
$recent = is_array($stats['recent'] ?? null) ? $stats['recent'] : [];
$safeRecent = [];
foreach (array_slice($recent, -MAX_RECENT_EVENTS) as $e) {
if (is_array($e)) {
$safe = safe_event($e);
if ($safe) {
$safeRecent[] = $safe;
}
}
}
return [
'clientVersion' => clean_scalar($payload['clientVersion'] ?? '', 40),
'event' => clean_scalar($payload['event'] ?? '', 40),
'assignedCondition' => clean_scalar($payload['assignedCondition'] ?? '', 64),
'localCreatedAt' => clean_scalar($stats['createdAt'] ?? '', 40),
'localUpdatedAt' => clean_scalar($stats['updatedAt'] ?? '', 40),
'totals' => [
'totalHeard' => max(0, (int)($stats['totalHeard'] ?? 0)),
'testPrompts' => max(0, (int)($stats['testPrompts'] ?? 0)),
'testAttempts' => max(0, (int)($stats['testAttempts'] ?? 0)),
'testCorrect' => max(0, (int)($stats['testCorrect'] ?? 0)),
'testWrong' => max(0, (int)($stats['testWrong'] ?? 0)),
],
'charSummaries' => safe_char_summaries($stats),
'recent' => $safeRecent,
];
}
function save_telemetry(): never {
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($len > MAX_POST_BYTES) {
json_response(['ok' => false, 'error' => 'Payload too large.'], 413);
}
$raw = file_get_contents('php://input');
$payload = json_decode((string)$raw, true);
if (!is_array($payload)) {
json_response(['ok' => false, 'error' => 'Invalid JSON.'], 400);
}
$visitor = visitor_hash();
$row = [
'visitor' => $visitor,
'updatedAt' => gmdate('c'),
'uaHash' => user_agent_hash(),
'payload' => safe_payload($payload),
];
$result = with_rows_lock(function (array $rows) use ($visitor, $row) {
$rows[$visitor] = $row;
return ['__rows' => $rows, 'count' => count($rows)];
});
json_response(['ok' => true, 'visitorHash' => $visitor, 'storedVisitors' => $result['count'] ?? null]);
}
function bucket_num(float $value, float $step): string {
if ($value <= 0) {
return 'off';
}
$rounded = round($value / $step) * $step;
return number_format($rounded, $step < 1 ? 1 : 0, '.', '');
}
function wilson_lower(int $correct, int $n): float {
if ($n <= 0) {
return 0.0;
}
$z = 1.96;
$phat = $correct / $n;
$den = 1 + ($z * $z / $n);
$centre = $phat + ($z * $z / (2 * $n));
$margin = $z * sqrt(($phat * (1 - $phat) + ($z * $z / (4 * $n))) / $n);
return ($centre - $margin) / $den;
}
function median_value(array $values): ?float {
$values = array_values(array_filter($values, fn($v) => is_numeric($v)));
$n = count($values);
if ($n === 0) {
return null;
}
sort($values, SORT_NUMERIC);
$mid = intdiv($n, 2);
if ($n % 2 === 1) {
return (float)$values[$mid];
}
return ((float)$values[$mid - 1] + (float)$values[$mid]) / 2;
}
function condition_shell(): array {
return [
'heard' => 0,
'attempts' => 0,
'correct' => 0,
'eligibleChars' => 0,
'masteredChars' => 0,
'successWithinBudget' => 0,
'successRate' => null,
'successScore' => 0,
'medianExposuresToMastery' => null,
'medianMsToMastery' => null,
'medianAttemptsToMastery' => null,
'learningSpeedScore' => null,
'_exposures' => [],
'_ms' => [],
'_attemptsToMastery' => [],
];
}
function aggregate_rows(array $rows): array {
$summary = [
'visitors' => count($rows),
'events' => 0,
'heard' => 0,
'testAttempts' => 0,
'testCorrect' => 0,
'eligibleChars' => 0,
'masteredChars' => 0,
'successWithinBudget' => 0,
'conditions' => [],
'spinBuckets' => [],
'spreadBuckets' => [],
'bestSuccess' => null,
'bestSpeed' => null,
'best' => null,
];
foreach ($rows as $row) {
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
$recent = is_array($payload['recent'] ?? null) ? $payload['recent'] : [];
$charSummaries = is_array($payload['charSummaries'] ?? null) ? $payload['charSummaries'] : [];
$rowCondition = clean_scalar($payload['assignedCondition'] ?? 'unknown', 64) ?: 'unknown';
foreach ($recent as $e) {
if (!is_array($e)) {
continue;
}
$summary['events']++;
$snap = is_array($e['snapshot'] ?? null) ? $e['snapshot'] : [];
$condition = clean_scalar($snap['assignedCondition'] ?? $rowCondition, 64) ?: 'unknown';
if (!isset($summary['conditions'][$condition])) {
$summary['conditions'][$condition] = condition_shell();
}
$spinBucket = bucket_num((float)($snap['spinHz'] ?? 0), 0.5);
$spreadBucket = bucket_num((float)($snap['spreadHz'] ?? 0), 2.0);
if (!isset($summary['spinBuckets'][$spinBucket])) {
$summary['spinBuckets'][$spinBucket] = ['heard' => 0, 'attempts' => 0, 'correct' => 0];
}
if (!isset($summary['spreadBuckets'][$spreadBucket])) {
$summary['spreadBuckets'][$spreadBucket] = ['heard' => 0, 'attempts' => 0, 'correct' => 0];
}
if (($e['event'] ?? '') === 'heard') {
$summary['heard']++;
$summary['conditions'][$condition]['heard']++;
$summary['spinBuckets'][$spinBucket]['heard']++;
$summary['spreadBuckets'][$spreadBucket]['heard']++;
}
if (($e['event'] ?? '') === 'test-answer') {
$summary['testAttempts']++;
$summary['conditions'][$condition]['attempts']++;
$summary['spinBuckets'][$spinBucket]['attempts']++;
$summary['spreadBuckets'][$spreadBucket]['attempts']++;
if (!empty($e['correct'])) {
$summary['testCorrect']++;
$summary['conditions'][$condition]['correct']++;
$summary['spinBuckets'][$spinBucket]['correct']++;
$summary['spreadBuckets'][$spreadBucket]['correct']++;
}
}
}
foreach ($charSummaries as $char => $c) {
if (!is_array($c)) {
continue;
}
$condition = clean_scalar($c['masteryCondition'] ?? $rowCondition, 64) ?: $rowCondition;
if (!isset($summary['conditions'][$condition])) {
$summary['conditions'][$condition] = condition_shell();
}
if (!empty($c['eligibleForSuccess'])) {
$summary['eligibleChars']++;
$summary['conditions'][$condition]['eligibleChars']++;
}
if (!empty($c['mastered'])) {
$summary['masteredChars']++;
$summary['conditions'][$condition]['masteredChars']++;
$exposures = (int)($c['exposuresToMastery'] ?? 0);
$ms = (int)($c['msToMastery'] ?? 0);
$attempts = (int)($c['attemptsToMastery'] ?? 0);
if ($exposures > 0) {
$summary['conditions'][$condition]['_exposures'][] = $exposures;
}
if ($ms > 0) {
$summary['conditions'][$condition]['_ms'][] = $ms;
}
if ($attempts > 0) {
$summary['conditions'][$condition]['_attemptsToMastery'][] = $attempts;
}
}
if (!empty($c['successWithinBudget'])) {
$summary['successWithinBudget']++;
$summary['conditions'][$condition]['successWithinBudget']++;
}
}
}
$bestSuccess = null;
$bestSpeed = null;
foreach ($summary['conditions'] as $name => &$c) {
$eligible = (int)$c['eligibleChars'];
$successes = (int)$c['successWithinBudget'];
$masteries = (int)$c['masteredChars'];
$c['successRate'] = $eligible > 0 ? round($successes / $eligible, 4) : null;
$c['successScore'] = $eligible >= 5 ? round(wilson_lower($successes, $eligible), 4) : 0;
$c['medianExposuresToMastery'] = median_value($c['_exposures']);
$c['medianMsToMastery'] = median_value($c['_ms']);
$c['medianAttemptsToMastery'] = median_value($c['_attemptsToMastery']);
$c['learningSpeedScore'] = $c['medianExposuresToMastery'] !== null ? round((float)$c['medianExposuresToMastery'], 2) : null;
if ($eligible >= 5 && (!$bestSuccess || $c['successScore'] > $bestSuccess['score'])) {
$bestSuccess = [
'condition' => $name,
'eligibleChars' => $eligible,
'successWithinBudget' => $successes,
'successRate' => $c['successRate'],
'score' => $c['successScore'],
];
}
if ($masteries >= 5 && $c['medianExposuresToMastery'] !== null && (!$bestSpeed || $c['medianExposuresToMastery'] < $bestSpeed['medianExposuresToMastery'])) {
$bestSpeed = [
'condition' => $name,
'masteredChars' => $masteries,
'medianExposuresToMastery' => round((float)$c['medianExposuresToMastery'], 2),
'medianMsToMastery' => $c['medianMsToMastery'] !== null ? (int)round((float)$c['medianMsToMastery']) : null,
'medianAttemptsToMastery' => $c['medianAttemptsToMastery'] !== null ? round((float)$c['medianAttemptsToMastery'], 2) : null,
];
}
unset($c['_exposures'], $c['_ms'], $c['_attemptsToMastery']);
}
unset($c);
$summary['bestSuccess'] = $bestSuccess;
$summary['bestSpeed'] = $bestSpeed;
$summary['best'] = $bestSuccess;
return $summary;
}
function random_condition(array $summary): array {
$baseConditions = [
[
'id' => 'A',
'spinMinHz' => 0.5,
'spinMaxHz' => 1.5,
'spreadMaxHz' => 12,
'fieldVolume' => 4,
'movementDepth' => 55,
],
[
'id' => 'B',
'spinMinHz' => 1.5,
'spinMaxHz' => 2.5,
'spreadMaxHz' => 16,
'fieldVolume' => 5,
'movementDepth' => 65,
],
[
'id' => 'C',
'spinMinHz' => 2.5,
'spinMaxHz' => 3.5,
'spreadMaxHz' => 20,
'fieldVolume' => 5,
'movementDepth' => 70,
],
[
'id' => 'D',
'spinMinHz' => 3.0,
'spinMaxHz' => 4.0,
'spreadMaxHz' => 20,
'fieldVolume' => 6,
'movementDepth' => 75,
],
];
$chosen = $baseConditions[random_int(0, count($baseConditions) - 1)];
// Mild perpetual randomizer around the assigned profile.
$chosen['spinMinHz'] = max(0.5, min(4.0, $chosen['spinMinHz'] + random_int(-10, 10) / 100));
$chosen['spinMaxHz'] = max($chosen['spinMinHz'] + 0.2, min(4.0, $chosen['spinMaxHz'] + random_int(-10, 10) / 100));
$chosen['spreadMaxHz'] = max(6, min(24, $chosen['spreadMaxHz'] + random_int(-10, 10) / 10));
$chosen['fieldVolume'] = max(2, min(10, $chosen['fieldVolume'] + random_int(-1, 1)));
$chosen['movementDepth'] = max(35, min(90, $chosen['movementDepth'] + random_int(-5, 5)));
$tokenBody = implode('|', [
$chosen['id'],
$chosen['spinMinHz'],
$chosen['spinMaxHz'],
$chosen['spreadMaxHz'],
$chosen['fieldVolume'],
$chosen['movementDepth'],
gmdate('Y-m-d'),
]);
$chosen['token'] = substr(hash_hmac('sha256', $tokenBody, app_salt()), 0, 24);
$chosen['label'] = 'Audio Profile ' . $chosen['id'];
return $chosen;
}
function aggregate_summary(): array {
return with_rows_lock(function (array $rows) {
return aggregate_rows($rows);
});
}
function nsp_omniteacher_preselect_from_sketch(string $sketch): string {
$sketch = strtolower($sketch);
if ($sketch === 'morse') return 'morse';
if ($sketch === 'asl') return 'visual-asl';
if ($sketch === 'train' || $sketch === 'teacher') return '';
return '';
}
/** Built-in starter courses — honest coverage, not a full corpus. */
function nsp_omniteacher_builtin_courses(): array {
$fr = [
['bonjour','hello'],['merci','thank you'],['oui','yes'],['non','no'],
['s\'il vous plaît','please'],['au revoir','goodbye'],['eau','water'],
['pain','bread'],['ami','friend'],['maison','house'],['chien','dog'],
['chat','cat'],['livre','book'],['temps','time'],['jour','day'],
['nuit','night'],['amour','love'],['manger','to eat'],['boire','to drink'],
['parler','to speak'],['je','I'],['tu','you'],['nous','we'],['bonjour le monde','hello world'],
];
$es = [
['hola','hello'],['gracias','thank you'],['sí','yes'],['no','no'],
['por favor','please'],['adiós','goodbye'],['agua','water'],['pan','bread'],
['amigo','friend'],['casa','house'],['perro','dog'],['gato','cat'],
['libro','book'],['tiempo','time'],['día','day'],['noche','night'],
['amor','love'],['comer','to eat'],['beber','to drink'],['hablar','to speak'],
['yo','I'],['tú','you'],['nosotros','we'],['buenos días','good morning'],
];
$de = [
['hallo','hello'],['danke','thank you'],['ja','yes'],['nein','no'],
['bitte','please'],['auf wiedersehen','goodbye'],['wasser','water'],
['brot','bread'],['freund','friend'],['haus','house'],['hund','dog'],
['katze','cat'],['buch','book'],['zeit','time'],['tag','day'],
['nacht','night'],['liebe','love'],['essen','to eat'],['trinken','to drink'],
['sprechen','to speak'],['ich','I'],['du','you'],['wir','we'],['guten tag','good day'],
];
$mkItems = static function (array $pairs, string $prefix): array {
$out = [];
foreach ($pairs as $i => [$a, $b]) {
$out[] = [
'id' => $prefix . ($i + 1),
'prompt' => $a,
'answer' => $b,
'accepted_answers' => [$b],
'hint' => '',
'speak_text' => $a,
'media' => (object)[],
'tags' => [],
];
}
return $out;
};
$morseChars = array_merge(
str_split('abcdefghijklmnopqrstuvwxyz'),
str_split('0123456789'),
['.', ',', '?', '/']
);
$morseItems = [];
foreach ($morseChars as $i => $ch) {
$morseItems[] = [
'id' => 'm' . ($i + 1),
'prompt' => $ch,
'answer' => $ch,
'accepted_answers' => [$ch],
'hint' => 'Morse tone',
'speak_text' => $ch,
'media' => (object)[],
'tags' => [ctype_alpha($ch) ? 'letter' : (ctype_digit($ch) ? 'number' : 'symbol')],
];
}
$aslMap = [
'A'=>'fist thumb side','B'=>'flat hand thumb in','C'=>'C shape','D'=>'index up rest touch',
'E'=>'fist fingertips','F'=>'OK circle three up','G'=>'index+thumb side','H'=>'two fingers side',
'I'=>'pinky up','J'=>'pinky draw J','K'=>'index mid V thumb','L'=>'L shape',
'M'=>'three over thumb','N'=>'two over thumb','O'=>'O circle','P'=>'K pointing down',
'Q'=>'G pointing down','R'=>'cross index mid','S'=>'fist thumb front','T'=>'thumb under index',
'U'=>'two fingers up','V'=>'peace V','W'=>'three fingers','X'=>'hook index',
'Y'=>'thumb+pinky','Z'=>'index draw Z',
];
$aslItems = [];
$ai = 0;
foreach ($aslMap as $letter => $desc) {
$ai++;
$aslItems[] = [
'id' => 'a' . $ai,
'prompt' => $desc,
'answer' => $letter,
'accepted_answers' => [$letter, strtolower($letter)],
'hint' => 'ASL handshape (text)',
'speak_text' => $letter,
'media' => (object)[],
'tags' => ['asl','visual'],
];
}
$jp = [
['あ','a'],['い','i'],['う','u'],['え','e'],['お','o'],
['か','ka'],['き','ki'],['く','ku'],['け','ke'],['こ','ko'],
['さ','sa'],['し','shi'],['す','su'],['せ','se'],['そ','so'],
['た','ta'],['ち','chi'],['つ','tsu'],['て','te'],['と','to'],
['な','na'],['に','ni'],['ぬ','nu'],['ね','ne'],['の','no'],
['ま','ma'],['み','mi'],['む','mu'],['め','me'],['も','mo'],
];
return [
[
'schema' => 'nsu-omniteacher-course-v1',
'id' => 'morse',
'kind' => 'morse',
'language_from' => 'morse',
'language_to' => 'en',
'label' => 'Morse code',
'directions' => ['forward'],
'coverage_note' => 'Built-in: letters, digits, . , ? / — audio in Morse course panel.',
'items' => $morseItems,
],
[
'schema' => 'nsu-omniteacher-course-v1',
'id' => 'fr-en-core',
'kind' => 'vocabulary',
'language_from' => 'fr',
'language_to' => 'en',
'label' => 'French → English',
'directions' => ['forward', 'reverse'],
'coverage_note' => 'Starter deck (~24 pairs). Import custom decks for more.',
'items' => $mkItems($fr, 'fr'),
],
[
'schema' => 'nsu-omniteacher-course-v1',
'id' => 'es-en-core',
'kind' => 'vocabulary',
'language_from' => 'es',
'language_to' => 'en',
'label' => 'Spanish → English',
'directions' => ['forward', 'reverse'],
'coverage_note' => 'Starter deck (~24 pairs). Import custom decks for more.',
'items' => $mkItems($es, 'es'),
],
[
'schema' => 'nsu-omniteacher-course-v1',
'id' => 'de-en-core',
'kind' => 'vocabulary',
'language_from' => 'de',
'language_to' => 'en',
'label' => 'German → English',
'directions' => ['forward', 'reverse'],
'coverage_note' => 'Starter deck (~24 pairs). Import custom decks for more.',
'items' => $mkItems($de, 'de'),
],
[
'schema' => 'nsu-omniteacher-course-v1',
'id' => 'jp-kana-core',
'kind' => 'script',
'language_from' => 'ja',
'language_to' => 'romaji',
'label' => 'Hiragana → Romaji',
'directions' => ['forward', 'reverse'],
'coverage_note' => 'Starter hiragana subset. Unicode-safe; import for full kana/kanji.',
'items' => $mkItems($jp, 'jp'),
],
[
'schema' => 'nsu-omniteacher-course-v1',
'id' => 'visual-asl',
'kind' => 'visual',
'language_from' => 'asl-text',
'language_to' => 'en-letter',
'label' => 'ASL handshape (text)',
'directions' => ['forward'],
'coverage_note' => 'Text handshape hints only — not video/camera; letters A–Z.',
'items' => $aslItems,
],
];
}
function nsp_omniteacher_manifest_hash(): string {
$courses = nsp_omniteacher_builtin_courses();
$slim = [];
foreach ($courses as $c) {
$slim[] = [
'id' => $c['id'],
'kind' => $c['kind'],
'label' => $c['label'],
'n' => count($c['items']),
'directions' => $c['directions'],
];
}
return hash('sha256', json_encode([
'version' => OMNITEACHER_VERSION,
'schema' => 'nsu-omniteacher-course-v1',
'courses' => $slim,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
/**
* Omniteacher — universal competency trainer (center ? product).
* Morse is one course; language decks + custom import earn the "all languages" claim honestly.
*/
function nsm21_front(?string $preselectCourse = null): void {
/* Telemetry/summary are handled by top-level MAP API (opt-in). This face is HTML only. */
if (($_GET['api'] ?? '') !== '') {
json_response(['ok' => false, 'error' => 'Use top-level ?api=telemetry|summary'], 400);
}
$courses = nsp_omniteacher_builtin_courses();
$manifestHash = nsp_omniteacher_manifest_hash();
$summary = aggregate_summary();
$condition = random_condition($summary);
$pre = is_string($preselectCourse) ? $preselectCourse : '';
if ($pre === '' && isset($_GET['course'])) {
$pre = preg_replace('/[^a-z0-9_-]/', '', strtolower((string)$_GET['course'])) ?? '';
}
$sketchHint = strtolower(preg_replace('/[^a-z0-9_-]/', '', (string)($_GET['sketch'] ?? '')) ?? '');
if ($pre === '') {
$pre = nsp_omniteacher_preselect_from_sketch($sketchHint);
}
$bootstrap = [
'appVersion' => OMNITEACHER_VERSION,
'omniteacherVersion' => OMNITEACHER_VERSION,
'manifestHash' => $manifestHash,
'preselectCourse' => $pre,
'courses' => $courses,
'condition' => $condition,
'aggregate' => [
'visitors' => $summary['visitors'] ?? 0,
'heard' => $summary['heard'] ?? 0,
'testAttempts' => $summary['testAttempts'] ?? 0,
'masteredChars' => $summary['masteredChars'] ?? 0,
],
'storageKey' => 'ns_omniteacher_v1',
'legacyMorseKey' => 'morseCompetencyTrainerStatsV2',
];
?>
Omniteacher
= ns_worktable_banner() ?>
OMNITEACHER
MAP · v= htmlspecialchars(OMNITEACHER_VERSION) ?> · universal competency trainer. Morse is one course. Built-in starters + import any Unicode deck. Progress stays in this browser.
OT = htmlspecialchars(OMNITEACHER_VERSION) ?>
Ready
?
CHECK
SPEAK
REVEAL
NEXT
PLAY MORSE
Omniteacher = htmlspecialchars(OMNITEACHER_VERSION) ?> · manifest = htmlspecialchars(substr($manifestHash, 0, 12)) ?>…
Built-in courses are starters, not a complete world corpus — import any language pair.
Local-only progress. No account. No wallet seed required.
false, 'err' => 'admin auth'], 401);
}
function nsp_handle_admin_api(string $api): bool {
if (!str_starts_with($api, 'admin_')) return false;
nsp_ensure_site_seed();
if ($api === 'admin_status') {
$v = nsp_hub_variant();
// Soft-verify: site + version only. No filesystem vault path to strangers.
nsp_json(['ok' => true, 'site' => $v, 'version' => $v]);
}
if ($api === 'admin_login' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
nsp_require();
$v = nsp_hub_variant();
nsp_json(['ok' => true, 'msg' => 'ok', 'site' => $v, 'vault_hint' => nsp_vault_dir(), 'version' => $v]);
}
/* LORD SOVEREIGNTY — rotate this crop's panel seed.
*
* Until this existed a lord could not become independent of the king. The
* ten panel seeds are minted by genesis and every one of them is printed in
* the king's GENESIS-INFO.txt, so the key to a rented crop was issued by the
* landlord and the landlord kept a copy. admin_change_pass rotates only the
* legacy PASSWORD, which changes nothing: nsp_require() accepts the seed
* directly, so the seed is the real door and it could never be changed.
*
* That is fine for a staff position and wrong for a tenancy - and the rent
* system (admin_rent_claim, operator_addr, NST_RENT_NSU_PER_DAY) says
* tenancy is the intent. A tenant whose landlord holds a key to the safe is
* not a tenant.
*
* CONFIRMATION IS REQUIRED AND CASE-SENSITIVE, matching the WIPE prompt in
* Deploy.bat. Re-keying is irreversible with no recovery desk, so it must be
* un-runnable by accident rather than merely documented as dangerous.
*
* THE ADDRESS CHANGES, AND THAT HAS CONSEQUENCES THE CALLER MUST SEE.
* Addresses derive from seeds, so a new seed is a new wallet:
* - the crop's existing NSU stays at the OLD address, which the old seed
* still opens; move it deliberately, it is not swept
* - trade pays this crop's emission share to the address in its on-chain
* crop registry, which still names the OLD one until a successor is
* anchored there
* Both are reported in the response rather than left to be discovered.
*/
if ($api === 'admin_rekey' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
nsp_require();
if ((string)($_POST['confirm'] ?? '') !== 'REKEY') {
nsp_json([
'ok' => false,
'err' => 'Re-keying replaces this crop\'s panel seed permanently. There is no '
. 'recovery desk. POST confirm=REKEY to proceed.',
'confirm_required' => 'REKEY',
], 400);
}
$oldSeed = nsp_norm_seed((string)@file_get_contents(nsp_seed_file()));
$oldAddr = $oldSeed !== '' ? nsp_addr_from_seed($oldSeed) : '';
$new = nsp_generate_site_seed();
$newAddr = nsp_addr_from_seed($new);
if ($new === '' || $newAddr === '' || $newAddr === $oldAddr) {
nsp_json(['ok' => false, 'err' => 'seed generation failed'], 500);
}
/* Vault note first: it is the operator's offline copy, so if the second
* write fails the seed still exists somewhere other than this response.
* The old seed keeps working until site.seed itself is replaced, so a
* half-finished rotation locks nobody out. */
nsp_vault_site_seed_note($new);
if (@file_put_contents(nsp_seed_file(), $new . "\n", LOCK_EX) === false) {
nsp_json(['ok' => false, 'err' => 'could not write site.seed - crop unchanged, old seed still valid'], 500);
}
@chmod(nsp_seed_file(), 0600);
/* Read back before claiming success. Reporting a rotation that did not
* land would strand the lord with a seed the crop does not accept. */
$check = nsp_norm_seed((string)@file_get_contents(nsp_seed_file()));
if ($check !== nsp_norm_seed($new)) {
nsp_json(['ok' => false, 'err' => 'readback mismatch - rotation not confirmed'], 500);
}
nsp_json([
'ok' => true,
'seed' => $new,
'seed_shown_once' => true,
'old_addr' => $oldAddr,
'new_addr' => $newAddr,
'vault' => 'SITE_WALLET_SEED.txt',
'next_steps' => [
'SAVE THIS SEED OFFLINE NOW. It is shown once and there is no recovery desk.',
'The king\'s copy of the previous seed no longer opens this crop.',
'Your NSU is still at the OLD address - the old seed opens that wallet. '
. 'Transfer it to the new address deliberately; nothing is swept for you.',
'Trade still pays this crop\'s emission to the OLD address until a successor '
. 'is anchored in its on-chain crop registry.',
],
'msg' => 'Panel seed rotated. This crop is now yours alone.',
]);
}
if ($api === 'admin_get_source' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
nsp_require();
$raw = (string)file_get_contents(__FILE__);
nsp_json(['ok' => true, 'bytes' => strlen($raw), 'sha256' => hash('sha256', $raw), 'source' => $raw]);
}
if ($api === 'admin_put_source' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
nsp_require();
$src = (string)($_POST['source'] ?? '');
if (strlen($src) < 100 || strpos($src, ' false, 'err' => 'bad source'], 400);
$bak = __FILE__ . '.bak.' . time();
@copy(__FILE__, $bak);
if (file_put_contents(__FILE__, $src, LOCK_EX) === false) nsp_json(['ok' => false, 'err' => 'write failed'], 500);
nsp_json(['ok' => true, 'msg' => 'replaced', 'backup' => basename($bak), 'sha256' => hash('sha256', $src)]);
}
nsp_json(['ok' => false, 'err' => 'unknown admin api'], 404);
return true;
}
function nsp_render_controlpanel(): void {
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store');
$v = nsp_hub_variant();
$site = nsp_hub_label() . ' MAP · AB hub';
$h = htmlspecialchars($site);
echo ' ';
echo '' . $h . ' Control
' . ns_worktable_banner(false) . '';
echo '
DNA · MAP · AB hub · pinwheel · ' . htmlspecialchars($v) . '
';
echo '
' . $h . ' · Control Panel ';
echo '
Site-local renter key for THIS domain only (com/org/net AB triad shares UI code, not vaults). '
. 'Paste this host\'s site wallet seed (vault SITE_WALLET_SEED.txt / data/site.seed). '
. 'UTTER control of THIS index.php. Not OS root. Independent vault per host. No visitor wallet on hub — money/buy on nosignup.trade. '
. 'Panel door is site seed only. Leftover admin password files are unlinked.
';
echo '
';
echo '
Site wallet seed (12 words) ';
echo '
';
echo '
Unlock with site seed ';
echo '
';
echo '
Owner: seed auto-generated at first boot into data/site.seed + vault SITE_WALLET_SEED.txt (root pull). Paste seed → unlock. No password product path. No recovery desk. Hub is not a mint. Not a visitor account.
';
echo '
Vault: -
';
echo '
Source (utter control) Load Save ';
echo '
index.php source ';
echo '
← back
';
exit;
}
/**
* In-file skill sketches: Morse (v1) + ASL text-only peer deck (same practice/test shell).
* Two skills only — text letter hints for ASL, tones for Morse. No camera/video; no third skill.
*/
/**
* In-file skill sketches: Morse (v2 competency engine) + ASL text-only peer deck.
* Morse: clean 550 Hz tone SEPARATE from optional stereo field (profiles A–E).
* Two skills only. No camera/video; no third skill. Hub DNA: free · no signup.
*/
function nsp_render_skill_sketch(string $skill): void {
$skill = $skill === 'asl' ? 'asl' : 'morse';
$v = nsp_hub_variant();
$title = $skill === 'asl' ? 'ASL · skill deck' : 'Morse · competency sketch';
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store');
$hubHome = './';
$morseJson = json_encode([
'A'=>'.-','B'=>'-...','C'=>'-.-.','D'=>'-..','E'=>'.','F'=>'..-.','G'=>'--.','H'=>'....',
'I'=>'..','J'=>'.---','K'=>'-.-','L'=>'.-..','M'=>'--','N'=>'-.','O'=>'---','P'=>'.--.',
'Q'=>'--.-','R'=>'.-.','S'=>'...','T'=>'-','U'=>'..-','V'=>'...-','W'=>'.--','X'=>'-..-',
'Y'=>'-.--','Z'=>'--..','0'=>'-----','1'=>'.----','2'=>'..---','3'=>'...--','4'=>'....-',
'5'=>'.....','6'=>'-....','7'=>'--...','8'=>'---..','9'=>'----.',
'.'=>'.-.-.-',','=>'--..--','?'=>'..--..','/'=>'-....-',
], JSON_UNESCAPED_SLASHES);
$aslJson = json_encode([
'A'=>'fist thumb side','B'=>'flat hand thumb in','C'=>'C shape','D'=>'index up rest touch',
'E'=>'fist fingertips','F'=>'OK circle three up','G'=>'index+thumb side','H'=>'two fingers side',
'I'=>'pinky up','J'=>'pinky draw J','K'=>'index mid V thumb','L'=>'L shape',
'M'=>'three over thumb','N'=>'two over thumb','O'=>'O circle','P'=>'K pointing down',
'Q'=>'G pointing down','R'=>'cross index mid','S'=>'fist thumb front','T'=>'thumb under index',
'U'=>'two fingers up','V'=>'peace V','W'=>'three fingers','X'=>'hook index',
'Y'=>'thumb+pinky','Z'=>'index draw Z',
], JSON_UNESCAPED_SLASHES);
$spokenJson = json_encode([
'A'=>'A','B'=>'B','C'=>'C','D'=>'D','E'=>'E','F'=>'F','G'=>'G','H'=>'H','I'=>'I','J'=>'J',
'K'=>'K','L'=>'L','M'=>'M','N'=>'N','O'=>'O','P'=>'P','Q'=>'Q','R'=>'R','S'=>'S','T'=>'T',
'U'=>'U','V'=>'V','W'=>'W','X'=>'X','Y'=>'Y','Z'=>'Z',
'0'=>'zero','1'=>'one','2'=>'two','3'=>'three','4'=>'four','5'=>'five','6'=>'six','7'=>'seven','8'=>'eight','9'=>'nine',
'.'=>'period',','=>'comma','?'=>'question mark','/'=>'slash',
], JSON_UNESCAPED_SLASHES);
$serverBootstrap = null;
if ($skill === 'morse') {
/* Fail-open: soft row lock so unwritable data/ still serves the trainer. */
$got = nsp_morse_with_rows(static function (array $rows) {
return nsp_morse_aggregate($rows);
}, false);
$agg = is_array($got) ? $got : ['visitors' => 0, 'bestSpeed' => null];
$cond = nsp_morse_random_condition($agg);
$serverBootstrap = [
'appVersion' => 'hub-morse-v2',
'condition' => $cond,
'aggregate' => [
'visitors' => (int)($agg['visitors'] ?? 0),
'bestSpeed' => $agg['bestSpeed'] ?? null,
],
'mastery' => [
'window' => NSP_MORSE_MASTERY_WINDOW,
'required' => NSP_MORSE_MASTERY_REQUIRED,
'maxMedianMs' => NSP_MORSE_MASTERY_MAX_MEDIAN_MS,
'exposureBudget' => NSP_MORSE_MASTERY_EXPOSURE_BUDGET,
'attemptBudget' => NSP_MORSE_MASTERY_ATTEMPT_BUDGET,
],
];
}
echo ' ';
echo ' ';
echo '' . htmlspecialchars($title) . ' ' . ns_worktable_banner();
echo '';
echo '
';
echo '';
echo '';
echo ' ';
echo ' ';
echo ' ';
echo 'Close ';
echo ' ';
echo 'MAP ';
echo '';
echo '
';
echo 'Morse ';
echo '
';
echo '
';
echo 'Letters only ';
echo '';
echo '
';
echo '
';
echo 'Foreign → English → foreign ';
echo '
';
echo '·
';
echo '
?
';
echo '
—
';
echo '
' . ($skill === 'morse' ? 'Morse · then the answer · Morse again' : 'Pick a stream · START') . '
';
echo '
';
echo '';
echo '
';
echo 'Start ';
echo 'Again ';
echo 'Stop ';
echo '
';
echo '
Listen ';
if ($skill === 'morse') {
echo '
Check
';
} else {
echo '
';
}
echo '
';
echo '';
echo '
';
echo '
Back ';
echo '
';
echo '';
if ($skill === 'morse') {
echo '
';
echo '
';
echo '
';
} else {
echo '
Tone ASL uses spoken letter names, not Morse beeps.
';
echo '
Field Field beds are Morse-only.
';
}
echo '
Back ';
echo '
';
echo '';
if ($skill === 'morse') {
echo '
This device ';
echo '
heard 0 quiz 0 learned 0 visitors 0
';
echo '
';
echo '
Session ';
echo 'Save scores ';
echo 'Clear scores ';
echo 'Skip ';
echo 'Play one ';
echo '
';
} else {
echo '
This device ASL text practice. No camera.
';
echo '
Skip ';
echo '
Show answer: OFF ';
}
echo '
Back ';
echo '
';
echo '';
echo ' ';
}
if (isset($_GET['src']) || isset($_GET['download']) || (isset($_GET['api']) && $_GET['api'] === 'src')) {
$raw = (string)file_get_contents(__FILE__);
header('Content-Type: text/plain; charset=UTF-8');
header('X-Content-Type-Options: nosniff');
header('X-NS-Sha256: ' . hash('sha256', $raw));
if (isset($_GET['download'])) {
header('Content-Disposition: attachment; filename="nosignup-' . nsp_hub_variant() . '.php"');
}
echo $raw;
exit;
}
/* Site seed genesis BEFORE any admin API / controlpanel (no land-grab window). */
$api = (string)($_GET['api'] ?? $_POST['api'] ?? '');
/* MORSE-v2: organism research hooks (hashed anon; no accounts) */
/** P1-HUB-TELEMETRY-METHOD RR210: non-POST must not fall through as MAP HTML (200).
* Sisters chat/market/work/date answer write routes with 405 POST only. */
if ($api === 'telemetry' && strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') {
nsp_json(['ok' => false, 'err' => 'POST only'], 405);
}
if ($api === 'telemetry' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
nsp_morse_save_telemetry();
}
if ($api === 'summary') {
nsp_morse_summary_api();
}
/* WIRING-PARITY: public selfhash (trade-shape; no auth) */
if ($api === 'selfhash') {
nsp_json([
'ok' => true,
'version' => 'hub-sketch-1',
'omniteacherVersion' => OMNITEACHER_VERSION,
'omniteacherManifest' => nsp_omniteacher_manifest_hash(),
'sha256' => @hash_file('sha256', __FILE__) ?: null,
'file' => basename(__FILE__),
]);
}
$wantPanel = isset($_GET['controlpanel']) || (isset($_SERVER['REQUEST_URI']) && preg_match('#/controlpanel/?(\?|$)#', (string)$_SERVER['REQUEST_URI']));
if (($api !== '' && str_starts_with($api, 'admin_')) || ($wantPanel && $api === '')) {
nsp_ensure_site_seed();
}
if ($api !== '' && str_starts_with($api, 'admin_')) { nsp_handle_admin_api($api); }
if ($wantPanel && $api === '') { nsp_render_controlpanel(); }
/* RR325 P1-HUB-UNKNOWN-API-PLAIN: unknown ?api= must not fall through as MAP HTML 200 */
if ($api !== '' && !str_starts_with($api, 'admin_') && $api !== 'telemetry' && $api !== 'summary' && $api !== 'selfhash') {
nsp_json([
'ok' => false,
'err' => 'Unknown MAP API — use selfhash, summary, telemetry (POST), or open the face pinwheel',
'api' => $api,
'money' => 'nosignup.trade',
], 404);
}
/* EMPIRE SETUP GATE: while trade setup unfilled, public faces → trade console */
if ($api === '' && !isset($_GET['src']) && !isset($_GET['download']) && !isset($_GET['controlpanel']) && !isset($_GET['empire_setup']) && !isset($_GET['setup']) && !isset($_GET['sketch'])) {
$uri = (string)($_SERVER['REQUEST_URI'] ?? '');
if (!preg_match('#/(controlpanel|setup)/?(\?|$)#', $uri)) {
$dataDir = __DIR__ . DIRECTORY_SEPARATOR . 'data';
$local = $dataDir . DIRECTORY_SEPARATOR . 'empire_setup.json';
$ok = false;
if (is_file($local)) {
$lj = json_decode((string)@file_get_contents($local), true);
$ok = is_array($lj) && !empty($lj['filled']);
}
if (!$ok) {
$cache = $dataDir . DIRECTORY_SEPARATOR . 'empire_setup_status_cache.json';
$now = time();
if (is_file($cache)) {
$c = json_decode((string)@file_get_contents($cache), true);
if (is_array($c) && isset($c['ts'], $c['filled']) && ($now - (int)$c['ts']) < 60 && !empty($c['filled'])) {
$ok = true;
}
}
}
if (!$ok) {
$ctx = stream_context_create(['http' => ['timeout' => 2.5, 'ignore_errors' => true]]);
$httpsOn = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off') || ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443) || (strtolower((string)($_SERVER['REQUEST_SCHEME'] ?? '')) === 'https') || (strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https');
$scheme = $httpsOn ? 'https' : 'http';
$raw = @file_get_contents($scheme . '://nosignup.trade/?api=empire_setup_status', false, $ctx);
$httpsHit = false;
if (is_string($raw) && $raw !== '') {
$j = json_decode($raw, true);
if (is_array($j) && array_key_exists('filled', $j)) {
$httpsHit = true;
if (!empty($j['filled'])) {
$ok = true;
}
if (is_dir($dataDir)) {
@file_put_contents($cache, json_encode(['ts' => time(), 'filled' => !empty($j['filled'])]) . "\n", LOCK_EX);
}
}
}
if (!$httpsHit && !$ok && is_file($cache)) {
$c = json_decode((string)@file_get_contents($cache), true);
if (is_array($c) && !empty($c['filled'])) {
$ok = true;
}
}
}
if (!$ok) {
header('Cache-Control: no-store');
header('Location: //nosignup.trade/?empire_setup=1', true, 302);
exit;
}
}
}
/* In-file sketch / custom-site memory — served from this PHP */
$sketch = strtolower(preg_replace('/[^a-z0-9_-]/', '', (string)($_GET['sketch'] ?? '')) ?? '');
if (in_array($sketch, ['teacher','morse','train','asl'], true)) {
/* Unified Omniteacher aliases — Morse/ASL are courses, not separate shells. */
nsm21_front(nsp_omniteacher_preselect_from_sketch($sketch));
exit;
}
$nsHubVar = nsp_hub_variant();
$nsHubTitle = nsp_hub_label();
$nsHubPoolJson = '[]'; /* HUB_POOL retired — center is Omniteacher only */
?>
= htmlspecialchars($nsHubTitle) ?> · free map of nosignup sites
= ns_worktable_banner() ?>
' ',
2 => ' ',
3 => ' ',
4 => ' ',
5 => ' ',
6 => ' ',
7 => ' ',
];
$inner = $g[$n] ?? '';
return '' . $inner . ' ';
}
function ns_hub_spoke_svg(int $n, string $title, string $sub, string $c1, string $c2): string {
$title = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$sub = htmlspecialchars($sub, ENT_QUOTES, 'UTF-8');
return ''
. '' . $title . ' '
. '' . $sub . ' ' /* NS_R4_HUB_SPOKE */
. ' ';
}
function ns_hub_q_svg(): string {
return ''
. '? '
. ' ';
}
?>
= ns_hub_spoke_ico(1).ns_hub_spoke_svg(1,'TRADE','(MONEY)','#ff5050','#b30000') ?>
= ns_hub_spoke_ico(2).ns_hub_spoke_svg(2,'CHAT','(PEER)','#ffb450','#ff6f00') ?>
= ns_hub_spoke_ico(3).ns_hub_spoke_svg(3,'WORK','(JOBS)','#fff078','#c8a000') ?>
= ns_hub_spoke_ico(4).ns_hub_spoke_svg(4,'MARKET','(ADS)','#50dc78','#008000') ?>
= ns_hub_spoke_ico(5).ns_hub_spoke_svg(5,'DATE','(MEET)','#648cff','#0000c8') ?>
= ns_hub_spoke_ico(6).ns_hub_spoke_svg(6,'FUN','(MEDIA)','#c878ff','#8000ff') ?>
= ns_hub_spoke_ico(7).ns_hub_spoke_svg(7,'INFO','(NOTES)','#dc78dc','#993399') ?>
= ns_hub_q_svg() ?>
Options
Mirrors
Donate
Wallet
Free map · no signup · spokes = sites (= htmlspecialchars($nsHubVar) ?>)
= htmlspecialchars($nsHubTitle) ?>
Map Spokes open each product site · center ? = Omniteacher · not one mega-appTRADE · CHAT · WORK · MARKET · DATE · FUN · INFO Sketchpad Omniteacher in the center — languages + Morse as courses. Progress stays local in this browser No signup Explore free · no account · no app install No balance here Trade wallet opens an empire seed · spending stays on nosignup.trade · hub has no local balance
Risk acknowledgment · not an account. This page keeps: Morse counts if you use the trainer (hashed). No wallet, listing, or camera. Host logs may exist. Experimental · enter at your own risk.
I AGREE TO THESE TERMS · ENTER
I am a minor — leave
x Mirrors
Open Donate
Equal mirrors. A mirror is the complete ten-face NoSignup spore. Operators supply capacity and earn through the traffic and services they actually serve; an overloaded face may hand users to a confirmed identical mirror. Capacity never creates authority. This download is one auditable face, not a complete mirror.
↓ Download this file · View source
Known public roots:
Close
x Donate One .php file. No build step. Optional — never required.
BITCOIN bc1qqnu6n0jztxl4f6krv7klradghle09uhyu7uymz
MONERO 8Ab24DppUvcdtHfm7K8gTqdBTmPCBiak1GwxgPm1C3osYVQL2QdC1C8GMwggKF77RKKzDgP2R8E3VH8ifetsKms5AqkVyVg
LITECOIN ltc1qlpdy8qzejcmjdn6vwarpyz8djdlk780w4qkwyp
Tap address to copy · also see nosignup.trade Money/buy · buy on book / optional donate
Close
= nsu_ad_slot_html() ?>
×
Change wallet
Seed is the password. Write it down. Do not give it out.
New wallet
Generate a new random wallet on this browser.
Generate a new random wallet
Open on Trade
Open a different wallet
Paste its 12-word seed to use that wallet on this device.
12-word seed phrase
Use this seed
';
}
return '';
}
/**
* Where a visitor's "report ad" goes. Reports surface to the King and to
* this crop's lord; they never auto-pull a paying ad, because the lord is
* paid by the advertiser and should not be the only reviewer.
*/
function nsu_report_endpoint(): string {
$t = nsu_crop_tld();
if ($t === '') return '';
return nsu_trade_origin() . '/?api=ad_report&crop=' . urlencode($t);
}
/** Badge stub. Shared ticket may be read from localStorage nosignup_device_id. No second mill on this face. */
function ns_identity_badge_later(): string { return ''; }
/* ===================== END ECONOMY+IDENTITY TELOMERE ================== */