Spaces:
Running
Running
File size: 52,759 Bytes
b51e03a 3597f5a fcce7e3 369b236 fcce7e3 369b236 3597f5a fcce7e3 cf96355 116dba6 369b236 fcce7e3 d9dd17d fcce7e3 116dba6 fcce7e3 116dba6 fcce7e3 116dba6 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 116dba6 fcce7e3 116dba6 fcce7e3 369b236 fcce7e3 369b236 fcce7e3 116dba6 fcce7e3 369b236 fcce7e3 369b236 116dba6 fcce7e3 3311082 fcce7e3 3311082 fcce7e3 3311082 fcce7e3 3311082 fcce7e3 369b236 116dba6 fcce7e3 116dba6 fcce7e3 116dba6 fcce7e3 116dba6 fcce7e3 369b236 fcce7e3 116dba6 fcce7e3 d9dd17d fcce7e3 3311082 b51e03a fcce7e3 3311082 fcce7e3 3311082 d9dd17d fcce7e3 d9dd17d fcce7e3 d9dd17d fcce7e3 d9dd17d fcce7e3 3597f5a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 | ;
;
/* ============================================================================
AUTH PORTAL β email + password accounts, cloud save push/pull
----------------------------------------------------------------------------
A second, independent account system alongside the one in src/account.js.
That one is Google/Facebook sign-in β deliberately disabled (see the block
comment at the top of account.js) because a shipped build has no way to
supply OAuth app credentials. This one is plain email + password against a
MASSFRONT-owned server (Cloudflare Worker + D1, see cloudflare/massfront-
auth/), so it needs no third-party app registration to turn on β only a
deployed worker and a URL.
NAMESPACING. Every binding in this file is prefixed `AP` / `ap` (Auth
Portal) and nothing here is declared with the same name as anything in
account.js (AUTH_CFG, ACCOUNT, SYNC, ...). That is deliberate, not
incidental: classic <script> files sharing one global scope means a second
`let SYNC = ...` anywhere would be a page-breaking SyntaxError (redeclaring
a lexical binding), and a second `function signOut(){}` would silently
replace whichever one loaded second. Boot order (see boot.js) currently
loads this file BEFORE account.js, so the risk is exactly backwards from
what it looks like β collide with account.js and IT clobbers whatever THIS
file defined, not the other way round. Distinct names sidestep the whole
question. The two systems otherwise don't know about each other on
purpose: this module reads only the small set of globals the task
describes as shared (META, metaSave/metaLoad, PROFILES, activeProf,
profSave, toast, sfx, encodeSave/decodeSave, renderMetaHead, renderProfile)
and never reaches into account.js's private state or DOM (#accPanel,
#accDlg, ...), so either module can keep changing without breaking this
one.
ENTRY POINT. A button is appended to the existing `.menuStrip` element on
the start screen (chosen over Settings' `#setList`): menuStrip is static
markup in index.html that this file only ever appends to once, whereas
#setList is fully rebuilt from scratch by renderSettings() (src/game/
meta.js) every time the Settings screen opens β appending there would get
silently wiped the first time a player opened Settings, without a
MutationObserver this module has no reason to need otherwise.
HONEST DEGRADATION β the actual contract this file is held to:
* No server configured at all -> say exactly that, in the panel, and
point at the save-code path under Profile. Never show sign-in fields
pretending they would work.
* Server configured but unreachable -> say that, distinctly from "no
server". Never fall back to a fake signed-in state.
* A cached session survives a restart, but ONLY the client ever sets
AP_SESSION from a real 2xx server response (register/login). A network
failure while re-verifying it on boot marks it "unverified", it does
not manufacture a new one, and an explicit 401 clears it immediately.
ENDPOINT RESOLUTION mirrors updResolveEndpoint() in src/updater.js:
1. window.MASSFRONT_AUTH_URL β set by an embedder before boot
2. a URL saved on this device β the "Set server URL" affordance below
3. assets/auth.json `syncUrl` β same field src/account.js already reads
for its own (disabled) cloud sync, so one shipped config file lights
up both systems; this file never writes that JSON, only reads it
4. nothing β and the panel says so, plainly
============================================================================ */
/* ---- state ------------------------------------------------------------------
AP_SESSION is the ONLY source of truth for "signed in". It is either null,
or {token,email,expiresAt,offline?} where every field except `offline` came
from a real server response. Cached in localStorage so a restart doesn't
sign a player out, but re-checked against /me on boot (apVerifySession). */
let AP_CFG = { endpoint: null, src: 'none', resolved: false };
let AP_SESSION = null;
let AP_BUSY = false;
let AP_TAB = 'signin'; // 'signin' | 'register'
let AP_LAST_PUSH = 0, AP_LAST_PULL = 0;
let AP_LAST_FOCUS = null;
let AP_SYNC_KIND = 'idle'; // idle | busy | success | error
let AP_SYNC_MESSAGE = '';
const AP_SESSION_KEY = 'massfront_authp_session_v1';
const AP_URL_KEY = 'massfront_authp_url';
/* ---- endpoint resolution ----------------------------------------------------
Deliberately no "packaged host -> relative path" tier the way updater.js
has one: there is no same-origin `auth.json` convention to fall back to
here, so 4 tiers is the whole chain (task spec names exactly these 4). */
async function apResolveEndpoint(){
AP_CFG.resolved = true;
if (typeof window !== 'undefined' && window.MASSFRONT_AUTH_URL){
AP_CFG.src = 'embed';
return (AP_CFG.endpoint = String(window.MASSFRONT_AUTH_URL));
}
try{
const s = localStorage.getItem(AP_URL_KEY);
if (s && s.trim()){ AP_CFG.src = 'device'; return (AP_CFG.endpoint = s.trim()); }
}catch(e){}
try{
const r = await fetch('./assets/auth.json?t=' + Date.now(), { cache: 'no-store' });
if (r.ok){
const c = await r.json();
if (c && typeof c.syncUrl === 'string' && c.syncUrl.trim()){
AP_CFG.src = 'config';
return (AP_CFG.endpoint = c.syncUrl.trim());
}
}
}catch(e){}
AP_CFG.src = 'none';
return (AP_CFG.endpoint = null);
}
function apEndpoint(){ return AP_CFG.endpoint || ''; }
function apSetEndpoint(url){
try{
if (url && url.trim()) localStorage.setItem(AP_URL_KEY, url.trim());
else localStorage.removeItem(AP_URL_KEY);
}catch(e){}
AP_CFG = { endpoint: null, src: 'none', resolved: false };
apResolveEndpoint().then(apRender);
}
/* ---- session persistence ----------------------------------------------------
Storing {token,email,expiresAt} in localStorage, per the task spec ("not
JWT-without-verification") β the token is an opaque, meaningless-on-its-own
lookup key the server verifies against its own `sessions` table on every
call. Losing this value just means signing in again; it is not a secret
that unlocks anything by itself without the server's cooperation. */
function apSaveSession(){
try{
if (AP_SESSION) localStorage.setItem(AP_SESSION_KEY, JSON.stringify(AP_SESSION));
else localStorage.removeItem(AP_SESSION_KEY);
}catch(e){}
}
function apLoadSession(){
try{
const s = localStorage.getItem(AP_SESSION_KEY);
if (s){ const o = JSON.parse(s); if (o && o.token && o.email) AP_SESSION = o; }
}catch(e){ AP_SESSION = null; }
}
function apClearSession(){
AP_SESSION = null;
AP_SYNC_KIND = 'idle'; AP_SYNC_MESSAGE = '';
apSaveSession();
/* Re-arm the launch gate. apGateSatisfied() latches mf_auth_gate_v1 on a
successful sign-in AND on PLAY OFFLINE, but nothing ever cleared it, so
once set the portal never appeared again β a player who signed out was
left with no launch prompt at all. Signing out is exactly the moment the
gate should ask again. */
try{ localStorage.removeItem(AP_GATE_KEY); }catch(e){}
if(typeof renderAccount==='function')renderAccount();
}
/* ---- validation --------------------------------------------------------------
The server re-validates everything independently (never trust the client) β
this copy exists purely so a typo is caught before a round trip, with text
specific enough to actually fix. */
const AP_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function apValidEmail(raw){
const s = String(raw || '').trim();
if (!s) return { ok: false, msg: 'Enter your email address.' };
if (s.length > 254) return { ok: false, msg: 'That email address is too long.' };
if (!AP_EMAIL_RE.test(s))
return { ok: false, msg: "That doesn't look like a valid email β check for a typo like a missing @ or domain." };
return { ok: true, value: s.toLowerCase() };
}
/* ---- networking --------------------------------------------------------------
apThrow tags every failure with a `.kind` (plus `.status` when the server
answered at all) so apErrorText() can turn it into one honest sentence
without every caller re-deriving what went wrong. */
function apThrow(kind, message, status){
const e = new Error(message);
e.kind = kind;
if (status != null) e.status = status;
throw e;
}
async function apRequest(method, path, body, needsAuth){
/* Offline is a normal state here too. Refusing up front means the sign-in
screen says "you are offline" instead of spinning for a timeout and then
showing a server error that misdescribes what happened. */
if(typeof netAllowed === 'function' && !netAllowed())
throw new Error('offline');
const base = apEndpoint();
if (!base) apThrow('no_server', 'No account server is set up for this build.');
const headers = { 'content-type': 'application/json' };
if (needsAuth){
if (!AP_SESSION || !AP_SESSION.token) apThrow('no_session', 'Not signed in.');
headers.authorization = 'Bearer ' + AP_SESSION.token;
}
let r;
try{
r = await fetch(base.replace(/\/+$/, '') + path, {
method, headers, cache: 'no-store',
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}catch(netErr){
apThrow('network', 'Could not reach the account server.');
}
let data = null;
try{ data = await r.json(); }catch(e){ data = null; }
if (!r.ok) apThrow((data && data.error) || 'server', (data && data.message) || ('Server error (' + r.status + ')'), r.status);
return data;
}
function apErrorText(e){
if (!e) return 'Something went wrong.';
if (e.kind === 'no_server')
return 'No account server is set up for this build. Your progress is safe on this device β use a game save file (Profile βΈ Local Save File) to move it to another phone.';
if (e.kind === 'network')
return "Can't reach the account server right now. Your progress is still safe on this device β try again later, or export a game save file instead.";
return e.message || 'Something went wrong β try again in a moment.';
}
/* ---- account actions ---------------------------------------------------------- */
function apSetSessionFrom(data, email){
const u = data.user || null;
AP_SESSION = { token: data.token, email: (u && u.email) || email,
username: (u && u.username) || null,
/* A MISSING ageOk means the server did not say β not that the
player answered and failed. Coercing absence to false is why
a perfectly good signed-in account meets the ONE-TIME AGE
CHECK panel on every single sign-in, and the CONFIRM button
behind it posts to a route the deployed worker does not
have. Only `false` should nag; `null` is "unknown, carry on".
Line 256 already gets this right with an `in` test β the two
paths into the same field disagreed. */
ageOk: (u && 'ageOk' in u) ? !!u.ageOk : null,
expiresAt: data.expiresAt || 0 };
AP_SYNC_KIND = 'idle';
AP_SYNC_MESSAGE = '';
apSaveSession();
if(typeof renderAccount==='function')renderAccount();
apGateSatisfied();
apGreet();
}
/* "Welcome Commander <name>" β shown once per sign-in, on the menu the player
is about to land on. The name resolution lives in meta.js (mfGreetName) so the
header greeting and this toast can never disagree. */
function apGreet(){
let nm = 'Commander';
try{ if (typeof mfGreetName === 'function') nm = mfGreetName(); }catch(e){}
const line = 'Welcome Commander ' + nm;
if (typeof renderMetaHead === 'function'){ try{ renderMetaHead(); }catch(e){} }
setTimeout(() => {
if (typeof toast === 'function'){ try{ toast(line); return; }catch(e){} }
apToast(line);
}, 260);
}
async function apRegister(email, password, username){
const body = { email, password, ageOk: true };
if (username) body.username = username;
const data = await apRequest('POST', '/register', body, false);
apSetSessionFrom(data, email);
}
/* ---- account deletion (App Store 5.1.1(v)) ---------------------------------
Two-step by design: the first tap arms it, the second commits. Deletion is
irreversible and the local career is erased too, so a single mis-tap must not
be able to do it. */
async function apDeleteAccount(){
const data = await apRequest('POST', '/account/delete', {}, true);
apClearSession();
return data;
}
async function apLogin(email, password){
const data = await apRequest('POST', '/login', { email, password }, false);
apSetSessionFrom(data, email);
}
async function apLogout(){
try{ await apRequest('POST', '/logout', undefined, true); }catch(e){ /* best-effort β clear locally regardless */ }
apClearSession();
}
/* Runs once at boot if a cached session exists. A confirmed-invalid session
(401) is cleared β that's the server actively saying "this is not real
anymore". Any other failure (network, 5xx) leaves the cached identity in
place but marks it unverified rather than guessing either way. */
async function apVerifySession(){
if (!AP_SESSION) return;
try{
const data = await apRequest('GET', '/me', undefined, true);
AP_SESSION.email = (data.user && data.user.email) || AP_SESSION.email;
if (data.user && 'username' in data.user) AP_SESSION.username = data.user.username;
if (data.user && 'ageOk' in data.user) AP_SESSION.ageOk = !!data.user.ageOk;
AP_SESSION.offline = false;
apSaveSession();
}catch(e){
if (e.status === 401){ apClearSession(); apToast('Your session expired β sign in again.'); }
else if (AP_SESSION) AP_SESSION.offline = true;
}
if(typeof renderAccount==='function')renderAccount();
apRender();
}
/* ---- cloud save push / pull ----------------------------------------------------
Reuses encodeSave()/decodeSave() from src/account.js unmodified β the same
deflate+base64url blob a save code is, just PUT/GET instead of copy/paste.
A successful sign-in performs a READ-ONLY comparison. It never guesses
which copy is authoritative: different saves produce an explicit choice,
while manual Pull and Push each require confirmation before replacing an
existing career. This is deliberately more cautious than career-score-only
auto-selection because two equal-score careers can still contain different
gear, research purchases, settings, names or faction records. */
function apRelTime(ts){
if (!ts) return '';
const s = (Date.now() - ts) / 1000;
if (s < 8) return 'just now';
if (s < 90) return 'moments ago';
if (s < 5400) return Math.round(s / 60) + 'm ago';
return Math.round(s / 3600) + 'h ago';
}
function apSyncSet(kind, message, busy){
AP_SYNC_KIND = kind || 'idle';
AP_SYNC_MESSAGE = message || '';
AP_BUSY = !!busy;
apRender();
}
function apLocalPayload(){
if (typeof syncPayload === 'function') return syncPayload();
return { v: 1, at: Date.now(), profile: typeof activeProf === 'function' ? activeProf() : null, meta: META };
}
function apStableValue(v){
if (Array.isArray(v)) return '[' + v.map(apStableValue).join(',') + ']';
if (v && typeof v === 'object'){
const keys = Object.keys(v).filter(k => k !== 'at').sort();
return '{' + keys.map(k => JSON.stringify(k) + ':' + apStableValue(v[k])).join(',') + '}';
}
return JSON.stringify(v);
}
function apSavesEquivalent(a, b){
if (!a || !b) return false;
return apStableValue({ profile: a.profile || null, meta: a.meta || {} }) ===
apStableValue({ profile: b.profile || null, meta: b.meta || {} });
}
function apSaveStats(data){
const m = data && data.meta || {};
return (m.xp || 0).toLocaleString() + ' XP / ' + (m.cores || 0).toLocaleString() +
' CORES\n' + (m.researchData || 0).toLocaleString() + ' DATA / ' +
(m.matches || 0).toLocaleString() + ' matches';
}
function apCompareMessage(local, cloud, cloudAt, lead){
const when = cloudAt ? ' / saved ' + apRelTime(cloudAt) : '';
return (lead || 'A cloud save was found.') + '\n\n' +
'THIS DEVICE\n' + apSaveStats(local) + '\n\n' +
'CLOUD' + when + '\n' + apSaveStats(cloud) + '\n\n' +
'Nothing changes until you choose.';
}
async function apReadCloudSave(){
const res = await apRequest('GET', '/save', undefined, true);
if (!res || !res.payload) return { data: null, at: res && res.at || 0 };
let data;
try{ data = await decodeSave(res.payload); }
catch(e){ apThrow('bad_save', 'The cloud save exists but could not be read. This device was not changed.'); }
if (!data || !data.meta || typeof data.meta !== 'object')
apThrow('bad_save', 'The cloud save is incomplete. This device was not changed.');
return { data, at: Number(res.at || data.at || 0) };
}
function apCommitIncoming(data){
META = Object.assign({}, META, data.meta);
if (data.profile){
const p = activeProf();
if (p){
p.name = data.profile.name || p.name;
p.emblem = data.profile.emblem || p.emblem;
if (typeof profSave === 'function') profSave();
}
}
metaSave();
if (typeof applyColor === 'function') applyColor();
if (typeof applySettings === 'function') applySettings();
if (typeof renderMetaHead === 'function') renderMetaHead();
if (typeof renderProfile === 'function') renderProfile();
if (typeof renderAccount === 'function') renderAccount();
AP_LAST_PULL = Date.now();
apSyncSet('success', 'SYNCED: Cloud save restored to this device - ' + apSaveStats(data), false);
apToast('β Cloud save restored');
}
async function apWriteCloudSave(){
apSyncSet('busy', 'SYNC: Uploading this device to your accountβ¦', true);
try{
const blob = await encodeSave();
const res = await apRequest('PUT', '/save', { payload: blob }, true);
AP_LAST_PUSH = Date.now();
apSyncSet('success', 'SYNCED: This device is backed up to your account - ' + apSaveStats(apLocalPayload()), false);
apToast('β Save pushed to the cloud');
return res;
}catch(e){
const msg = apErrorText(e);
apSyncSet('error', 'ERROR: ' + msg, false);
apSetError(msg);
return null;
}
}
async function apPullSave(){
if (!AP_SESSION || AP_BUSY) return;
if (typeof sfx === 'function') sfx('ui');
apSetError('');
apSyncSet('busy', 'SYNC: Checking your cloud saveβ¦', true);
try{
const cloud = await apReadCloudSave();
if (!cloud.data){
apSyncSet('success', 'No cloud save exists yet. This device was not changed.', false);
apConfirm(
'There is no cloud save for this account yet. Back up this device now?',
'BACK UP DEVICE', 'NOT NOW',
() => { apWriteCloudSave(); },
() => { apSyncSet('idle', 'No cloud save yet - this device remains your only copy', false); });
return;
}
const local = apLocalPayload();
apSyncSet('idle', 'Cloud save ready - waiting for your confirmation', false);
apConfirm(
apCompareMessage(local, cloud.data, cloud.at, 'Pull found a cloud save. Restore it onto this device?'),
'RESTORE CLOUD', 'CANCEL',
() => { apCommitIncoming(cloud.data); },
() => { apSyncSet('idle', 'Restore canceled - this device was kept unchanged', false); });
}catch(e){
const msg = apErrorText(e);
apSyncSet('error', 'ERROR: Pull failed: ' + msg, false);
apSetError(msg);
}
}
async function apPushSave(){
if (!AP_SESSION || AP_BUSY) return;
if (typeof sfx === 'function') sfx('ui');
apSetError('');
apSyncSet('busy', 'SYNC: Checking the existing cloud saveβ¦', true);
try{
const cloud = await apReadCloudSave();
const local = apLocalPayload();
if (!cloud.data){
await apWriteCloudSave();
return;
}
if (apSavesEquivalent(local, cloud.data)){
AP_LAST_PUSH = Date.now();
apSyncSet('success', 'SYNCED: Cloud and this device already match - nothing was overwritten', false);
return;
}
apSyncSet('idle', 'Existing cloud save found - waiting for your confirmation', false);
apConfirm(
apCompareMessage(local, cloud.data, cloud.at, 'Push will replace the existing cloud save with this device.'),
'OVERWRITE CLOUD', 'CANCEL',
() => { apWriteCloudSave(); },
() => { apSyncSet('idle', 'Upload canceled - the existing cloud save was kept', false); });
}catch(e){
const msg = apErrorText(e);
apSyncSet('error', 'ERROR: Push failed: ' + msg, false);
apSetError(msg);
}
}
/* Sign-in is the moment players expect their account and device to meet. The
comparison is automatic; the overwrite is not. Three explicit outcomes are
offered when the saves differ, and a brand-new account offers a one-tap
backup rather than silently creating one. */
async function apOfferSyncAfterSignIn(){
if (!AP_SESSION || AP_BUSY) return;
apSetError('');
apSyncSet('busy', 'SYNC: Signed in - comparing this device with your cloud saveβ¦', true);
try{
const cloud = await apReadCloudSave();
const local = apLocalPayload();
if (!cloud.data){
apSyncSet('idle', 'Signed in - no cloud save exists for this account yet', false);
apConfirm(
'Signed in successfully. This account has no cloud save yet. Back up this device now?',
'BACK UP DEVICE', 'NOT NOW',
() => { apWriteCloudSave(); },
() => { apSyncSet('idle', 'Signed in - cloud backup skipped for now', false); });
return;
}
if (apSavesEquivalent(local, cloud.data)){
AP_LAST_PULL = Date.now();
apSyncSet('success', 'SYNCED: Signed in - cloud and this device already match', false);
return;
}
apSyncSet('idle', 'Signed in - choose which save you want to keep', false);
apConfirm(
apCompareMessage(local, cloud.data, cloud.at, 'Your account and this device contain different saves.'),
'USE CLOUD', 'NOT NOW',
() => { apCommitIncoming(cloud.data); },
() => { apSyncSet('idle', 'Signed in - no save was changed', false); },
'USE THIS DEVICE',
() => { apWriteCloudSave(); });
}catch(e){
const msg = apErrorText(e);
apSyncSet('error', 'ERROR: Signed in, but save comparison failed: ' + msg, false);
apSetError(msg);
}
}
/* ---- local confirm dialog ------------------------------------------------------
Deliberately a second, separate confirm box rather than reaching into
index.html's #accDlg (owned by account.js) β same visual language, zero
shared DOM. Focus defaults to the non-destructive choice. */
function apConfirmBuildUI(){
if (document.getElementById('apConfirmOverlay')) return;
const ov = document.createElement('div');
ov.id = 'apConfirmOverlay';
ov.className = 'apConfirmOverlay';
ov.innerHTML =
'<div class="apConfirmBox" role="alertdialog" aria-modal="true" aria-labelledby="apConfirmTx">' +
'<div id="apConfirmTx" class="apConfirmTx"></div>' +
'<div class="apConfirmBtns">' +
'<button type="button" class="accBtn ghost" id="apConfirmNo"></button>' +
'<button type="button" class="accBtn" id="apConfirmAlt"></button>' +
'<button type="button" class="accBtn" id="apConfirmYes"></button>' +
'</div>' +
'</div>';
document.body.appendChild(ov);
}
function apConfirm(message, yesLabel, noLabel, onYes, onNo, altLabel, onAlt){
apConfirmBuildUI();
const ov = document.getElementById('apConfirmOverlay');
document.getElementById('apConfirmTx').textContent = message;
const y = document.getElementById('apConfirmYes'), n = document.getElementById('apConfirmNo');
const a = document.getElementById('apConfirmAlt'), row = y.parentElement;
y.textContent = yesLabel || 'CONFIRM';
n.textContent = noLabel || 'CANCEL';
a.textContent = altLabel || '';
a.style.display = altLabel ? '' : 'none';
row.classList.toggle('apConfirmTriple', !!altLabel);
ov.style.display = 'flex';
const close = () => { ov.style.display = 'none'; y.onclick = null; n.onclick = null; a.onclick = null; };
y.onclick = () => { close(); if (onYes) onYes(); };
n.onclick = () => { close(); if (onNo) onNo(); };
a.onclick = () => { close(); if (onAlt) onAlt(); };
requestAnimationFrame(() => n.focus());
}
/* ---- small helpers -------------------------------------------------------------- */
function apToast(msg){ if (typeof toast === 'function') toast(msg); }
function apEsc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
function apSetError(msg){
const el = document.getElementById('apErr');
if (!el) return;
if (msg){ el.textContent = msg; el.style.display = 'block'; }
else { el.textContent = ''; el.style.display = 'none'; }
}
function apRenderBusy(b){
AP_BUSY = b;
const body = document.getElementById('apBody');
if (body) body.classList.toggle('apBusy', b);
}
/* ---- UI: html builders ------------------------------------------------------------ */
function apNoServerHtml(){
return '' +
'<div class="apEmpty">' +
'<div class="apEmptyEm">β</div>' +
'<div class="apEmptyTx">No account server is set up for this build.</div>' +
'<div class="apEmptyHint">Your progress is already safe β it\'s saved on this device. To carry a career to another phone, export a file from <b>Profile βΈ Local Save File</b>.</div>' +
'<button type="button" class="accBtn ghost" id="apSetSrvBtn">β Set server URL</button>' +
'</div>';
}
function apCheckingHtml(){
return '<div class="apEmpty"><div class="spin apEmptyEm">β</div><div class="apEmptyTx">Checking for an account serverβ¦</div></div>';
}
function apFormHtml(){
const signIn = AP_TAB !== 'register';
return '' +
'<div class="apTabs" role="tablist">' +
'<button type="button" class="apTab' + (signIn ? ' on' : '') + '" role="tab" aria-selected="' + signIn + '" data-tab="signin">SIGN IN</button>' +
'<button type="button" class="apTab' + (!signIn ? ' on' : '') + '" role="tab" aria-selected="' + (!signIn) + '" data-tab="register">REGISTER</button>' +
'</div>' +
'<form id="apForm" novalidate>' +
'<label class="apLbl" for="apEmail">EMAIL</label>' +
'<input id="apEmail" type="email" inputmode="email" autocomplete="email" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="you@example.com">' +
'<label class="apLbl" for="apPass">PASSWORD</label>' +
'<div class="apPassWrap">' +
'<input id="apPass" type="password" autocomplete="' + (signIn ? 'current-password' : 'new-password') + '" placeholder="' + (signIn ? 'Your password' : 'At least 8 characters') + '">' +
'<button type="button" class="apEye" id="apEyeBtn" aria-label="Show password" aria-pressed="false">π</button>' +
'</div>' +
(signIn ? '' :
'<label class="apLbl" for="apPass2">CONFIRM PASSWORD</label>' +
'<input id="apPass2" type="password" autocomplete="new-password" placeholder="Type it again">' +
'<label class="apLbl" for="apUser">USERNAME <i>(optional β how friends find you)</i></label>' +
'<input id="apUser" type="text" autocomplete="username" autocapitalize="off" autocorrect="off" ' +
'spellcheck="false" maxlength="16" placeholder="3-16 characters, a-z 0-9 _">' +
apAgeFieldHtml()) +
'<div class="apErr" id="apErr" role="alert" style="display:none"></div>' +
'<button type="submit" class="mbtn apSubmit" id="apSubmitBtn">' + (signIn ? 'βΆ SIGN IN' : 'βΆ CREATE ACCOUNT') + '</button>' +
'</form>' +
'<div class="apFoot">No account needed β a real <b>.mfsave file</b> moves your career between phones. Profile βΈ Local Save File.</div>';
}
/* ---- age gate ---------------------------------------------------------------
MASSFRONT has player-to-player communication, so it is a 13+ product.
This is a NEUTRAL age screen, which is the part that matters: it asks for a
birth month and year rather than "are you 13?", because a yes/no question
about an age limit tells the person the answer they need to give. The date is
computed HERE and only the boolean leaves the device β the server never
receives or stores a date of birth. Collecting a child's birth date in order
to decide whether you are allowed to collect a child's data is precisely the
trap to avoid.
A refusal is remembered locally so the form cannot simply be re-rolled. It is
a screen, not a proof of age; nothing on the open internet is. */
const AP_AGE_MIN = 13;
const AP_AGE_FAIL_KEY = 'mf_age_refused_v1';
function apAgeRefused(){
try{ return localStorage.getItem(AP_AGE_FAIL_KEY) === '1'; }catch(e){ return false; }
}
function apAgeFieldHtml(){
const now = new Date(), thisYear = now.getFullYear();
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
let mo = '<option value="">Month</option>';
for (let i = 0; i < 12; i++) mo += '<option value="' + i + '">' + months[i] + '</option>';
let yr = '<option value="">Year</option>';
for (let y = thisYear; y >= thisYear - 100; y--) yr += '<option value="' + y + '">' + y + '</option>';
return '<label class="apLbl" for="apDobY">DATE OF BIRTH</label>' +
'<div class="apDobRow">' +
'<select id="apDobM" aria-label="Birth month">' + mo + '</select>' +
'<select id="apDobY" aria-label="Birth year">' + yr + '</select>' +
'</div>' +
'<div class="apDobNote">Used once to check you are ' + AP_AGE_MIN +
' or over. It is not sent anywhere and not stored.</div>';
}
/* null = not answered yet; true/false = the computed result. */
function apAgeOk(){
const mEl = document.getElementById('apDobM'), yEl = document.getElementById('apDobY');
if (!mEl || !yEl || mEl.value === '' || yEl.value === '') return null;
const m = parseInt(mEl.value, 10), y = parseInt(yEl.value, 10);
const now = new Date();
let age = now.getFullYear() - y;
if (now.getMonth() < m) age--; // birthday not reached this year
return age >= AP_AGE_MIN;
}
function apSignedInHtml(){
const s = AP_SESSION;
const statusTx =
AP_SYNC_MESSAGE ? AP_SYNC_MESSAGE :
AP_BUSY ? 'β Workingβ¦' :
s.offline ? 'β Could not verify this session β check your connection' :
(AP_LAST_PUSH || AP_LAST_PULL) ? 'β Last synced ' + apRelTime(Math.max(AP_LAST_PUSH, AP_LAST_PULL)) :
'β Not synced yet this session';
const statusClass = AP_SYNC_KIND === 'success' ? ' apSyncOk' : AP_SYNC_KIND === 'error' ? ' apSyncError' :
AP_SYNC_KIND === 'busy' ? ' apSyncBusy' : '';
const disabled = AP_BUSY ? ' disabled aria-disabled="true"' : '';
return '' +
'<div class="apAccHead">' +
'<div class="apAv">β</div>' +
'<div class="apWho"><b>' + apEsc(s.username || s.email) + '</b><span>' +
(s.username ? apEsc(s.email) + ' Β· ' : '') + 'Signed in' + (s.offline ? ' Β· unverified' : '') + '</span></div>' +
'</div>' +
/* Accounts created before the age gate shipped carry ageOk=0. They are
asked once, here, and no social surface opens until they answer. */
(s.ageOk === false ?
'<div class="apAgeAsk">' +
'<b>ONE-TIME AGE CHECK</b>' +
'<p>MASSFRONT is adding player-to-player features, so accounts need to confirm they are ' +
AP_AGE_MIN + ' or over. Nothing is stored except the answer.</p>' +
apAgeFieldHtml() +
'<button type="button" class="accBtn" id="apAgeBtn"' + disabled + '>CONFIRM</button>' +
'</div>' : '') +
'<div class="apUserRow">' +
'<label class="apLbl" for="apUserSet">USERNAME</label>' +
'<div class="apUserSetRow">' +
'<input id="apUserSet" type="text" autocapitalize="off" autocorrect="off" spellcheck="false" ' +
'maxlength="16" placeholder="3-16 characters" value="' + apEsc(s.username || '') + '">' +
'<button type="button" class="accBtn" id="apUserBtn"' + disabled + '>' +
(s.username ? 'CHANGE' : 'CLAIM') + '</button>' +
'</div>' +
'<div class="apDobNote">This is how friends will find you. Your email is never shown to other players.</div>' +
'</div>' +
'<div class="apSyncRow' + statusClass + '" role="status" aria-live="polite">' + apEsc(statusTx) + '</div>' +
'<div class="apErr" id="apErr" role="alert" style="display:none"></div>' +
'<div class="apBtnCol">' +
'<button type="button" class="accBtn" id="apPushBtn"' + disabled + '>β¬ PUSH SAVE TO CLOUD</button>' +
'<button type="button" class="accBtn" id="apPullBtn"' + disabled + '>β¬ PULL SAVE FROM CLOUD</button>' +
'<button type="button" class="accBtn ghost" id="apSignOutBtn"' + disabled + '>SIGN OUT</button>' +
'<button type="button" class="accBtn danger" id="apDelBtn"' + disabled + '>' +
(AP_DEL_ARMED ? 'β TAP AGAIN TO DELETE PERMANENTLY' : 'DELETE ACCOUNT') + '</button>' +
'</div>';
}
/* ---- UI: wiring (re-run every time the innerHTML above is rebuilt) ------------------ */
function apWireNoServer(){
const b = document.getElementById('apSetSrvBtn');
if (b) apTapBind(b, () => {
let v = null;
try{ v = prompt('Account server URL\n\nThe https:// base address of your MASSFRONT auth worker.\n\nLeave blank to clear.', apEndpoint() || ''); }catch(e){}
if (v === null) return;
apSetEndpoint(v);
apToast(v.trim() ? 'Saved β checkingβ¦' : 'Cleared');
});
}
function apWireForm(){
const form = document.getElementById('apForm');
/* Keeps Enter and assistive-tech activation working. */
if (form) form.addEventListener('submit', e => { e.preventDefault(); apSubmit(); });
document.querySelectorAll('#apBody .apTab').forEach(b => {
apTapBind(b, () => {
AP_TAB = b.dataset.tab;
apRenderSignedOut();
const em = document.getElementById('apEmail'); if (em) em.focus();
});
});
/* The submit control is <button type="submit">: when the click is cancelled the
form never gets a submit event either, so drive apSubmit() from the pointer
binding. Only when mfBindTap is present β its fallback would otherwise fire
alongside the form's own submit handler and double-submit. */
if (typeof mfBindTap === 'function')
apTapBind(document.getElementById('apSubmitBtn'), () => { if (!AP_BUSY) apSubmit(); });
apTapBind(document.getElementById('apEyeBtn'), () => {
const eye = document.getElementById('apEyeBtn');
const p = document.getElementById('apPass'); if (!p || !eye) return;
const show = p.type === 'password';
p.type = show ? 'text' : 'password';
eye.setAttribute('aria-pressed', String(show));
eye.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
});
}
function apWireSignedIn(){
/* Samsung Internet and Android WebView may cancel the compatibility click
after a small finger drift inside this scroll area. Use the game's
pointer-up/slop binding when available; click remains the keyboard path. */
const bind = (el, fn) => {
if (!el) return;
if (typeof mfBindTap === 'function') mfBindTap(el, fn);
else el.addEventListener('click', fn);
};
bind(document.getElementById('apPushBtn'), apPushSave);
bind(document.getElementById('apPullBtn'), apPullSave);
bind(document.getElementById('apSignOutBtn'), apDoSignOut);
bind(document.getElementById('apUserBtn'), apDoClaimUsername);
bind(document.getElementById('apDelBtn'), apDoDeleteAccount);
bind(document.getElementById('apAgeBtn'), apDoConfirmAge);
}
/* An endpoint this build knows about but the deployed worker does not yet
answer comes back 404. Say so plainly rather than showing a generic failure β
"not deployed yet" and "broken" are different problems for whoever reads it. */
function apNotDeployed(e){
return e && (e.status === 404 || e.kind === 'no_server');
}
async function apDoClaimUsername(){
if (AP_BUSY) return;
const el = document.getElementById('apUserSet');
const v = (el && el.value || '').trim();
if (!/^[a-z0-9_]{3,16}$/i.test(v)){
apSetError('Username must be 3-16 characters β letters, numbers and underscore only.');
if (el) el.focus(); return;
}
apSetError(''); AP_BUSY = true; apRenderBusy(true);
try{
const d = await apRequest('POST', '/username', { username: v }, true);
if (AP_SESSION) { AP_SESSION.username = d.username || v; apSaveSession(); }
sfx('level'); apToast('β Username set to ' + (d.username || v));
}catch(e){
sfx('alarm');
apSetError(apNotDeployed(e)
? 'Usernames need the updated account server β it has not been deployed yet.'
: apErrorText(e));
}
AP_BUSY = false; apRenderBusy(false); apRender();
if (typeof renderMetaHead === 'function') renderMetaHead();
}
async function apDoConfirmAge(){
if (AP_BUSY) return;
const ok = apAgeOk();
if (ok === null){ apSetError('Select your date of birth to continue.'); return; }
if (ok === false){
apSetError('MASSFRONT accounts are for players ' + AP_AGE_MIN + ' and over. '
+ 'You can keep playing on this device β your progress is saved locally.');
return;
}
apSetError(''); AP_BUSY = true; apRenderBusy(true);
try{
await apRequest('POST', '/age', { ageOk: true }, true);
if (AP_SESSION) { AP_SESSION.ageOk = true; apSaveSession(); }
sfx('level'); apToast('β Thanks β that is confirmed.');
}catch(e){
sfx('alarm');
apSetError(apNotDeployed(e)
? 'This needs the updated account server β it has not been deployed yet.'
: apErrorText(e));
}
AP_BUSY = false; apRenderBusy(false); apRender();
}
/* Two taps, and the second one says exactly what it does. The arm resets on any
re-render, so leaving the panel and coming back does not leave it primed. */
let AP_DEL_ARMED = false;
async function apDoDeleteAccount(){
if (AP_BUSY) return;
if (!AP_DEL_ARMED){
AP_DEL_ARMED = true; sfx('alarm');
apSetError('This permanently deletes your account and its cloud save. '
+ 'Your career on THIS device is not touched. Tap again to confirm.');
apRender(); return;
}
AP_DEL_ARMED = false;
apSetError(''); AP_BUSY = true; apRenderBusy(true);
try{
await apDeleteAccount();
sfx('level'); apToast('β Account deleted.');
}catch(e){
sfx('alarm');
apSetError(apNotDeployed(e)
? 'Account deletion needs the updated account server β it has not been deployed yet.'
: apErrorText(e));
}
AP_BUSY = false; apRenderBusy(false); apRender();
}
async function apDoSignOut(){
if (AP_BUSY) return;
sfx('ui');
AP_BUSY = true; apRenderBusy(true);
await apLogout();
AP_BUSY = false;
apToast('Signed out β your progress stays on this device');
apRender();
}
/* ---- UI: state dispatch ------------------------------------------------------------ */
function apRenderNoServer(){ const b = document.getElementById('apBody'); if (!b) return; b.innerHTML = apNoServerHtml(); apWireNoServer(); }
function apRenderSignedOut(){ const b = document.getElementById('apBody'); if (!b) return; b.innerHTML = apFormHtml(); apWireForm(); }
function apRenderSignedIn(){ const b = document.getElementById('apBody'); if (!b) return; b.innerHTML = apSignedInHtml(); apWireSignedIn(); }
function apRender(){
const body = document.getElementById('apBody');
if (!body) return;
if (!AP_CFG.resolved){ body.innerHTML = apCheckingHtml(); return; }
if (!apEndpoint()){ apRenderNoServer(); return; }
if (AP_SESSION){ apRenderSignedIn(); return; }
apRenderSignedOut();
}
/* ---- form submit ------------------------------------------------------------------- */
async function apSubmit(){
if (AP_BUSY) return;
const emailEl = document.getElementById('apEmail');
const passEl = document.getElementById('apPass');
const pass2El = document.getElementById('apPass2');
if (!emailEl || !passEl) return;
const isRegister = AP_TAB === 'register';
const ev = apValidEmail(emailEl.value);
if (!ev.ok){ apSetError(ev.msg); emailEl.focus(); return; }
const pass = passEl.value || '';
if (!pass){ apSetError('Enter a password.'); passEl.focus(); return; }
if (pass.length < 8){ apSetError('Password needs at least 8 characters.'); passEl.focus(); return; }
if (pass.length > 256){ apSetError('Password is too long β 256 characters max.'); passEl.focus(); return; }
let uname = '';
if (isRegister){
const pass2 = (pass2El && pass2El.value) || '';
if (pass !== pass2){ apSetError("Passwords don't match β check both fields."); if (pass2El) pass2El.focus(); return; }
const uEl = document.getElementById('apUser');
uname = (uEl && uEl.value || '').trim();
if (uname && !/^[a-z0-9_]{3,16}$/i.test(uname)){
apSetError('Username must be 3-16 characters β letters, numbers and underscore only.');
if (uEl) uEl.focus(); return;
}
/* The gate runs BEFORE the network call, so an under-13 registration never
creates an account and never transmits anything. */
const ok = apAgeOk();
if (ok === null){ apSetError('Select your date of birth to continue.');
const d=document.getElementById('apDobM'); if(d) d.focus(); return; }
if (ok === false){
try{ localStorage.setItem(AP_AGE_FAIL_KEY, '1'); }catch(e){}
apSetError('You need to be ' + AP_AGE_MIN + ' or over to create a MASSFRONT account. '
+ 'You can keep playing on this device β your progress is saved locally.');
return;
}
}
apSetError('');
AP_BUSY = true; apRenderBusy(true);
try{
if (isRegister) await apRegister(ev.value, pass, uname);
else await apLogin(ev.value, pass);
AP_BUSY = false; apRenderBusy(false);
sfx('level');
apToast(isRegister ? 'β Account created β signed in as ' + ev.value : 'β Signed in as ' + ev.value);
apRender();
await apOfferSyncAfterSignIn();
}catch(e){
AP_BUSY = false; apRenderBusy(false);
sfx('alarm');
if (e.status === 409){
/* Registering an email that already exists β the useful next step is
signing in, not staring at an error, so switch there and keep what
they typed. */
AP_TAB = 'signin';
apRenderSignedOut();
const em = document.getElementById('apEmail'); if (em) em.value = ev.value;
apSetError(apErrorText(e));
const pw = document.getElementById('apPass'); if (pw) pw.focus();
} else {
apSetError(apErrorText(e));
}
}
}
/* ---- modal shell: open / close / focus management -------------------------------- */
function apKeyHandler(e){
if (e.key === 'Escape'){ e.stopPropagation(); apClose(); return; }
if (e.key === 'Tab') apTrapFocus(e);
}
function apTrapFocus(e){
const ov = document.getElementById('apOverlay');
if (!ov) return;
const items = Array.prototype.slice.call(ov.querySelectorAll('button,input,[tabindex]'))
.filter(el => !el.disabled && el.offsetParent !== null);
if (!items.length) return;
const first = items[0], last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first){ e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last){ e.preventDefault(); first.focus(); }
}
function apOpen(triggerEl){
const ov = document.getElementById('apOverlay');
if (!ov) return;
/* Prefer an explicit trigger element over document.activeElement: apBindTap
calls preventDefault() on the opening pointerdown, so the browser never
runs its default "focus the target" action, and for a mouse-initiated
open document.activeElement at this instant is still whatever had focus
BEFORE the click (often <body>, nothing sensible to return to later).
Passing the actual trigger button makes "restore focus on close" work
for mouse/touch users too, not just keyboard users (who already have it
focused via Tab before Enter/Space activates it, so document.activeElement
happens to be correct for them regardless). */
AP_LAST_FOCUS = triggerEl || document.activeElement;
ov.style.display = 'flex';
document.addEventListener('keydown', apKeyHandler, true);
apRender();
/* Synchronous, not requestAnimationFrame: the display:flex above already
took effect on this same tick, so the close button is focusable right
now. Deferring a frame used to lose the race against a keyboard user who
Tabs immediately after Enter/Space-activating the trigger β focus was
still outside the modal when their Tab landed, so the (also-correct)
trap below had nothing to trap yet and let them tab into the page
underneath. */
const f = ov.querySelector('input,button');
if (f && f.focus) f.focus();
}
function apClose(){
AP_DEL_ARMED = false;
const ov = document.getElementById('apOverlay');
if (!ov) return;
/* Dismissing the gate by β counts as answering it β otherwise the same modal
returns on the next launch and reads as a bug. */
if (typeof AP_GATE_OPEN !== 'undefined' && AP_GATE_OPEN){
AP_GATE_OPEN = false;
try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
}
if (typeof apGateFoot === 'function') apGateFoot(false);
ov.style.display = 'none';
document.removeEventListener('keydown', apKeyHandler, true);
if (AP_LAST_FOCUS && AP_LAST_FOCUS.focus) AP_LAST_FOCUS.focus();
}
/* ---- build the modal DOM once (createElement/innerHTML, appended to body) --------- */
function apBuildUI(){
if (document.getElementById('apOverlay')) return;
const ov = document.createElement('div');
ov.id = 'apOverlay';
ov.className = 'apOverlay';
ov.style.display = 'none';
ov.innerHTML =
'<div class="apBox" role="dialog" aria-modal="true" aria-labelledby="apTitleTx">' +
'<div class="apHead">' +
'<div id="apTitleTx" class="apTitleTx">⬑ ACCOUNT</div>' +
'<button type="button" class="apClose" id="apCloseBtn" aria-label="Close account panel">β</button>' +
'</div>' +
'<div class="apScroll" id="apBody"></div>' +
'</div>';
document.body.appendChild(ov);
apTapBind(document.getElementById('apCloseBtn'), apClose);
ov.addEventListener('pointerdown', (e) => {
if (e.target === ov) apClose();
});
apConfirmBuildUI();
}
/* ---- entry point: one button appended into the existing .menuStrip ---------------- */
/* pointerdown alone (what the sibling .sbtn buttons in index.html use, via
main.js) never fires for a keyboard Enter/Space activation β only a real
pointer/touch/mouse interaction dispatches pointerdown. click fires for
both, but binding click ALONE reintroduces the ~300ms tap latency this
game avoids everywhere else. Binding both with a debounce (a real tap
fires pointerdown then click within the same gesture) is exactly the
pattern src/updater.js's own tap() helper uses for this identical
problem β mirrored here rather than imported, since nothing in this file
may reference updater.js internals. */
function apBindTap(el, fn){
let last = 0;
const go = e => {
/* An OTA restart can finish while the install finger is still lifting.
The replacement shell publishes this brief guard so that release cannot
be mistaken for a fresh tap on the newly injected Account button. */
/* Honour it ONLY within its intended brief window. That guard is a bare
window value that the boot/OTA shell parks far in the future
(Number.MAX_SAFE_INTEGER) until a release hook lowers it to ~now+450ms.
On the packaged-into-body OTA path that hook can fail to run, stranding
the value at the sentinel β and because apBindTap is the ONLY input path
that reads it, that leaves this Account button (hence the whole portal)
permanently dead while every pointerdown/mfBindTap menu control, which
never consults the guard, keeps working. A real install-gesture guard is
never more than a fraction of a second out, so treat any deadline past a
wide ceiling as that stuck sentinel and ignore it rather than suppressing
the tap forever. */
const until = Number(window.__MASSFRONT_INPUT_GUARD_UNTIL||0);
const nowMs = Date.now();
if(nowMs < until && until - nowMs <= 8000){
e.preventDefault();
e.stopImmediatePropagation();
return;
}
e.stopPropagation();
/* Without this, the browser's own default action for pointerdown
focuses `el` (the trigger) AFTER this handler returns β clobbering
the focus apOpen() just moved onto the close button inside the
modal. Harmless to prevent here: el is a plain <button>, not
something that depends on default pointerdown behaviour (text
selection, scrolling, drag). */
e.preventDefault();
const t = (typeof performance !== 'undefined' ? performance.now() : Date.now());
if (t - last < 350) return;
last = t;
fn();
};
el.addEventListener('pointerdown', go);
el.addEventListener('click', go);
}
/* Every control INSIDE this dialog was bound with a plain click. Android WebView
cancels the compatibility click after a few pixels of finger drift, so on a
phone the SIGN IN / REGISTER tabs, the submit button and the reveal toggle
were completely dead while the pointerdown-bound Account button that opened
the dialog still worked β exactly the symptom reported. apWireSignedIn()
already guarded its own buttons this way; the signed-OUT form, which is what
a new player sees, was missed. Same binding, one shared helper. */
function apTapBind(el, fn){
if (!el) return false;
if (typeof mfBindTap === 'function'){ mfBindTap(el, fn); return true; }
el.addEventListener('click', fn);
return false;
}
/* Account used to be a fifth icon in .menuStrip while Profile ALREADY carried an
ACCOUNT tab β two doors into one room, and the strip door was the one nobody
could find a reason for. The tab is now the only route. Any stale button from
a previous shell is removed, and .apStrip5 is dropped so the strip returns to
its four-column grid. */
function apInjectMenuButton(){
const old = document.getElementById('acctBtn');
if (old && old.parentNode) old.parentNode.removeChild(old);
const strip = document.querySelector('.menuStrip');
if (strip) strip.classList.remove('apStrip5');
}
/* ---- first-run gate ---------------------------------------------------------------
The sign-in screen now appears between the launch title and the menu. It is a
GATE, not a WALL: "PLAY OFFLINE" dismisses it and the flag below stops it ever
asking again, so the game keeps working with no signal and no account. That
distinction is deliberate β a hard requirement would break offline play, put a
network call before any consent, and pull App Store 5.1.1(v)'s account-deletion
requirement onto a build that has no deletion path yet. */
const AP_GATE_KEY = 'mf_auth_gate_v1';
let AP_GATE_OPEN = false;
function apGateSeen(){
try{ return localStorage.getItem(AP_GATE_KEY) === '1'; }catch(e){ return true; }
}
function apGateSatisfied(){
try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
if (AP_GATE_OPEN){ AP_GATE_OPEN = false; apClose(); }
}
function apGateFoot(on){
const box = document.querySelector('#apOverlay .apBox');
let f = document.getElementById('apGateFoot');
if (!on){ if (f && f.parentNode) f.parentNode.removeChild(f); return; }
if (!box || f) return;
f = document.createElement('div');
f.id = 'apGateFoot';
f.innerHTML = '<button type="button" class="mbtn alt" id="apOfflineBtn">PLAY OFFLINE</button>' +
'<small>You can sign in any time from Profile βΈ Account. ' +
'Your progress is saved on this device either way.</small>';
box.appendChild(f);
apTapBind(document.getElementById('apOfflineBtn'), () => {
if (typeof sfx === 'function'){ try{ sfx('ui'); }catch(e){} }
apGateSatisfied();
});
}
/* Called by intro.js the moment the launch title hands off to the front end. */
function mfAuthGate(){
try{
if (AP_SESSION) return; // already signed in β nothing to ask
if (apGateSeen()) return; // asked once; never nag again
if (!document.getElementById('apOverlay')) return;
AP_GATE_OPEN = true;
apGateFoot(true);
/* On the gate this modal is a welcome, not a settings panel. */
const t = document.getElementById('apTitleTx');
if (t) t.textContent = '\u2b21 SIGN IN OR REGISTER';
apOpen(null);
}catch(e){ AP_GATE_OPEN = false; }
}
/* ---- boot -------------------------------------------------------------------------- */
function initAuthPortal(){
apBuildUI();
apInjectMenuButton();
apLoadSession();
apResolveEndpoint().then(() => {
apRender();
if (AP_SESSION) apVerifySession();
});
}
|