diff --git a/.dprint.json b/.dprint.json index 4b5ffa433..9ce4cfd2b 100644 --- a/.dprint.json +++ b/.dprint.json @@ -16,7 +16,8 @@ "**/*-lock.json", "translations/*.json", "dist", - "mockServiceWorker.js" + "mockServiceWorker.js", + "src-tauri" ], "plugins": [ "https://plugins.dprint.dev/typescript-0.88.7.wasm" diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml new file mode 100644 index 000000000..23a034cba --- /dev/null +++ b/.github/workflows/electron-build.yml @@ -0,0 +1,69 @@ +name: Electron Build + +on: + push: + paths-ignore: + - '*.md' + workflow_dispatch: + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + platform: win + - os: macos-latest + platform: mac + - os: ubuntu-latest + platform: linux + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm i + + - name: Build Electron app + run: npm run electron:build:${{ matrix.platform }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Windows artifacts + if: matrix.platform == 'win' + uses: actions/upload-artifact@v4 + with: + name: synergism-windows + path: | + release/*.exe + if-no-files-found: error + + - name: Upload macOS artifacts + if: matrix.platform == 'mac' + uses: actions/upload-artifact@v4 + with: + name: synergism-macos + path: | + release/*.dmg + release/*.zip + if-no-files-found: error + + - name: Upload Linux artifacts + if: matrix.platform == 'linux' + uses: actions/upload-artifact@v4 + with: + name: synergism-linux + path: | + release/*.AppImage + release/*.deb + release/*.tar.gz + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 3c9056d15..88583cfaf 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ Pictures/Splash/Thumbs.db Pictures/TransparentPics/Thumbs.db **/.DS_Store +release +!electron/**/*.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 00ad71fba..44aeb4060 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "typescript.tsdk": "node_modules\\typescript\\lib" -} \ No newline at end of file + "typescript.tsdk": "node_modules\\typescript\\lib" +} diff --git a/Pictures/electron-logo.ico b/Pictures/electron-logo.ico new file mode 100644 index 000000000..19f68625a Binary files /dev/null and b/Pictures/electron-logo.ico differ diff --git a/biome.json b/biome.json index aa75c358b..b216fe491 100644 --- a/biome.json +++ b/biome.json @@ -26,8 +26,7 @@ "correctness": { "noUndeclaredVariables": "error" } - }, - "ignore": ["./dist/*", "**/node_modules/*", "mockServiceWorker.js"] + } }, "formatter": { "enabled": false @@ -39,5 +38,8 @@ "parser": { "allowComments": true } + }, + "files": { + "ignore": ["./dist/*", "**/node_modules/*", "mockServiceWorker.js", "./src-tauri/*"] } } diff --git a/claude.md b/claude.md index 19b05877d..20060dc54 100644 --- a/claude.md +++ b/claude.md @@ -18,17 +18,11 @@ 2. **Check back with user** after writing significant code 3. **Ask questions** when task requirements are unclear -### Quality Assurance Commands -Run these after making changes: -```bash -node --run format # Format code -npx -p typescript tsc # TypeScript check -``` - ## File Structure Rules ``` src/ # Core game logic ├── mock/ # Mock API responses +├── steam/ # Steam (Electron) features ├── Purchases/ # Purchase-related logic ├── saves/ # Save system logic ├── types/ # TypeScript definitions @@ -57,7 +51,7 @@ translations/en.json # Required for all new text strings ### Save System Variables **CRITICAL**: Before adding to `player` object: 1. Get explicit permission from user -2. Add to `src/types/Synergism.d.ts` +2. Add to `src/types/Synergism.ts` 3. Add to `src/saves/PlayerSchema.ts` 4. Variable location: `player` in `src/Synergism.ts` @@ -67,9 +61,6 @@ translations/en.json # Required for all new text strings - **DOM Access**: ALWAYS use `DOMCacheGetOrSet('elementId')` instead of `document.getElementById` - Import: `import { DOMCacheGetOrSet } from './Cache/DOM'` - Reason: Performance optimization through caching -- **Import Style**: Top-level imports ONLY - never use dynamic imports like `import().then()` - - Correct: `import { functionName } from './ModuleName'` at top of file - - Wrong: `import('./Module').then(({ functionName }) => ...)` - **Import Organization**: Alphabetical ordering within import groups - **Destructured Imports**: Use for specific functions/variables from modules @@ -79,9 +70,26 @@ translations/en.json # Required for all new text strings - Match existing naming conventions - Maintain consistency with current architecture +### Steam +- There is a Steam version of the app that uses Electron. +- Steam features MUST be gated by checking the `platform` variable from Config.ts +- When using a feature only available to the Electron app, you MUST use dynamic imports. Example: +```ts +import { platform } from './Config' +async function myFunction () { + if (platform === 'steam') { + const { steamOnlyFeature } = await import('./steam/steam') + await steamOnlyFeature() + } else { + // browser version + browserOnlyFeature() + } +} +``` - - +- The platform variable comes from esbuild define hooks. These act as macros essentially, which removes the + `else` block on Steam and vice-versa on browser builds. +- **Wrong**: `import { steamOnlyFeature } from './steam/steam'` diff --git a/electron-builder.json b/electron-builder.json new file mode 100644 index 000000000..c7109ae24 --- /dev/null +++ b/electron-builder.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "appId": "cc.synergism.app", + "productName": "Synergism", + "directories": { + "output": "release", + "buildResources": "build" + }, + "files": [ + "dist/**/*", + "electron/**/*" + ], + "extraMetadata": { + "main": "electron/main.ts" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": ["x64"] + }, + { + "target": "portable", + "arch": ["x64"] + } + ], + "icon": "dist/Pictures/electron-logo.ico", + "artifactName": "${productName}-${os}-${arch}.${ext}", + "extraFiles": [ + { "from": "node_modules/steamworks.js/dist/win64/steam_api64.dll", "to": "." } + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "installerIcon": "dist/Pictures/electron-logo.ico", + "uninstallerIcon": "dist/Pictures/electron-logo.ico", + "installerHeaderIcon": "dist/Pictures/electron-logo.ico", + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "Synergism" + }, + "mac": { + "target": [ + { + "target": "dmg", + "arch": ["universal"] + }, + { + "target": "zip", + "arch": ["universal"] + } + ], + "icon": "build/icon.icns", + "category": "public.app-category.games", + "artifactName": "${productName}-${os}-${arch}.${ext}", + "extraFiles": [ + { "from": "node_modules/steamworks.js/dist/osx/libsteam_api.dylib", "to": "." } + ], + "x64ArchFiles": "**/steamworksjs.darwin-*.node" + }, + "linux": { + "maintainer": "Khafra ", + "target": [ + { + "target": "AppImage", + "arch": ["x64"] + }, + { + "target": "deb", + "arch": ["x64"] + }, + { + "target": "tar.gz", + "arch": ["x64"] + } + ], + "icon": "build/icons", + "category": "Game", + "artifactName": "${productName}-${os}-${arch}.${ext}", + "extraFiles": [ + { "from": "node_modules/steamworks.js/dist/linux64/libsteam_api.so", "to": "." } + ] + } +} diff --git a/electron/lib/discord.ts b/electron/lib/discord.ts new file mode 100644 index 000000000..5a7f7889b --- /dev/null +++ b/electron/lib/discord.ts @@ -0,0 +1,51 @@ +import { Client, type Presence } from 'discord-rpc' +import { ipcMain } from 'electron' + +export type PresenceOptions = Omit + +const clientId = '1289263890445631581' + +const rpc = new Client({ transport: 'ipc' }) +const startTimestamp = new Date() + +let options: PresenceOptions | undefined + +// biome-ignore lint/complexity/noUselessLoneBlockStatements: organization +{ + ipcMain.handle('discord:setRichPresence', (_, presence: PresenceOptions) => { + options = presence + }) +} + +async function setActivity () { + if (!options) return + + await rpc.setActivity({ + startTimestamp, + ...options, + // largeImageKey: 'snek_large', + // largeImageText: 'This is large image text', + // smallImageKey: 'snek_small', + // smallImageText: 'This is small image text', + instance: false, + buttons: [ + { + label: 'Play Synergism!', + url: 'https://synergism.cc' + } + ] + }) + + options = undefined +} + +rpc.once('ready', () => { + setActivity() + + // activity can only be set every 15 seconds + setInterval(() => { + setActivity() + }, 15e3) +}) + +rpc.login({ clientId }) diff --git a/electron/lib/steam-ipc.ts b/electron/lib/steam-ipc.ts new file mode 100644 index 000000000..9da7b1bf6 --- /dev/null +++ b/electron/lib/steam-ipc.ts @@ -0,0 +1,99 @@ +import { BrowserWindow, ipcMain } from 'electron' +import log from 'electron-log/main.js' +import steamworks from 'steamworks.js' + +log.initialize() + +const STEAM_APP_ID = 3552310 + +let steamClient: ReturnType | null = null + +export function initializeSteam (): boolean { + try { + steamClient = steamworks.init(STEAM_APP_ID) + + // Register callback for MicroTxnAuthorizationResponse_t + // https://partner.steamgames.com/doc/features/microtransactions/implementation#5 + steamClient.callback.register( + steamworks.SteamCallback.MicroTxnAuthorizationResponse, + (response) => { + log.info('MicroTxnAuthorizationResponse received:', response) + BrowserWindow.getAllWindows()[0]?.webContents.send('steam:microTxnAuthorizationResponse', response) + } + ) + + return true + } catch (error) { + log.error('Failed to initialize Steam:', error) + return false + } +} + +{ + ipcMain.handle('steam:getSteamId', () => { + if (!steamClient) return null + return steamClient.localplayer.getSteamId().steamId64.toString() + }) + + ipcMain.handle('steam:getUsername', () => { + if (!steamClient) return null + return steamClient.localplayer.getName() + }) + + ipcMain.handle('steam:getCurrentGameLanguage', () => { + return steamClient?.apps.currentGameLanguage() ?? null + }) + + ipcMain.handle('steam:setRichPresence', (_, key: string, value?: string) => { + steamClient?.localplayer.setRichPresence(key, value) + }) + + ipcMain.handle('steam:getSessionTicket', async () => { + if (!steamClient) return null + + const ticket = await steamClient.auth.getAuthTicketForWebApi('synergism-backend', 60 * 3) + return ticket.getBytes().toString('hex') + }) + + // Steam Cloud Storage + const isCloudEnabled = () => steamClient?.cloud.isEnabledForAccount() && steamClient?.cloud.isEnabledForApp() + + ipcMain.handle('steam:cloudFileExists', (_, name: string) => { + if (!steamClient || !isCloudEnabled()) return false + return steamClient.cloud.fileExists(name) + }) + + ipcMain.handle('steam:cloudReadFile', (_, name: string) => { + if (!steamClient || !isCloudEnabled()) return null + try { + return steamClient.cloud.readFile(name) + } catch (error) { + log.error('Failed to read Steam Cloud file:', error) + return null + } + }) + + ipcMain.handle('steam:cloudWriteFile', (_, name: string, content: string) => { + if (!steamClient || !isCloudEnabled()) return false + try { + return steamClient.cloud.writeFile(name, content) + } catch (error) { + log.error('Failed to write Steam Cloud file:', error) + return false + } + }) + + ipcMain.handle('steam:cloudDeleteFile', (_, name: string) => { + if (!steamClient || !isCloudEnabled()) return false + try { + return steamClient.cloud.deleteFile(name) + } catch (error) { + log.error('Failed to delete Steam Cloud file:', error) + return false + } + }) +} + +export function enableSteamOverlay () { + steamworks.electronEnableSteamOverlay() +} diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 000000000..2c89cd4e6 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,130 @@ +import cookie from 'cookie' +import { app, BrowserWindow, session } from 'electron' +import mimeTypes from 'mime-types' +import fs from 'node:fs' +import path from 'node:path' +import { enableSteamOverlay, initializeSteam } from './lib/steam-ipc.ts' +import './lib/discord.ts' // Discord RPC + +app.commandLine.appendSwitch('disable-http-cache') + +let mainWindow: BrowserWindow | null = null + +function createWindow (): void { + mainWindow = new BrowserWindow({ + maximizable: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(app.getAppPath(), 'electron', 'preload.js') + }, + icon: path.join(app.getAppPath(), 'dist', 'favicon.ico'), + title: 'Synergism', + autoHideMenuBar: true + }) + + if (mainWindow.maximizable) { + mainWindow.maximize() + } + + mainWindow.loadURL('https://synergism.cc/', { extraHeaders: 'pragma: no-cache\n' }) + + if (!app.isPackaged) { + mainWindow.webContents.openDevTools() + } + + mainWindow.on('closed', () => { + mainWindow = null + }) +} + +app.whenReady().then(async () => { + const distPath = path.join(app.getAppPath(), 'dist') + + // Intercept requests to synergism.cc and serve local files + session.defaultSession.protocol.handle('https', async (request) => { + const url = new URL(request.url) + + if (url.hostname === 'synergism.cc') { + let filePath = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\//, '') + filePath = path.join(distPath, filePath) + + try { + const data = fs.readFileSync(filePath) + const ext = path.extname(filePath) + + return new Response(data, { + headers: { 'Content-Type': mimeTypes.contentType(ext) || 'application/octet-stream' } + }) + } catch { + } + } + + // Pass through to network - attach cookies from Electron's cookie jar + const cookies = await session.defaultSession.cookies.get({ url: request.url }) + const cookieHeader = cookies.reduce((prev, curr) => { + prev[curr.name] = curr.value + return prev + }, {} as Record) + + const headers = new Headers(request.headers) + if (cookies.length > 0) { + headers.set('Cookie', cookie.stringifyCookie(cookieHeader)) + } + + const response = await fetch(request.url, { + method: request.method, + headers, + body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.clone().body, + redirect: 'manual', // Handle redirects manually to preserve cookies + // @ts-expect-error Who needs types when you have ts-expect-error? + duplex: 'half' + }) + + // Manually apply Set-Cookie headers to Chrome's cookie jar + const setCookieHeaders = response.headers.getSetCookie() + for (const cookieValue of setCookieHeaders) { + const c = cookie.parseSetCookie(cookieValue) + // https://stackoverflow.com/a/39136448 + // Cookies require an expirationDate to be persistent, for some reason + const expires = c.maxAge + ? Date.now() + (c.maxAge * 1000) + : c.expires?.getTime() + + await session.defaultSession.cookies.set({ + url: request.url, + domain: c.domain, + expirationDate: expires, + httpOnly: c.httpOnly, + name: c.name, + path: c.path, + sameSite: c.sameSite === true + ? 'strict' + : c.sameSite === 'none' || c.sameSite === false + ? 'no_restriction' + : c.sameSite, + secure: c.secure, + value: c.value + }) + } + + return response + }) + + createWindow() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } + }) +}) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } +}) + +initializeSteam() +enableSteamOverlay() diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 000000000..9a8b1f27c --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,43 @@ +// @ts-check +// Yes, it has to be CommonJS + +const { contextBridge, ipcRenderer } = require('electron') + +contextBridge.exposeInMainWorld('steam', { + getSteamId: () => ipcRenderer.invoke('steam:getSteamId'), + getUsername: () => ipcRenderer.invoke('steam:getUsername'), + getCurrentGameLanguage: () => ipcRenderer.invoke('steam:getCurrentGameLanguage'), + /** + * @param {string} key + * @param {string} [value] + */ + setRichPresence: (key, value) => ipcRenderer.invoke('steam:setRichPresence', key, value), + getSessionTicket: () => ipcRenderer.invoke('steam:getSessionTicket'), + /** + * @param {(response: import('steamworks.js/callbacks').CallbackReturns[ + * import('steamworks.js/client').callback.SteamCallback.MicroTxnAuthorizationResponse + * ]) => void} callback + */ + onMicroTxnAuthorizationResponse: (callback) => { + ipcRenderer.once('steam:microTxnAuthorizationResponse', (_, response) => callback(response)) + }, + // Steam Cloud Storage + /** @param {string} name */ + cloudFileExists: (name) => ipcRenderer.invoke('steam:cloudFileExists', name), + /** @param {string} name */ + cloudReadFile: (name) => ipcRenderer.invoke('steam:cloudReadFile', name), + /** + * @param {string} name + * @param {string} content + */ + cloudWriteFile: (name, content) => ipcRenderer.invoke('steam:cloudWriteFile', name, content), + /** @param {string} name */ + cloudDeleteFile: (name) => ipcRenderer.invoke('steam:cloudDeleteFile', name) +}) + +contextBridge.exposeInMainWorld('discord', { + /** + * @param {import('./lib/discord').PresenceOptions} options + */ + setRichPresence: (options) => ipcRenderer.invoke('discord:setRichPresence', options) +}) diff --git a/index.html b/index.html index a3f1a76eb..c9f8e92d4 100644 --- a/index.html +++ b/index.html @@ -3233,7 +3233,7 @@

