feat: add savegame export/import via JSON file and clipboard in executive profile modal

This commit is contained in:
Christian 2026-10-16 17:53:05 +02:00
parent c566e294bc
commit 3d20bc315b
5 changed files with 621 additions and 21 deletions

View file

@ -287,3 +287,216 @@
line-height: 1.5; line-height: 1.5;
} }
/* Profile & Savegame Management Styles */
.modal-profile-card {
max-width: 480px;
gap: 14px;
}
.modal-header-text {
flex: 1;
}
.btn-modal-close {
padding: 6px;
font-size: 1.1rem;
cursor: pointer;
background: transparent;
border: none;
color: var(--text-muted);
border-radius: var(--radius-sm);
}
.btn-modal-close:hover {
background: var(--bg-surface-sub);
color: var(--text-primary);
}
.profile-status-box {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-surface-sub);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 10px 14px;
}
.profile-avatar-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: var(--primary-blue-light);
color: var(--primary-blue);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
}
.profile-info-content {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.profile-name-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.92rem;
color: var(--text-primary);
}
.badge-pill-soft {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 4px;
background: var(--border-subtle);
color: var(--text-secondary);
}
.badge-pill-soft.badge-online {
background: var(--emerald-light);
color: var(--emerald-dark);
}
.profile-sub-desc {
font-size: 0.72rem;
color: var(--text-muted);
}
.profile-sub-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.profile-save-section {
border-top: 1px solid var(--border-color);
padding-top: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.save-section-header {
display: flex;
flex-direction: column;
gap: 2px;
}
.save-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 700;
color: var(--text-primary);
}
.save-section-title iconify-icon {
font-size: 1.1rem;
color: var(--primary-blue);
}
.save-section-sub {
font-size: 0.72rem;
color: var(--text-muted);
}
.save-actions-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
@media (max-width: 480px) {
.save-actions-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.btn-save-action {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--bg-surface-sub);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 10px 6px;
color: var(--text-primary);
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: all 0.15s;
user-select: none;
}
.btn-save-action iconify-icon {
font-size: 1.3rem;
color: var(--primary-blue);
}
.btn-save-action:hover {
background: var(--bg-surface);
border-color: var(--primary-blue);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.btn-upload-label {
margin: 0;
box-sizing: border-box;
}
.import-text-container {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-surface-sub);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 10px;
margin-top: 4px;
}
.modal-textarea {
width: 100%;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: 0.78rem;
font-family: var(--font-mono);
outline: none;
resize: vertical;
box-sizing: border-box;
background: var(--bg-surface);
color: var(--text-primary);
}
.modal-textarea:focus {
border-color: var(--primary-blue);
}
.import-text-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn-sm {
padding: 6px 12px !important;
font-size: 0.75rem !important;
}

View file