Artists

-
+ @@ -3245,7 +3245,7 @@

Artists

-
+ diff --git a/package.json b/package.json index 9a4379f70..2b19d16a9 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,57 @@ { "name": "synergismofficial", - "version": "2.9.0", + "version": "4.1.1", "description": "", "main": "dist/bundle.js", "engines": { - "node": ">=24.0.0" + "node": ">=22.21.0" }, "dependencies": { "@paypal/paypal-js": "^8.2.0", + "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-http": "^2.5.4", "@ungap/custom-elements": "^1.3.0", "break_infinity.js": "^2.0.0", "clipboard": "^2.0.11", + "cookie": "^1.1.1", + "discord-rpc": "^4.0.1", "dompurify": "^3.2.7", + "electron-log": "^5.4.3", "fast-mersenne-twister": "^1.0.3", "i18next": "^22.4.9", "lz-string": "^1.4.4", + "mime-types": "^3.0.2", "rfdc": "^1.4.1", + "steamworks.js": "^0.4.0", + "tauri-plugin-drpc": "^1.0.3", "worker-timers": "^7.0.53", "zod": "^3.23.8" }, "devDependencies": { "@biomejs/biome": "^1.9.4", + "@tauri-apps/cli": "^2.9.6", "@types/cloudflare-turnstile": "^0.2.2", + "@types/discord-rpc": "^4.0.10", "@types/lodash.clonedeepwith": "^4.5.9", "@types/lz-string": "^1.3.34", + "@types/mime-types": "^3.0.1", "concurrently": "^9.2.1", "deep-object-diff": "^1.1.9", "dprint": "^0.48.0", + "electron": "^39.2.7", + "electron-builder": "^26.0.12", "esbuild": "^0.25.5", "htmlhint": "^1.1.4", "husky": "^8.0.3", "msw": "2.7.5", + "shx": "^0.4.0", "stylelint": "^14.8.2", "stylelint-config-standard": "^25.0.0", - "typescript": "^5.0.3" + "typescript": "^5.9.3" }, "scripts": { - "dev": "concurrently \"npm run watch:esbuild\" \"npx live-server --port=3000\"", + "live-server": "npx live-server --port=3000 -q --ignore=./src-tauri", + "dev": "concurrently \"npm run watch:esbuild\" \"npm run live-server\"", "lint": "npx @biomejs/biome lint .", "lint:fix": "npx @biomejs/biome lint --write .", "lint:fix-unsafe": "npx @biomejs/biome lint --write --unsafe .", @@ -52,7 +67,15 @@ "prepare": "husky install", "cloudflare:build": "node scripts/needsBundling.js || npm run build:esbuild", "msw:init": "npx -y msw init . --save", - "check-circular": "madge --circular src/" + "check-circular": "madge --circular src/", + "electron:cp": "shx rm -rf ./dist && shx mkdir dist && shx cp -r Pictures dist/ && shx cp -r translations dist/ && shx cp index.html Synergism.css favicon.ico dist/", + "electron:build:assets": "npm run electron:cp && npm run build:esbuild -- --define:PLATFORM=\\\"steam\\\" --outfile=\"./dist/dist/out.js\"", + "electron:watch": "npx esbuild src/Synergism.ts --bundle --target=\"chrome58,firefox57,safari11,edge29\" --outfile=\"./dist/dist/out.js\" --watch --sourcemap --define:PROD=false --define:DEV=true --define:PLATFORM=\\\"steam\\\"", + "electron:dev": "npm run electron:cp && npm run build:esbuild -- --define:PLATFORM=\\\"steam\\\" --outfile=\"./dist/dist/out.js\" && npx -y msw init dist --save && electron electron/main.ts", + "electron:build": "npm run electron:build:assets && electron-builder --win --mac --linux", + "electron:build:win": "npm run electron:build:assets && electron-builder --win", + "electron:build:mac": "npm run electron:build:assets && electron-builder --mac", + "electron:build:linux": "npm run electron:build:assets && electron-builder --linux" }, "repository": { "type": "git", @@ -62,7 +85,17 @@ "synergism", "synergismofficial" ], - "author": "Platonic/Pseudonian", + "author": "Platonic (Kevin Bunn)", + "maintainers": [ + { + "email": "pseudonian@gmail.com", + "name": "Kevin Bunn" + }, + { + "email": "maitken033380023@gmail.com", + "name": "Matthew Aitken" + } + ], "license": "MIT", "bugs": { "url": "https://github.com/Pseudo-Corp/SynergismOfficial/issues" @@ -71,7 +104,8 @@ "msw": { "workerDirectory": [ ".", - "" + "", + "dist" ] } } diff --git a/src/Config.ts b/src/Config.ts index 724de263e..d590df6b1 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -1,5 +1,6 @@ declare const PROD: boolean | undefined declare const DEV: boolean | undefined +declare const PLATFORM: 'steam' | undefined export const version = '4.1.1 December 17, 2025: The Ants Update' @@ -11,3 +12,5 @@ export const lastUpdated = new Date('##LAST_UPDATED##') export const prod = typeof PROD === 'undefined' ? false : PROD export const dev = typeof DEV === 'undefined' ? false : DEV + +export const platform = typeof PLATFORM === 'undefined' ? 'browser' : PLATFORM diff --git a/src/Login.ts b/src/Login.ts index 41d3e0554..fefb9968d 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -5,6 +5,7 @@ import i18next from 'i18next' import { z } from 'zod' import { DOMCacheGetOrSet } from './Cache/DOM' import { calculateAmbrosiaGenerationSpeed, calculateOffline, calculateRedAmbrosiaGenerationSpeed } from './Calculate' +import { platform } from './Config' import { updateGlobalsIsEvent } from './Event' import { addTimers, automaticTools } from './Helper' import { exportData, importSynergism, saveFilename } from './ImportExport' @@ -13,8 +14,8 @@ import { updatePseudoCoins } from './purchases/UpgradesSubtab' import { QuarkHandler, setPersonalQuarkBonus } from './Quark' import { updatePrestigeCount, updateReincarnationCount, updateTranscensionCount } from './Reset' import { format, player, saveSynergy } from './Synergism' -import { Alert, Notification } from './UpdateHTML' -import { assert, btoa, isomorphicDecode } from './Utility' +import { Alert, Confirm, Notification } from './UpdateHTML' +import { assert, btoa, isomorphicDecode, memoize } from './Utility' export type PseudoCoinConsumableNames = 'HAPPY_HOUR_BELL' @@ -239,6 +240,7 @@ interface AccountMetadata { discord: RawMember patreon: PatreonUser email: { email: string; verified: boolean } + steam: { avatar: string; profileURL: string; username: string } none: null } @@ -269,63 +271,73 @@ const isDiscordAccount = ( const isPatreonAccount = ( account: SynergismUserAPIResponse ): account is SynergismUserAPIResponse<'patreon'> => account.accountType === 'patreon' +const isSteamAccount = ( + account: SynergismUserAPIResponse +): account is SynergismUserAPIResponse<'steam'> => account.accountType === 'steam' const isEmailAccount = ( account: SynergismUserAPIResponse ): account is SynergismUserAPIResponse<'email'> => account.accountType === 'email' const hasAccount = ( account: SynergismUserAPIResponse -): account is SynergismUserAPIResponse<'discord' | 'patreon' | 'email'> => account.accountType !== 'none' +): account is SynergismUserAPIResponse> => account.accountType !== 'none' + +async function fetchMeRoute () { + const fallback = new Response( + JSON.stringify( + { + member: null, + personalBonus: 0, + accountType: 'none', + bonus: { quarks: 0 }, + subscription: null, + linkedAccounts: [] + } satisfies SynergismUserAPIResponse<'none'> + ), + { status: 401 } + ) + + return await fetch('https://synergism.cc/api/v1/users/me', { credentials: 'same-origin' }).catch(() => fallback) +} export async function handleLogin () { - const subtabElement = document.querySelector('#accountSubTab div#left.scrollbarX')! + // biome-ignore lint/suspicious/noConfusingLabels: it's not confusing or suspicious + generateSubtabBrowser: { + const subtabElement = document.querySelector('#accountSubTab div#left.scrollbarX')! - const logoutElement = document.getElementById('logoutButton') - if (logoutElement !== null) { - logoutElement.addEventListener('click', logout, { once: true }) - document.getElementById('accountSubTab')?.appendChild(logoutElement) - } + const logoutElement = document.getElementById('logoutButton') + if (logoutElement !== null) { + logoutElement.addEventListener('click', logout, { once: true }) + document.getElementById('accountSubTab')?.appendChild(logoutElement) + } - const response = await fetch('https://synergism.cc/api/v1/users/me', { credentials: 'same-origin' }).catch( - () => - new Response( - JSON.stringify( - { - member: null, - personalBonus: 0, - accountType: 'none', - bonus: { quarks: 0 }, - subscription: null, - linkedAccounts: [] - } satisfies SynergismUserAPIResponse<'none'> - ), - { status: 401 } - ) - ) + if (platform === 'steam') { + subtabElement.querySelectorAll('a').forEach((element) => element.classList.add('none')) + } - const account = await response.json() as SynergismUserAPIResponse - const { personalBonus, subscription: sub, linkedAccounts } = account + const response = await fetchMeRoute() - setPersonalQuarkBonus(personalBonus) - player.worlds = new QuarkHandler(Number(player.worlds)) + const account = await response.json() as SynergismUserAPIResponse + const { personalBonus, subscription: sub, linkedAccounts } = account - loggedIn = hasAccount(account) - subscription = sub + setPersonalQuarkBonus(personalBonus) + player.worlds = new QuarkHandler(Number(player.worlds)) - // biome-ignore lint/suspicious/noConfusingLabels: it's not confusing or suspicious - generateSubtab: { - if (location.hostname !== 'synergism.cc') { + loggedIn = hasAccount(account) + subscription = sub + + if (location.hostname !== 'synergism.cc' && platform === 'browser') { subtabElement.innerHTML = 'Login is not available here, go to https://synergism.cc instead!' } else if (hasAccount(account)) { if (Object.keys(account.member).length === 0) { subtabElement.innerHTML = `You are logged in, but your profile couldn't be retrieved from Discord or Patreon.` - break generateSubtab + break generateSubtabBrowser } if (account.error) { subtabElement.innerHTML = `You are logged in, but retrieving your profile yielded the following error: ${account.error}` - break generateSubtab + break generateSubtabBrowser } let user: string | null = null @@ -337,6 +349,8 @@ export async function handleLogin () { user = account.member.email } else if (isPatreonAccount(account)) { user = account.member?.data?.attributes?.email ?? null + } else if (isSteamAccount(account)) { + user = account.member.username } if (user !== null) { @@ -407,8 +421,15 @@ export async function handleLogin () { ${createLineHTML('yourself', 1, true, ['rainbowText'])} `.trim() - const allPlatforms = ['discord', 'patreon'] - const unlinkedPlatforms = allPlatforms.filter((platform) => !linkedAccounts.includes(platform)) + const allPlatforms = [ + { name: 'discord', direct: false }, + { name: 'patreon', direct: false }, + { name: 'steam', direct: true } + ] + + const unlinkedPlatforms = platform === 'steam' + ? allPlatforms.filter((platform) => platform.direct && !linkedAccounts.includes(platform.name)) + : allPlatforms.filter((platform) => !linkedAccounts.includes(platform.name)) if (unlinkedPlatforms.length > 0) { const linkAccountsSection = document.createElement('div') @@ -436,11 +457,20 @@ export async function handleLogin () { ` ` + }, + steam: { + label: 'Link Steam', + color: '#1b2838', + logo: + ` + + + ` } } - for (const platform of unlinkedPlatforms) { - const config = platformConfig[platform as keyof typeof platformConfig] + for (const unlinked of unlinkedPlatforms) { + const config = platformConfig[unlinked.name as keyof typeof platformConfig] const button = document.createElement('button') button.innerHTML = `${config.logo}${config.label}` button.style.padding = '10px 20px' @@ -460,8 +490,38 @@ export async function handleLogin () { button.addEventListener('mouseleave', () => { button.style.opacity = '1' }) - button.addEventListener('click', () => { - window.open(`https://synergism.cc/login?with=${platform}&link=true`, '_blank') + button.addEventListener('click', async () => { + if (unlinked.direct) { + if (button.dataset.loading) return + button.dataset.loading = 'true' + + if (platform === 'steam') { + const { getSessionTicket } = await import('./steam/steam') + const sessionTicket = await getSessionTicket() + + if (!sessionTicket) { + await Alert('Failed to validate against Steam API') + return + } + + const response = await fetch(`https://synergism.cc/login/link-direct/${unlinked.name}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ sessionTicket }) + }).finally(() => { + button.dataset.loading = '' + }) + + if (response.redirected || response.ok) { + location.reload() + } + } + } else { + window.open(`https://synergism.cc/login?with=${unlinked.name}&link=true`, '_blank') + } }) buttonContainer.appendChild(button) } @@ -515,6 +575,11 @@ export async function handleLogin () { handleWebSocket() handleCloudSaves() } + + // Steam cloud saves work without login + if (platform === 'steam') { + handleSteamCloudSave() + } } const queue: string[] = [] @@ -695,28 +760,75 @@ export function sendToWebsocket (message: string) { async function logout () { await fetch('https://synergism.cc/api/v1/users/logout') - await Alert(i18next.t('account.logout')) + await Alert(i18next.t('account.logout')) location.reload() } const hasCaptcha = new WeakSet() -export function renderCaptcha () { - const captchaElements = Array.from(document.querySelectorAll('.turnstile')) - const visible = captchaElements.find((el) => el.offsetParent !== null) +export const renderCaptcha = platform === 'steam' + ? memoize(() => { + const captchaElements = Array.from(document.querySelectorAll('.turnstile')) - if (visible && !hasCaptcha.has(visible)) { - // biome-ignore lint/correctness/noUndeclaredVariables: declared in types as a global - turnstile.render(visible, { - sitekey: visible.getAttribute('data-sitekey')!, - 'error-callback' () {}, - retry: 'never' - }) + for (const element of captchaElements) { + if (element.parentElement instanceof HTMLFormElement) { + const form = element.parentElement + form.addEventListener('submit', async (ev) => { + ev.preventDefault() + + if (form.dataset.submitting) return + form.dataset.submitting = 'true' + + try { + const { getSessionTicket } = await import('./steam/steam') + + const sessionTicket = await getSessionTicket() + + if (!sessionTicket) { + await Alert('Failed to validate against Steam API') + return + } + + const dataAction = form.getAttribute('data-steam-action')! + + const fd = new FormData(form) + const body = new URLSearchParams() + + fd.forEach((value, key) => body.set(key, `${value}`)) + body.set('sessionTicket', sessionTicket) + + const response = await fetch(dataAction, { + method: form.method.toUpperCase(), + body, + credentials: 'include' + }) + + if (response.redirected || response.ok) { + location.reload() + } + } finally { + form.dataset.submitting = undefined + } + }) + } + } + }) + : () => { + const captchaElements = Array.from(document.querySelectorAll('.turnstile')) + const visible = captchaElements.find((el) => el.offsetParent !== null) + + if (visible && !hasCaptcha.has(visible)) { + // biome-ignore lint/correctness/noUndeclaredVariables: declared in types as a global + turnstile.render(visible, { + sitekey: visible.getAttribute('data-sitekey')!, + 'error-callback' () {}, + retry: 'never' + }) - hasCaptcha.add(visible) + hasCaptcha.add(visible) + } } -} const createFastForward = (name: PseudoCoinTimeskipNames, minutes: number) => { const seconds = minutes * 60 @@ -1176,3 +1288,178 @@ function handleCloudSaves () { }) }) } + +async function handleSteamCloudSave () { + const { cloudFileExists, cloudReadFile, cloudWriteFile, getSteamId } = await import('./steam/steam') + + const steamId = await getSteamId() + if (!steamId) return + + const saveFileName = `synergism_${steamId}.txt` + const table = document.querySelector('#accountSubTab div#right.scrollbarX #table > #dataGrid')! + + // Remove any existing Steam save row + table.querySelectorAll('.steam-save-row, .steam-details-row').forEach((row) => row.remove()) + + const steamSaveExists = await cloudFileExists(saveFileName) + + // Create the Steam save row + const rowDiv = document.createElement('div') + rowDiv.className = 'grid-row steam-save-row' + rowDiv.style.display = 'contents' + + const idCell = document.createElement('div') + idCell.className = 'grid-cell id-cell' + idCell.textContent = '☁️' + idCell.title = i18next.t('account.steamCloud.title') + + const nameCell = document.createElement('div') + nameCell.className = 'grid-cell name-cell' + nameCell.textContent = i18next.t('account.steamCloud.name') + + const dateCell = document.createElement('div') + dateCell.className = 'grid-cell date-cell' + dateCell.textContent = steamSaveExists + ? i18next.t('account.steamCloud.exists') + : i18next.t('account.steamCloud.noSave') + + rowDiv.appendChild(idCell) + rowDiv.appendChild(nameCell) + rowDiv.appendChild(dateCell) + + // Alternate row styling (Steam is always first) + idCell.classList.add('alt-row') + nameCell.classList.add('alt-row') + dateCell.classList.add('alt-row') + + // Create the expandable details row + const detailsRow = document.createElement('div') + detailsRow.className = 'grid-details-row steam-details-row' + detailsRow.style.display = 'none' + detailsRow.style.gridColumn = '1 / -1' + + const detailsContent = document.createElement('div') + detailsContent.className = 'details-content' + + const actionsDiv = document.createElement('div') + actionsDiv.className = 'details-actions' + + // Upload button (to Steam Cloud) + const uploadBtn = document.createElement('button') + uploadBtn.className = 'btn-upload' + uploadBtn.textContent = i18next.t('account.steamCloud.upload') + + // Download button (export Steam Cloud save to file) + const downloadBtn = document.createElement('button') + downloadBtn.className = 'btn-download' + downloadBtn.textContent = i18next.t('account.download') + downloadBtn.disabled = !steamSaveExists + + // Load button (load Steam Cloud save into game) + const loadBtn = document.createElement('button') + loadBtn.className = 'btn-load' + loadBtn.textContent = i18next.t('account.loadSave') + loadBtn.disabled = !steamSaveExists + + actionsDiv.appendChild(uploadBtn) + actionsDiv.appendChild(downloadBtn) + actionsDiv.appendChild(loadBtn) + detailsContent.appendChild(actionsDiv) + detailsRow.appendChild(detailsContent) + + rowDiv.addEventListener('click', () => { + const isVisible = detailsRow.style.display !== 'none' + + // Close all other detail rows + const allDetailsRows = table.querySelectorAll('.grid-details-row') + allDetailsRows.forEach((row) => { + if (row !== detailsRow) { + row.style.display = 'none' + } + }) + + detailsRow.style.display = isVisible ? 'none' : 'block' + }) + + // Handle upload to Steam Cloud + uploadBtn.addEventListener('click', async (e) => { + e.stopPropagation() + + const saveExists = await cloudFileExists(saveFileName) + if (saveExists) { + const confirmed = await Confirm(i18next.t('account.steamCloud.confirmOverwrite')) + if (!confirmed) return + } + + uploadBtn.disabled = true + uploadBtn.textContent = i18next.t('account.steamCloud.uploading') + + const localSave = localStorage.getItem('Synergysave2') + if (!localSave) { + Alert(i18next.t('account.steamCloud.noLocalSave')) + uploadBtn.disabled = false + uploadBtn.textContent = i18next.t('account.steamCloud.upload') + return + } + + const success = await cloudWriteFile(saveFileName, localSave) + if (success) { + Notification(i18next.t('account.steamCloud.uploadSuccess')) + // Refresh the Steam save row + handleSteamCloudSave() + } else { + Alert(i18next.t('account.steamCloud.uploadFailed')) + uploadBtn.disabled = false + uploadBtn.textContent = i18next.t('account.steamCloud.upload') + } + }) + + // Handle download (export to file) + downloadBtn.addEventListener('click', async (e) => { + e.stopPropagation() + + downloadBtn.disabled = true + const steamSave = await cloudReadFile(saveFileName) + + if (!steamSave) { + Alert(i18next.t('account.steamCloud.readFailed')) + downloadBtn.disabled = false + return + } + + await exportData(steamSave, `synergism_steam_cloud_${steamId}.txt`) + Alert(i18next.t('account.downloadComplete')) + downloadBtn.disabled = false + }) + + // Handle load (import into game) + loadBtn.addEventListener('click', async (e) => { + e.stopPropagation() + + loadBtn.disabled = true + const steamSave = await cloudReadFile(saveFileName) + + if (!steamSave) { + Alert(i18next.t('account.steamCloud.readFailed')) + loadBtn.disabled = false + return + } + + importSynergism(steamSave) + }) + + // Insert Steam row at the beginning (after headers) + const firstRow = table.querySelector('.grid-row') + const emptyState = table.querySelector('.empty-state') + + // Remove empty state if exists + emptyState?.remove() + + if (firstRow) { + table.insertBefore(rowDiv, firstRow) + table.insertBefore(detailsRow, firstRow) + } else { + table.appendChild(rowDiv) + table.appendChild(detailsRow) + } +} diff --git a/src/Synergism.ts b/src/Synergism.ts index f0c7910a1..35827a6aa 100644 --- a/src/Synergism.ts +++ b/src/Synergism.ts @@ -175,7 +175,7 @@ import { updateMaxTokens, updateTokens } from './Campaign' -import { dev, lastUpdated, prod, testing, version } from './Config' +import { dev, lastUpdated, platform, prod, testing, version } from './Config' import { WowCubes, WowHypercubes, WowPlatonicCubes, WowTesseracts } from './CubeExperimental' import { eventCheck } from './Event' import { autobuyAnts } from './Features/Ants' @@ -1257,9 +1257,29 @@ export const saveSynergy = (button?: boolean) => { setTimeout(() => (el.textContent = ''), 4000) } + // Auto-sync to Steam Cloud (throttled to every 60 seconds) + if (platform === 'steam') { + const now = Date.now() + if (now - lastSteamCloudSync >= 60_000) { + lastSteamCloudSync = now + void syncToSteamCloud(save) + } + } + return true } +let lastSteamCloudSync = 0 + +async function syncToSteamCloud (saveData: string) { + const { cloudWriteFile, getSteamId } = await import('./steam/steam') + const steamId = await getSteamId() + if (!steamId) return + + const saveFileName = `synergism_${steamId}.txt` + await cloudWriteFile(saveFileName, saveData) +} + const loadSynergy = () => { const saveString = localStorage.getItem('Synergysave2') const data = saveString ? JSON.parse(atob(saveString)) : null @@ -5293,6 +5313,15 @@ window.addEventListener('load', async () => { i18n: { value: i18next } }) } + + if (platform === 'steam') { + const { setRichPresenceDiscord } = await import('./steam/discord') + + setRichPresenceDiscord({ + details: 'Playing Synergism', + state: 'gathering quarks...' + }) + } }, { once: true }) window.addEventListener('unload', () => { diff --git a/src/Tabs.ts b/src/Tabs.ts index b3c510c5f..e6ad2a216 100644 --- a/src/Tabs.ts +++ b/src/Tabs.ts @@ -1,5 +1,7 @@ +import i18next from 'i18next' import { awardUngroupedAchievement } from './Achievements' import { DOMCacheGetOrSet, DOMCacheHas } from './Cache/DOM' +import { platform } from './Config' import { pressedKeys } from './Hotkeys' import { hasUnreadMessages } from './Messages' import { initializeCart } from './purchases/CartTab' @@ -49,8 +51,8 @@ interface SubTab { subtabIndex: number subTabList: { subTabID: string - unlocked: boolean - buttonID?: string + unlocked: () => boolean + buttonID: string }[] } @@ -59,38 +61,30 @@ const subtabInfo: Record = { tabSwitcher: () => setActiveSettingScreen, subtabIndex: 0, subTabList: [ - { subTabID: 'settingsubtab', unlocked: true, buttonID: 'switchSettingSubTab1' }, - { subTabID: 'languagesubtab', unlocked: true, buttonID: 'switchSettingSubTab2' }, - { subTabID: 'creditssubtab', unlocked: true, buttonID: 'switchSettingSubTab3' }, - { subTabID: 'statisticsSubTab', unlocked: true, buttonID: 'switchSettingSubTab4' }, + { subTabID: 'settingsubtab', unlocked: () => true, buttonID: 'switchSettingSubTab1' }, + { subTabID: 'languagesubtab', unlocked: () => true, buttonID: 'switchSettingSubTab2' }, + { subTabID: 'creditssubtab', unlocked: () => true, buttonID: 'switchSettingSubTab3' }, + { subTabID: 'statisticsSubTab', unlocked: () => true, buttonID: 'switchSettingSubTab4' }, { subTabID: 'resetHistorySubTab', - get unlocked () { - return player.unlocks.prestige - }, + unlocked: () => player.unlocks.prestige, buttonID: 'switchSettingSubTab5' }, { subTabID: 'ascendHistorySubTab', - get unlocked () { - return player.ascensionCount > 0 - }, + unlocked: () => player.ascensionCount > 0, buttonID: 'switchSettingSubTab6' }, { subTabID: 'singularityHistorySubTab', - get unlocked () { - return player.highestSingularityCount > 0 - }, + unlocked: () => player.highestSingularityCount > 0, buttonID: 'switchSettingSubTab7' }, - { subTabID: 'hotkeys', unlocked: true, buttonID: 'switchSettingSubTab8' }, - { subTabID: 'accountSubTab', unlocked: true, buttonID: 'switchSettingSubTab9' }, + { subTabID: 'hotkeys', unlocked: () => true, buttonID: 'switchSettingSubTab8' }, + { subTabID: 'accountSubTab', unlocked: () => true, buttonID: 'switchSettingSubTab9' }, { subTabID: 'messagesSubTab', - get unlocked () { - return hasUnreadMessages() - }, + unlocked: hasUnreadMessages, buttonID: 'switchSettingSubTab10' } ] @@ -103,33 +97,25 @@ const subtabInfo: Record = { tabSwitcher: () => toggleBuildingScreen, subtabIndex: 0, subTabList: [ - { subTabID: 'coin', unlocked: true, buttonID: 'switchToCoinBuilding' }, + { subTabID: 'coin', unlocked: () => true, buttonID: 'switchToCoinBuilding' }, { subTabID: 'diamond', - get unlocked () { - return player.unlocks.prestige - }, + unlocked: () => player.unlocks.prestige, buttonID: 'switchToDiamondBuilding' }, { subTabID: 'mythos', - get unlocked () { - return player.unlocks.transcend - }, + unlocked: () => player.unlocks.transcend, buttonID: 'switchToMythosBuilding' }, { subTabID: 'particle', - get unlocked () { - return player.unlocks.reincarnate - }, + unlocked: () => player.unlocks.reincarnate, buttonID: 'switchToParticleBuilding' }, { subTabID: 'tesseract', - get unlocked () { - return player.ascensionCount > 0 - }, + unlocked: () => player.ascensionCount > 0, buttonID: 'switchToTesseractBuilding' } ] @@ -144,16 +130,12 @@ const subtabInfo: Record = { subTabList: [ { subTabID: '1', - get unlocked () { - return true - }, + unlocked: () => true, buttonID: 'toggleAchievementSubTab1' }, { subTabID: '2', - get unlocked () { - return true - }, + unlocked: () => true, buttonID: 'toggleAchievementSubTab2' } ] @@ -164,30 +146,22 @@ const subtabInfo: Record = { subTabList: [ { subTabID: '1', - get unlocked () { - return player.unlocks.prestige - }, + unlocked: () => player.unlocks.prestige, buttonID: 'toggleRuneSubTab1' }, { subTabID: '2', - get unlocked () { - return player.unlocks.talismans - }, + unlocked: () => player.unlocks.talismans, buttonID: 'toggleRuneSubTab2' }, { subTabID: '3', - get unlocked () { - return player.unlocks.blessings - }, + unlocked: () => player.unlocks.blessings, buttonID: 'toggleRuneSubTab3' }, { subTabID: '4', - get unlocked () { - return player.unlocks.spirits - }, + unlocked: () => player.unlocks.spirits, buttonID: 'toggleRuneSubTab4' } ] @@ -196,12 +170,10 @@ const subtabInfo: Record = { tabSwitcher: () => toggleChallengesScreen, subtabIndex: 0, subTabList: [ - { subTabID: '1', unlocked: true, buttonID: 'toggleChallengesSubTab1' }, + { subTabID: '1', unlocked: () => true, buttonID: 'toggleChallengesSubTab1' }, { subTabID: '2', - get unlocked () { - return player.highestSingularityCount >= 25 - }, + unlocked: () => player.highestSingularityCount >= 25, buttonID: 'toggleChallengesSubTab2' } ] @@ -216,19 +188,17 @@ const subtabInfo: Record = { subTabList: [ { subTabID: '1', - unlocked: true, + unlocked: () => true, buttonID: 'toggleAntSubtab1' }, { subTabID: '2', - unlocked: true, + unlocked: () => true, buttonID: 'toggleAntSubtab2' }, { subTabID: '3', - get unlocked () { - return player.ants.antSacrificeCount > 0 - }, + unlocked: () => player.ants.antSacrificeCount > 0, buttonID: 'toggleAntSubtab3' } ] @@ -239,51 +209,37 @@ const subtabInfo: Record = { subTabList: [ { subTabID: '1', - get unlocked () { - return player.unlocks.ascensions - }, + unlocked: () => player.unlocks.ascensions, buttonID: 'switchCubeSubTab1' }, { subTabID: '2', - get unlocked () { - return player.unlocks.tesseracts - }, + unlocked: () => player.unlocks.tesseracts, buttonID: 'switchCubeSubTab2' }, { subTabID: '3', - get unlocked () { - return player.unlocks.hypercubes - }, + unlocked: () => player.unlocks.hypercubes, buttonID: 'switchCubeSubTab3' }, { subTabID: '4', - get unlocked () { - return player.unlocks.platonics - }, + unlocked: () => player.unlocks.platonics, buttonID: 'switchCubeSubTab4' }, { subTabID: '5', - get unlocked () { - return player.unlocks.ascensions - }, + unlocked: () => player.unlocks.ascensions, buttonID: 'switchCubeSubTab5' }, { subTabID: '6', - get unlocked () { - return player.unlocks.platonics - }, + unlocked: () => player.unlocks.platonics, buttonID: 'switchCubeSubTab6' }, { subTabID: '7', - get unlocked () { - return player.unlocks.hepteracts - }, + unlocked: () => player.unlocks.hepteracts, buttonID: 'switchCubeSubTab7' } ] @@ -298,12 +254,12 @@ const subtabInfo: Record = { subTabList: [ { subTabID: 'true', - unlocked: true, + unlocked: () => true, buttonID: 'corrStatsBtn' }, { subTabID: 'false', - unlocked: true, + unlocked: () => true, buttonID: 'corrLoadoutsBtn' } ] @@ -314,37 +270,27 @@ const subtabInfo: Record = { subTabList: [ { subTabID: '1', - get unlocked () { - return player.highestSingularityCount > 0 - }, + unlocked: () => player.highestSingularityCount > 0, buttonID: 'toggleSingularitySubTab1' }, { subTabID: '2', - get unlocked () { - return player.highestSingularityCount > 0 - }, + unlocked: () => player.highestSingularityCount > 0, buttonID: 'toggleSingularitySubTab2' }, { subTabID: '3', - get unlocked () { - return player.highestSingularityCount > 0 - }, + unlocked: () => player.highestSingularityCount > 0, buttonID: 'toggleSingularitySubTab3' }, { subTabID: '4', - get unlocked () { - return Boolean(getGQUpgradeEffect('octeractUnlock')) - }, + unlocked: () => Boolean(getGQUpgradeEffect('octeractUnlock')), buttonID: 'toggleSingularitySubTab4' }, { subTabID: '5', - get unlocked () { - return player.highestSingularityCount >= 25 - }, + unlocked: () => player.highestSingularityCount >= 25, buttonID: 'toggleSingularitySubTab5' } ] @@ -359,32 +305,32 @@ const subtabInfo: Record = { subTabList: [ { subTabID: 'productContainer', - unlocked: true, + unlocked: () => true, buttonID: 'cartSubTab1' }, { subTabID: 'subscriptionContainer', - unlocked: true, + unlocked: () => platform !== 'steam', buttonID: 'cartSubTab2' }, { subTabID: 'upgradesContainer', - unlocked: true, + unlocked: () => true, buttonID: 'cartSubTab3' }, { subTabID: 'consumablesSection', - unlocked: true, + unlocked: () => true, buttonID: 'cartSubTab4' }, { subTabID: 'cartContainer', - unlocked: true, + unlocked: () => true, buttonID: 'cartSubTab5' }, { subTabID: 'merchContainer', - unlocked: true, + unlocked: () => true, buttonID: 'cartSubTab6' } ] @@ -534,7 +480,7 @@ class TabRow extends HTMLDivElement { interface kSubTabOptionsBag { id: string class?: string - i18n?: string + i18n: string borderColor?: string } @@ -551,9 +497,9 @@ class $Tab extends HTMLButtonElement { if (options.class) { this.classList.add(options.class) } - if (options.i18n) { - this.setAttribute('i18n', options.i18n) - } + + this.setAttribute('i18n', options.i18n) + if (options.borderColor) { this.style.borderColor = options.borderColor } @@ -759,15 +705,29 @@ export const changeTab = (tabs: Tabs, step?: number) => { const subTabList = subtabInfo[G.currentTab].subTabList for (let i = 0; i < subTabList.length; i++) { const id = subTabList[i].buttonID - if (id && DOMCacheHas(id)) { + if (DOMCacheHas(id)) { const button = DOMCacheGetOrSet(id) + if (!subTabList[i].unlocked()) { + button.classList.add('none') + } + if (button.classList.contains('active-subtab')) { subtabInfo[tabRow.getCurrentTab().getType()].subtabIndex = i - break } } } + + if (platform === 'steam') { + import('./steam/discord').then(({ setRichPresenceDiscord }) => { + const i18n = tabRow.getCurrentTab().getAttribute('i18n') + setRichPresenceDiscord({ + details: 'Playing Synergism', + state: `Looking at ${i18next.t(i18n!)}...`, + startTimestamp: new Date() + }) + }) + } } export const changeSubTab = (tabs: Tabs, { page, step }: SubTabSwitchOptions) => { @@ -796,7 +756,7 @@ export const changeSubTab = (tabs: Tabs, { page, step }: SubTabSwitchOptions) => let subTabList = subTabs.subTabList[subtabInfo[tab.getType()].subtabIndex] - while (!subTabList.unlocked) { + while (!subTabList.unlocked()) { subtabInfo[tab.getType()].subtabIndex = limitRange( subtabInfo[tab.getType()].subtabIndex + (step ?? 1), 0, @@ -805,10 +765,8 @@ export const changeSubTab = (tabs: Tabs, { page, step }: SubTabSwitchOptions) => subTabList = subTabs.subTabList[subtabInfo[tab.getType()].subtabIndex] } - if (subTabList.unlocked) { + if (subTabList.unlocked()) { for (const subtab of subTabs.subTabList) { - if (!subtab.buttonID) continue - const element = DOMCacheGetOrSet(subtab.buttonID) if (subtab === subTabList) { diff --git a/src/mock/websocket.ts b/src/mock/websocket.ts index a982a588a..e2df80352 100644 --- a/src/mock/websocket.ts +++ b/src/mock/websocket.ts @@ -81,7 +81,7 @@ export const consumeHandlers = [ clearTimeout(lotus.timer) } - lotus.timer = setTimeout(() => { + lotus.timer = +setTimeout(() => { lotus.active -= data.amount client.send(messages.lotusEnded()) }, lotus.activeUntil - Date.now()) diff --git a/src/purchases/CartUtil.ts b/src/purchases/CartUtil.ts index efb5cd769..630982e04 100644 --- a/src/purchases/CartUtil.ts +++ b/src/purchases/CartUtil.ts @@ -93,3 +93,9 @@ export const getProductsInCart = () => { return temp } + +export function calculateGrossPrice (net: number) { + const platformFeePercent = 0 + + return net / ((100 - platformFeePercent) / 100) +} diff --git a/src/purchases/CheckoutTab.ts b/src/purchases/CheckoutTab.ts index 90f529f98..b3dd83ef6 100644 --- a/src/purchases/CheckoutTab.ts +++ b/src/purchases/CheckoutTab.ts @@ -1,12 +1,27 @@ import { type FUNDING_SOURCE, loadScript } from '@paypal/paypal-js' -import { prod } from '../Config' +import { platform, prod } from '../Config' import { Alert, Confirm, Notification } from '../UpdateHTML' -import { memoize } from '../Utility' +import { assert, memoize } from '../Utility' import { products, subscriptionProducts } from './CartTab' -import { addToCart, clearCart, getPrice, getProductsInCart, getQuantity, removeFromCart } from './CartUtil' +import { + addToCart, + calculateGrossPrice, + clearCart, + getPrice, + getProductsInCart, + getQuantity, + removeFromCart +} from './CartUtil' import { initializePayPal_Subscription } from './SubscriptionsSubtab' import { updatePseudoCoins } from './UpgradesSubtab' +interface SteamGetUserInfoResponse { + country: string + currency: string + state: string + status: 'Locked' | 'Active' | 'Trusted' +} + const tab = document.querySelector('#pseudoCoins > #cartContainer')! const form = tab.querySelector('div.cartList')! @@ -97,15 +112,138 @@ export const initializeCheckoutTab = memoize(() => { .finally(reset) } - checkoutStripe?.addEventListener('click', submitCheckout) - checkoutNowPayments?.addEventListener('click', submitCheckout) + // https://partner.steamgames.com/doc/features/microtransactions/implementation#5 + async function submitCheckoutSteam (_e: MouseEvent) { + // Step 2 + const { getCurrentGameLanguage, getSteamId, onMicroTxnAuthorizationResponse } = await import('../steam/steam') + + const [steamId, currentGameLanguage] = await Promise.all([getSteamId(), getCurrentGameLanguage()]) + + if (!steamId || !currentGameLanguage) { + await Alert('Steam is not initialized, I cannot create a transaction') + return + } + + const response = await fetch('/api/v1/steam/get-user-info', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + steamId, + currentGameLanguage + }) + }) + + if (!response.ok) { + const { error } = await response.json() + Notification(error) + return + } + + const { status } = await response.json() as SteamGetUserInfoResponse + + if (status === 'Locked') { + await Alert('Your Steam account is locked. You cannot make purchases.') + return + } + + // Step 3 + const fd = new FormData() + + for (const product of getProductsInCart()) { + fd.set(product.id, `${product.quantity}`) + } + + fd.set('tosAgree', radioTOSAgree.checked ? 'on' : 'off') + + const initTxnResponse = await fetch('/api/v1/steam/init-txn', { + method: 'POST', + body: fd + }) + + if (!initTxnResponse.ok) { + const { error } = await initTxnResponse.json() as { error: string } + Notification(error) + return + } + + const { orderId } = await initTxnResponse.json() as { orderId: string; transId: string } + + // Step 4 + type MicroTxnAuthorizationResponse = import('../steam/steam').MicroTxnAuthorizationResponse + + const p = Promise.withResolvers() + onMicroTxnAuthorizationResponse((txnResponse) => p.resolve(txnResponse)) + + const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 15 * 60 * 1000)) + + let txnResponse: MicroTxnAuthorizationResponse + try { + txnResponse = await Promise.race([p.promise, timeout]) + } catch { + Notification('Steam did not respond in time. Please try again.') + return + } + + if (txnResponse.order_id.toString() !== orderId) { + return + } + + if (!txnResponse.authorized) { + Notification('Transaction was not authorized.') + return + } + + // Step 5 + const finalizeResponse = await fetch('/api/v1/steam/finalize-txn', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ orderId }) + }) + + if (!finalizeResponse.ok) { + const { error } = await finalizeResponse.json() as { error: string } + Notification(error) + return + } + + Notification('Transaction completed successfully!') + clearCart() + updateItemList() + updateTotalPriceInCart() + exponentialPseudoCoinBalanceCheck() + } // Remove rainbow border highlight when TOS is clicked radioTOSAgree.addEventListener('change', () => { tosSection.classList.remove('rainbow-border-highlight') }) - initializePayPal_OneTime('#checkout-paypal') + if (platform !== 'steam') { + checkoutStripe?.addEventListener('click', submitCheckout) + checkoutNowPayments?.addEventListener('click', submitCheckout) + + initializePayPal_OneTime('#checkout-paypal') + } else { + const checkoutButtonsContainer = tab.querySelector('#checkout-buttons')! + + // Hide Stripe/PayPal/NowPayments checkout buttons + checkoutButtonsContainer.querySelectorAll('*').forEach((el) => el.classList.add('none')) + + // Add Steam checkout button + const checkoutSteam = document.createElement('button') + checkoutSteam.id = 'checkout-steam' + checkoutSteam.type = 'submit' + checkoutSteam.textContent = 'Checkout with Steam' + checkoutSteam.addEventListener('click', (ev) => { + checkoutSteam.disabled = true + submitCheckoutSteam(ev).finally(() => checkoutSteam.disabled = false) + }) + checkoutButtonsContainer.appendChild(checkoutSteam) + } }) function addItem (e: MouseEvent) { @@ -181,13 +319,15 @@ export const clearCheckoutTab = () => { } const updateTotalPriceInCart = () => { - totalCost!.textContent = `${formatter.format(getPrice() / 100)} USD` + totalCost!.textContent = `${formatter.format(calculateGrossPrice(getPrice() / 100))} USD` } /** * https://stackoverflow.com/a/69024269 */ async function initializePayPal_OneTime (selector: string | HTMLElement) { + assert(platform !== 'steam', 'Cannot use PayPal on steam') + const paypal = await loadScript({ clientId: 'AS1HYTVcH3Kqt7IVgx7DkjgG8lPMZ5kyPWamSBNEowJ-AJPpANNTJKkB_mF0C4NmQxFuWQ9azGbqH2Gr', disableFunding: ['paylater', 'credit', 'card'] satisfies FUNDING_SOURCE[], @@ -314,14 +454,13 @@ async function initializePayPal_OneTime (selector: string | HTMLElement) { const sleep = (delay: number) => new Promise((r) => setTimeout(r, delay)) async function exponentialPseudoCoinBalanceCheck () { - const delays = [0, 30_000, 60_000, 120_000, 180_000, 240_000, 300_000] - const lastCoinAmount = 0 + const delays = [15_000, 30_000, 60_000, 120_000, 180_000, 240_000, 300_000] + const lastCoinAmount = await updatePseudoCoins() for (const delay of delays) { await sleep(delay) - const coins = await updatePseudoCoins() - if (lastCoinAmount !== coins) { + if (lastCoinAmount !== await updatePseudoCoins()) { break } } diff --git a/src/purchases/ProductSubtab.ts b/src/purchases/ProductSubtab.ts index f4d455b35..bbf86d49e 100644 --- a/src/purchases/ProductSubtab.ts +++ b/src/purchases/ProductSubtab.ts @@ -2,7 +2,7 @@ import { format } from '../Synergism' import { Alert, Notification } from '../UpdateHTML' import { memoize } from '../Utility' import { coinProducts } from './CartTab' -import { addToCart } from './CartUtil' +import { addToCart, calculateGrossPrice } from './CartUtil' const productContainer = document.querySelector('#pseudoCoins > #productContainer') @@ -33,7 +33,7 @@ export const initializeProductPage = memoize(() => { ${product.name} [${format(product.coins)} PseudoCoins]

diff --git a/src/steam/discord.ts b/src/steam/discord.ts new file mode 100644 index 000000000..01f71e2d9 --- /dev/null +++ b/src/steam/discord.ts @@ -0,0 +1,15 @@ +import type { PresenceOptions } from '../../electron/lib/discord' + +interface Discord { + setRichPresence: (options: PresenceOptions) => Promise +} + +declare global { + interface Window { + discord?: Discord + } +} + +export const setRichPresenceDiscord: Discord['setRichPresence'] = (options) => { + return window.discord?.setRichPresence(options) ?? Promise.resolve() +} diff --git a/src/steam/steam.ts b/src/steam/steam.ts new file mode 100644 index 000000000..888b07ea3 --- /dev/null +++ b/src/steam/steam.ts @@ -0,0 +1,52 @@ +import type { CallbackReturns } from 'steamworks.js/callbacks' +import type { callback } from 'steamworks.js/client' + +export type MicroTxnAuthorizationResponse = CallbackReturns[callback.SteamCallback.MicroTxnAuthorizationResponse] + +interface Steam { + getSteamId: () => Promise + getUsername: () => Promise + getCurrentGameLanguage: () => Promise + setRichPresence: (key: string, value?: string) => Promise + getSessionTicket: () => Promise + onMicroTxnAuthorizationResponse: (callback: (response: MicroTxnAuthorizationResponse) => void) => void + // Steam Cloud Storage + cloudFileExists: (name: string) => Promise + cloudReadFile: (name: string) => Promise + cloudWriteFile: (name: string, content: string) => Promise + cloudDeleteFile: (name: string) => Promise +} + +declare global { + interface Window { + steam?: Steam + } +} + +export const getSteamId = (): Promise => window.steam?.getSteamId() ?? Promise.resolve(null) + +export const getUsername = (): Promise => window.steam?.getUsername() ?? Promise.resolve(null) + +export const getCurrentGameLanguage: Steam['getCurrentGameLanguage'] = () => + window.steam?.getCurrentGameLanguage() ?? Promise.resolve(null) + +export const setRichPresenceSteam: Steam['setRichPresence'] = (...args) => + window.steam?.setRichPresence(...args) ?? Promise.resolve() + +export const getSessionTicket = () => window.steam?.getSessionTicket() ?? Promise.resolve(null) + +export const onMicroTxnAuthorizationResponse: Steam['onMicroTxnAuthorizationResponse'] = (callback) => + window.steam?.onMicroTxnAuthorizationResponse(callback) + +// Steam Cloud Storage +export const cloudFileExists: Steam['cloudFileExists'] = (name) => + window.steam?.cloudFileExists(name) ?? Promise.resolve(false) + +export const cloudReadFile: Steam['cloudReadFile'] = (name) => + window.steam?.cloudReadFile(name) ?? Promise.resolve(null) + +export const cloudWriteFile: Steam['cloudWriteFile'] = (name, content) => + window.steam?.cloudWriteFile(name, content) ?? Promise.resolve(false) + +export const cloudDeleteFile: Steam['cloudDeleteFile'] = (name) => + window.steam?.cloudDeleteFile(name) ?? Promise.resolve(false) diff --git a/translations/en.json b/translations/en.json index 059dcde20..7491e231a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -7429,6 +7429,19 @@ "diamondSmithMessiah": "Diamond Smith Messiah", "mythosSmith": "The Mythos Smith SPIRIT OF DERPSMITH", "yourself": "Being yourself! (Making a Synergism Account)" + }, + "steamCloud": { + "title": "Steam Cloud Save", + "name": "Steam Cloud Save", + "exists": "Save available", + "noSave": "No save yet", + "upload": "Upload to Steam", + "uploading": "Uploading...", + "uploadSuccess": "Save uploaded to Steam Cloud!", + "uploadFailed": "Failed to upload to Steam Cloud.", + "readFailed": "Failed to read Steam Cloud save.", + "noLocalSave": "No local save found.", + "confirmOverwrite": "This will overwrite your existing Steam Cloud save. Continue?" } }, "pseudoCoins": { diff --git a/translations/ja.json b/translations/ja.json index 050ef8f20..53039a8ed 100644 --- a/translations/ja.json +++ b/translations/ja.json @@ -5818,7 +5818,6 @@ "Viscount": "Benefits of being a Viscount:", "FirstSingularityBonus": "First Singularity Bonus:", "Event": "Event + Consumables Bonus:", - "PatreonBonus": "Patreon Bonus:", "Total": "Total Quark Multiplier:" }, "baseObtainiumStats": { @@ -6104,15 +6103,12 @@ "FastForwards": "Singularity Fast Forwards:", "ImmaculateAlchemy": "Immaculate Alchemy:", "Event": "Event Bonus:", - "PatreonBonus": "Patreon Bonus:", - "FirstSingBonus": "First Singularity Bonus:", "Total": "Total Golden Quark Gained:" }, "goldenQuarkPurchaseCostStats": { "title": "Golden Quark Purchase Cost", "Base": "Base Cost:", "PseudoCoins": "Gold Quark PseudoCoin Upgrade:", - "Patreon": "Patreon:", "AchievementPoints": "Achievement Points:", "CubeUpgrade6x10": "Cube Upgrade 6x10:", "GoldenQuarks2": "Golden Quarks II:", diff --git a/tsconfig.json b/tsconfig.json index b4f1a09d0..bf9fae4e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -70,6 +70,9 @@ /* Advanced Options */ "skipLibCheck": true, /* Skip type checking of declaration files. */ - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ - } + "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ + + "allowImportingTsExtensions": true + }, + "exclude": ["src-tauri"] }