@ -141,17 +141,38 @@
<!-- Toast Notifications Container --> <!-- Toast Notifications Container -->
<div id="toast-container"></div> <div id="toast-container"></div>
<!-- Authentication & Cloud Sync Modal --> <!-- Authentication, Profile & Save Management Modal -->
<div id="auth-modal" class="modal-overlay"> <div id="auth-modal" class="modal-overlay">
<div class="modal-card"> <div class="modal-card modal-profile-card">
<div class="modal-header"> <div class="modal-header">
<iconify-icon icon="lucide:shield-check" class="modal-icon"></iconify-icon> <iconify-icon icon="lucide:user-circle" class="modal-icon"></iconify-icon>
<div> <div class="modal-header-text">
<h3 class="modal-title">Executive Cloud Terminal</h3> <h3 class="modal-title" id="auth-modal-title">Executive Profile & Data</h3>
<p class="modal-subtitle">Save your production empire and sync across devices.</p> <p class="modal-subtitle" id="auth-modal-subtitle">Manage cloud synchronization and local savegames.</p>
</div>
<button id="btn-close-auth-x" class="btn-icon-soft btn-modal-close" title="Close">
<iconify-icon icon="lucide:x"></iconify-icon>
</button>
</div>
<!-- Profile Info Banner (Status indicator) -->
<div id="profile-status-banner" class="profile-status-box">
<div class="profile-avatar-icon">
<iconify-icon icon="lucide:shield" id="profile-status-icon"></iconify-icon>
</div>
<div class="profile-info-content">
<div class="profile-name-row">
<strong id="profile-display-name">Executive Guest</strong>
<span id="profile-badge-pill" class="badge-pill-soft">Offline Guest</span>
</div>
<div class="profile-sub-desc" id="profile-sync-desc">
Progress is saved locally in this browser.
</div>
</div> </div>
</div> </div>
<!-- Account Forms (When Logged Out: Login/Register) -->
<div id="auth-guest-section" class="profile-sub-section">
<div class="input-field-group"> <div class="input-field-group">
<label>Executive Username</label> <label>Executive Username</label>
<input type="text" id="auth-username" class="modal-input" placeholder="e.g. ChiefExecutive" autocomplete="username"> <input type="text" id="auth-username" class="modal-input" placeholder="e.g. ChiefExecutive" autocomplete="username">
@ -162,20 +183,81 @@
<input type="password" id="auth-password" class="modal-input" placeholder="••••••••" autocomplete="current-password"> <input type="password" id="auth-password" class="modal-input" placeholder="••••••••" autocomplete="current-password">
</div> </div>
<!-- In-modal Error Feedback (Replacing alert()) -->
<div id="auth-error-msg" class="modal-error-box" style="display: none;"></div>
<!-- Honeypot field for bot protection --> <!-- Honeypot field for bot protection -->
<div style="display: none;" aria-hidden="true"> <div style="display: none;" aria-hidden="true">
<input type="text" id="auth-website-hp" name="website_hp" tabindex="-1" autocomplete="off"> <input type="text" id="auth-website-hp" name="website_hp" tabindex="-1" autocomplete="off">
</div> </div>
<div class="modal-actions-row"> <div class="modal-actions-row">
<button id="btn-login-submit" class="btn-primary-corp">Login</button> <button id="btn-login-submit" class="btn-primary-corp">
<button id="btn-register-submit" class="btn-corp-secondary">Register New</button> <iconify-icon icon="lucide:log-in"></iconify-icon>
Login
</button>
<button id="btn-register-submit" class="btn-corp-secondary">
<iconify-icon icon="lucide:user-plus"></iconify-icon>
Register New
</button>
</div>
</div> </div>
<button id="btn-close-auth" class="btn-text-close">Continue as Guest</button> <!-- Account Actions (When Logged In: Cloud Sync, Logout) -->
<div id="auth-user-section" class="profile-sub-section" style="display: none;">
<div class="modal-actions-row">
<button id="btn-cloud-sync-now" class="btn-corp-secondary">
<iconify-icon icon="lucide:refresh-cw"></iconify-icon>
Sync Cloud Now
</button>
<button id="btn-logout-submit" class="btn-corp-secondary text-warning">
<iconify-icon icon="lucide:log-out"></iconify-icon>
Log Out
</button>
</div>
</div>
<!-- In-modal Error Feedback (Replacing alert()) -->
<div id="auth-error-msg" class="modal-error-box" style="display: none;"></div>
<!-- Save Management Section: Export & Import (Available for both Guests & Registered Users) -->
<div class="profile-save-section">
<div class="save-section-header">
<div class="save-section-title">
<iconify-icon icon="lucide:hard-drive"></iconify-icon>
<span>Savegame Management (Backup & Restore)</span>
</div>
<span class="save-section-sub">Export to JSON file or clipboard, or restore from a previous save.</span>
</div>
<div class="save-actions-grid">
<button id="btn-export-file" class="btn-save-action" title="Download backup savegame as a JSON file">
<iconify-icon icon="lucide:download"></iconify-icon>
<span>Export File</span>
</button>
<button id="btn-export-clipboard" class="btn-save-action" title="Copy raw save string to system clipboard">
<iconify-icon icon="lucide:copy"></iconify-icon>
<span>Copy Save</span>
</button>
<label for="import-file-input" class="btn-save-action btn-upload-label" title="Upload and load a backup savegame JSON file">
<iconify-icon icon="lucide:upload"></iconify-icon>
<span>Import File</span>
</label>
<input type="file" id="import-file-input" accept=".json,text/plain" style="display: none;">
<button id="btn-import-clipboard" class="btn-save-action" title="Paste raw save text to restore your enterprise">
<iconify-icon icon="lucide:clipboard-paste"></iconify-icon>
<span>Paste Save</span>
</button>
</div>
<!-- Textarea for manual paste import (hidden by default, toggled via Paste Save) -->
<div id="import-text-box" class="import-text-container" style="display: none;">
<textarea id="import-raw-input" class="modal-textarea" rows="3" placeholder="Paste your exported savegame JSON string here..."></textarea>
<div class="import-text-actions">
<button id="btn-import-text-apply" class="btn-primary-corp btn-sm">Load & Restore</button>
<button id="btn-import-text-cancel" class="btn-corp-secondary btn-sm">Cancel</button>
</div>
</div>
</div>
<button id="btn-close-auth" class="btn-text-close">Close</button>
</div> </div>
</div> </div>

View file

@ -140,15 +140,43 @@ class ProductionApp {
} }
}); });
// Auth Modal Close & Submit // Auth & Profile Modal Event Listeners
const closeAuth = document.getElementById('btn-close-auth'); const closeAuth = document.getElementById('btn-close-auth');
if (closeAuth) closeAuth.onclick = () => this.closeAuthModal(); if (closeAuth) closeAuth.onclick = () => this.closeAuthModal();
const closeAuthX = document.getElementById('btn-close-auth-x');
if (closeAuthX) closeAuthX.onclick = () => this.closeAuthModal();
const loginBtn = document.getElementById('btn-login-submit'); const loginBtn = document.getElementById('btn-login-submit');
if (loginBtn) loginBtn.onclick = () => this.handleLogin(); if (loginBtn) loginBtn.onclick = () => this.handleLogin();
const registerBtn = document.getElementById('btn-register-submit'); const registerBtn = document.getElementById('btn-register-submit');
if (registerBtn) registerBtn.onclick = () => this.handleRegister(); if (registerBtn) registerBtn.onclick = () => this.handleRegister();
const logoutBtn = document.getElementById('btn-logout-submit');
if (logoutBtn) logoutBtn.onclick = () => this.handleLogout();
const cloudSyncBtn = document.getElementById('btn-cloud-sync-now');
if (cloudSyncBtn) cloudSyncBtn.onclick = () => this.handleCloudSyncNow();
// Savegame Management: Export & Import
const exportFileBtn = document.getElementById('btn-export-file');
if (exportFileBtn) exportFileBtn.onclick = () => this.handleExportSaveFile();
const exportCopyBtn = document.getElementById('btn-export-clipboard');
if (exportCopyBtn) exportCopyBtn.onclick = () => this.handleExportSaveClipboard();
const importFileInput = document.getElementById('import-file-input');
if (importFileInput) importFileInput.onchange = (e) => this.handleImportSaveFile(e);
const importClipBtn = document.getElementById('btn-import-clipboard');
if (importClipBtn) importClipBtn.onclick = () => this.toggleImportTextBox();
const importApplyBtn = document.getElementById('btn-import-text-apply');
if (importApplyBtn) importApplyBtn.onclick = () => this.handleApplyImportText();
const importCancelBtn = document.getElementById('btn-import-text-cancel');
if (importCancelBtn) importCancelBtn.onclick = () => this.toggleImportTextBox(false);
} }
initTheme() { initTheme() {
@ -685,6 +713,42 @@ class ProductionApp {
errBox.style.display = 'none'; errBox.style.display = 'none';
errBox.textContent = ''; errBox.textContent = '';
} }
// Toggle guest vs user view
const isLoggedIn = this.sync.isLoggedIn();
const guestSec = document.getElementById('auth-guest-section');
const userSec = document.getElementById('auth-user-section');
const nameEl = document.getElementById('profile-display-name');
const badgeEl = document.getElementById('profile-badge-pill');
const descEl = document.getElementById('profile-sync-desc');
const iconEl = document.getElementById('profile-status-icon');
if (isLoggedIn) {
if (guestSec) guestSec.style.display = 'none';
if (userSec) userSec.style.display = 'block';
if (nameEl) nameEl.textContent = this.sync.getUsername();
if (badgeEl) {
badgeEl.textContent = 'Cloud Synced';
badgeEl.className = 'badge-pill-soft badge-online';
}
if (descEl) descEl.textContent = 'Account active. Enterprise data synchronizes automatically with the cloud server.';
if (iconEl) iconEl.setAttribute('icon', 'lucide:shield-check');
} else {
if (guestSec) guestSec.style.display = 'flex';
if (userSec) userSec.style.display = 'none';
if (nameEl) nameEl.textContent = 'Executive Guest';
if (badgeEl) {
badgeEl.textContent = 'Offline Guest';
badgeEl.className = 'badge-pill-soft';
}
if (descEl) descEl.textContent = 'Progress is saved locally in this browser. Register or login to sync across devices.';
if (iconEl) iconEl.setAttribute('icon', 'lucide:shield');
}
// Hide raw import box if open
const importBox = document.getElementById('import-text-box');
if (importBox) importBox.style.display = 'none';
if (modal) modal.classList.add('open'); if (modal) modal.classList.add('open');
} }
@ -711,8 +775,20 @@ class ProductionApp {
try { try {
await this.sync.login(u, p); await this.sync.login(u, p);
this.showToast(`Logged in as ${u}`, 'success'); this.showToast(`Logged in as ${u}`, 'success');
// Check for cloud state upon login
const cloud = await this.sync.fetchCloudState();
if (cloud && cloud.lastTick > this.sm.state.lastTick) {
this.sm.importState(cloud);
this.showToast('Restored latest game state from cloud!', 'info');
} else {
// If local save is newer or first login, push local state to cloud
await this.sync.saveCloudState(this.sm.state);
}
this.closeAuthModal(); this.closeAuthModal();
this.updateHud(); this.updateHud();
this.mountActiveView();
} catch (err) { } catch (err) {
this.showAuthError(err.message || 'Login failed'); this.showAuthError(err.message || 'Login failed');
} }
@ -727,6 +803,8 @@ class ProductionApp {
try { try {
await this.sync.register(u, p, hp); await this.sync.register(u, p, hp);
this.showToast(`Account created! Welcome ${u}`, 'success'); this.showToast(`Account created! Welcome ${u}`, 'success');
// Back up current game state to new account
await this.sync.saveCloudState(this.sm.state);
this.closeAuthModal(); this.closeAuthModal();
this.updateHud(); this.updateHud();
} catch (err) { } catch (err) {
@ -734,6 +812,151 @@ class ProductionApp {
} }
} }
handleLogout() {
this.sync.logout();
this.showToast('Logged out. Playing as Guest.', 'info');
this.openAuthModal(); // Refresh modal to show guest state
this.updateHud();
}
async handleCloudSyncNow() {
if (!this.sync.isLoggedIn()) {
return this.showToast('You must be logged in to sync to the cloud', 'warning');
}
try {
const ok = await this.sync.saveCloudState(this.sm.state);
if (ok) {
this.showToast('Empire successfully synced to cloud server!', 'success');
} else {
this.showToast('Cloud sync failed. Please try again.', 'warning');
}
} catch (err) {
this.showToast('Sync error: ' + err.message, 'warning');
}
}
// Savegame File Export
handleExportSaveFile() {
try {
const jsonStr = this.sm.exportState();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const fileName = `production_save_${timestamp}.json`;
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showToast(`Savegame downloaded: ${fileName}`, 'success');
} catch (err) {
console.error('Export error:', err);
this.showToast('Failed to export save: ' + err.message, 'warning');
}
}
// Savegame Clipboard Copy
async handleExportSaveClipboard() {
try {
const jsonStr = this.sm.exportState();
await navigator.clipboard.writeText(jsonStr);
this.showToast('Savegame copied to clipboard!', 'success');
} catch (err) {
// Fallback for browsers with restricted clipboard
const promptOk = window.prompt('Copy your savegame JSON string below:', this.sm.exportState());
if (promptOk !== null) {
this.showToast('Savegame copied!', 'success');
}
}
}
// Toggle Paste Save container
toggleImportTextBox(show = null) {
const box = document.getElementById('import-text-box');
const area = document.getElementById('import-raw-input');
if (!box) return;
const willShow = show !== null ? show : (box.style.display === 'none' || !box.style.display);
box.style.display = willShow ? 'flex' : 'none';
if (willShow && area) {
area.value = '';
area.focus();
}
}
// Import from uploaded file
handleImportSaveFile(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const text = event.target.result;
await this.executeSaveImport(text, `file "${file.name}"`);
e.target.value = ''; // Reset input to allow selecting same file again
};
reader.onerror = () => {
this.showToast('Could not read the selected file.', 'warning');
e.target.value = '';
};
reader.readAsText(file);
}
// Import from pasted text
async handleApplyImportText() {
const raw = document.getElementById('import-raw-input')?.value.trim();
if (!raw) {
return this.showToast('Please paste a savegame JSON string first.', 'warning');
}
await this.executeSaveImport(raw, 'clipboard text');
this.toggleImportTextBox(false);
}
// Common confirmation & restore executor
async executeSaveImport(rawJson, sourceLabel = 'input') {
let parsed;
try {
parsed = JSON.parse(rawJson);
} catch (err) {
return this.showToast('Invalid JSON savegame: syntax error.', 'warning');
}
const compName = parsed.companyName || 'Unknown Enterprise';
const cashAmt = parsed.cash !== undefined ? fmtCurrency(parsed.cash) : '$0.00';
const ipoCount = parsed.ipoCount || 0;
const confirmed = await this.showConfirmModal({
title: 'Restore Savegame',
subtitle: 'Data Override Confirmation',
icon: 'lucide:download',
text: `Are you sure you want to load savegame from <strong>${sourceLabel}</strong>?<br><br>` +
`<strong>Company:</strong> ${compName}<br>` +
`<strong>Capital:</strong> ${cashAmt}<br>` +
`<strong>IPO Prestige:</strong> ${ipoCount} iterations<br><br>` +
`<span style="color: var(--amber-dark);">Warning: This will replace your current game state immediately!</span>`,
okText: 'Restore & Load',
cancelText: 'Cancel'
});
if (confirmed) {
const res = this.sm.importState(parsed);
if (res.success) {
// If logged in, also sync the newly restored state to the cloud
if (this.sync.isLoggedIn()) {
this.sync.saveCloudState(this.sm.state);
}
this.closeAuthModal();
this.mountActiveView();
this.mountInspectorPane();
this.updateHud();
this.showToast(`Savegame successfully restored: ${res.companyName}!`, 'success');
} else {
this.showToast(`Failed to restore save: ${res.error}`, 'warning');
}
}
}
toggleSettings() { toggleSettings() {
const current = getNotation(); const current = getNotation();
const next = current === 'standard' ? 'scientific' : 'standard'; const next = current === 'standard' ? 'scientific' : 'standard';

View file

@ -134,6 +134,70 @@ export class StateManager {
this.notify(); this.notify();
} }
exportState() {
return JSON.stringify(this.state, null, 2);
}
importState(jsonStringOrObject) {
try {
const parsed = typeof jsonStringOrObject === 'string' ? JSON.parse(jsonStringOrObject) : jsonStringOrObject;
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid savegame format: must be an object.');
}
const fresh = getInitialState();
// Validate & sanitize incoming state against schema
const validated = { ...fresh, ...parsed };
validated.version = fresh.version;
validated.cash = typeof parsed.cash === 'number' && !isNaN(parsed.cash) ? Math.max(0, parsed.cash) : fresh.cash;
validated.lifetimeRevenue = typeof parsed.lifetimeRevenue === 'number' && !isNaN(parsed.lifetimeRevenue) ? Math.max(0, parsed.lifetimeRevenue) : fresh.lifetimeRevenue;
validated.valuation = typeof parsed.valuation === 'number' && !isNaN(parsed.valuation) ? Math.max(0, parsed.valuation) : fresh.valuation;
validated.ventureShares = typeof parsed.ventureShares === 'number' && !isNaN(parsed.ventureShares) ? Math.max(0, parsed.ventureShares) : fresh.ventureShares;
validated.totalVentureShares = typeof parsed.totalVentureShares === 'number' && !isNaN(parsed.totalVentureShares) ? Math.max(0, parsed.totalVentureShares) : fresh.totalVentureShares;
validated.ipoCount = typeof parsed.ipoCount === 'number' ? Math.max(0, parsed.ipoCount) : 0;
validated.reputation = typeof parsed.reputation === 'number' ? Math.max(0, parsed.reputation) : 0;
validated.lastTick = Date.now();
validated.products = { ...fresh.products };
if (parsed.products && typeof parsed.products === 'object') {
for (const pid of Object.keys(fresh.products)) {
if (parsed.products[pid]) {
const pp = parsed.products[pid];
validated.products[pid] = {
...fresh.products[pid],
unlocked: Boolean(pp.unlocked),
stock: typeof pp.stock === 'number' && !isNaN(pp.stock) ? Math.max(0, pp.stock) : 0,
lines: typeof pp.lines === 'number' && !isNaN(pp.lines) ? Math.max(0, pp.lines) : 0,
manager: Boolean(pp.manager),
logistics: typeof pp.logistics === 'number' ? Math.max(0, pp.logistics) : 0,
logisticsEnabled: pp.logisticsEnabled !== false,
supplyDownstreamEnabled: pp.supplyDownstreamEnabled !== false,
productionTarget: typeof pp.productionTarget === 'number' ? Math.max(0, Math.min(1.0, pp.productionTarget)) : 1.0,
cycleProgress: 0.0,
isBottlenecked: false,
totalProduced: typeof pp.totalProduced === 'number' ? Math.max(0, pp.totalProduced) : 0
};
}
}
}
validated.boardroomPerks = { ...fresh.boardroomPerks, ...(parsed.boardroomPerks || {}) };
validated.achievements = { ...fresh.achievements, ...(parsed.achievements || {}) };
validated.stats = { ...fresh.stats, ...(parsed.stats || {}) };
if (!Array.isArray(validated.activeContracts) || validated.activeContracts.length === 0) {
validated.activeContracts = fresh.activeContracts;
}
this.state = validated;
this.save();
this.notify();
return { success: true, companyName: validated.companyName || 'Apex Production Industries' };
} catch (err) {
console.error('Save import failed:', err);
return { success: false, error: err.message || 'Unknown save format' };
}
}
performIPO() { performIPO() {
const earned = calculateSharesEarned(this.state.lifetimeRevenue); const earned = calculateSharesEarned(this.state.lifetimeRevenue);
if (earned <= 0) return false; if (earned <= 0) return false;

View file

@ -103,6 +103,24 @@ export class SyncManager {
return data; return data;
} }
async saveCloudState(saveData) {
if (!this.token) return false;
try {
const res = await fetch('/api/game/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({ save_data: saveData })
});
return res.ok;
} catch (err) {
console.error('Error saving cloud state:', err);
return false;
}
}
async register(username, password, website_hp = '') { async register(username, password, website_hp = '') {
const res = await fetch('/api/auth/register', { const res = await fetch('/api/auth/register', {
method: 'POST', method: 'POST',