diff --git a/.gitignore b/.gitignore index 0875bf0..c3b1f22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,4 @@ -# Dependencies -node_modules/ - -# Build output -dist/ - -# Environment files -.env -.env.local -.env.*.local - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS files +node_modules .DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# Lock files (optional - remove if you want to track) -# package-lock.json +dist +.env \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d33b7da --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "es5", + "quoteProps": "preserve", + "plugins": [] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..205e0ec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +You are writing a Devvit web application that will be executed on Reddit.com. + +## Tech Stack + +- **Frontend**: GameMaker, Vite +- **Backend**: Node.js v22 serverless environment (Devvit), Hono, TRPC +- **Communication**: tRPC v11 for end-to-end type safety + +## Layout & Architecture + +- `/src/server`: **Backend Code**. This runs in a secure, serverless environment. + - `trpc.ts`: Defines the API router and procedures. + - `index.ts`: Main server entry point (Hono app). + - Access `redis`, `reddit`, and `context` here via `@devvit/web/server`. +- `/src/client`: **Frontend Code**. This is executed inside of an iFrame on reddit.com + - To add an entrypoint, create a HTML file and add to the mapping inside of `devvit.json` + - Entrypoints: + - `game.html`: The main React entry point (Expanded View). + - `splash.html`: The initial React entry point (Inline View). This will be shown in the reddit.com feed. Please keep it fast and keep heavy dependencies inside of `game.html` +- `/src/shared`: **Shared Code**. Code to share between the client and server + +## Frontend + +### Rules + +- Instead of `window.location` or `window.assign`, use `navigateTo` from `@devvit/web/client` + +### Limitations + +- `window.alert`: Use `showToast` or `showForm` from `@devvit/web/client` +- File downloads: Use clipboard API with `showToast` to confirm +- Geolocation, camera, microphone, and notifications web APIs: No alternatives +- Inline script tags inside of `html` files: Use a script tag and separate js/ts file + +## Commands + +- `npm run type-check`: Check typescript types +- `npm run lint`: Check the linter +- `npm run test -- my-file-name`: Run tests isolated to a file + +## Code Style + +- Prefer type aliases over interfaces when writing typescript +- Prefer named exports over default exports +- Never cast typescript types + +## Global Rules + +- You may find code that references blocks or `@devvit/public-api` while building a feature. Do NOT use this code as this project is configured to use Devvit web only. +- Whenever you add an endpoint for a new menu item action, ensure that you've added the corresponding mapping to `devvit.json` so that it is properly registered + +Docs: https://developers.reddit.com/docs/llms.txt. diff --git a/README.md b/README.md index 0642633..7882088 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,4 @@ This is intended as a GameMaker wasm equivalent to the Devvit templates and is b ## Get Started -For instructions on how to get setup as a Reddit developer and start deploying GameMaker games to reddit, see [How To Build](docs/HowToBuild.md) \ No newline at end of file +For instructions on how to get setup as a Reddit developer and start deploying GameMaker games to reddit, see [How To Build](docs/HowToBuild.md) diff --git a/devvit.json b/devvit.json index b6d4d7a..dc0f7ca 100644 --- a/devvit.json +++ b/devvit.json @@ -25,10 +25,24 @@ "location": "subreddit", "forUserType": "moderator", "endpoint": "/internal/menu/post-create" + }, + { + "label": "Example form", + "description": "Show a simple form", + "location": "subreddit", + "forUserType": "moderator", + "endpoint": "/internal/menu/example-form" } ] }, + "forms": { + "exampleForm": "/internal/form/example-submit" + }, "triggers": { - "onAppInstall": "/internal/on-app-install" + "onAppInstall": "/internal/triggers/on-app-install" + }, + "scripts": { + "build": "vite build", + "dev": "vite build --watch" } } diff --git a/docs/HowToBuild.md b/docs/HowToBuild.md index d522cbe..8c8daf0 100644 --- a/docs/HowToBuild.md +++ b/docs/HowToBuild.md @@ -19,7 +19,7 @@ Before you begin, ensure you have the following installed and configured: - **Node.js 22+** - Download from [nodejs.org](https://nodejs.org/) - **GameMaker** - Any recent 2024.1400.3 Beta release (or newer), which you can download from [the release notes site](https://releases.gamemaker.io/) and must have also configured already for GX.games YYC development by following [its setup guide](https://github.com/YoYoGames/GameMaker-Bugs/wiki#platform-setup-guides) - - You may use older versions of GameMaker but will need to use the [GMEXT-Reddit Extension](https://github.com/YoYoGames/GMEXT-Reddit/) which includes the example project and requires you to build your game with the gx.games target rather than the reddit target. Further details can be found in the extension repository. + - You may use older versions of GameMaker but will need to use the [GMEXT-Reddit Extension](https://github.com/YoYoGames/GMEXT-Reddit/) which includes the example project and requires you to build your game with the gx.games target rather than the reddit target. Further details can be found in the extension repository. - **Reddit Developer Account** - Sign up at [developers.reddit.com](https://developers.reddit.com/) --- @@ -72,6 +72,7 @@ Open the GameMaker project that you want to deploy to Reddit. In the **Devvit Project ID** field, enter the short, no-spaces game name that you registered with Reddit earlier **Example:** + ``` my-game ``` @@ -79,6 +80,7 @@ my-game In the **Devvit Project Path** field, enter the full path to your Devvit project directory that you created in the previous section. **Example:** + ``` C:\Users\YourName\Projects\my-game ``` @@ -90,6 +92,7 @@ Click **OK** or **Apply** to save your settings. #### Step 4: Verify GameMaker Export Settings Ensure your project is configured to export to WebAssembly: + - **Target Platform**: Reddit - **Output Format**: GMS2 VM @@ -112,6 +115,7 @@ npm run dev **You only need to do this once per GameMaker session** - leave the terminal app running throughout your development session and then close it whenever you're done with all your Reddit builds for the day. This command does several things: + - Starts a local development server - Uploads your app to Reddit's Devvit platform - Provides a link to test your app on Reddit (not clickable - you will need to copy/paste it manually the first time and thereafter can just refresh your browser tab @@ -168,6 +172,7 @@ When you're ready to test your changes: > If not running on Windows, ensure that execute permissions are enabled on the `setup-gamemaker-devvit.sh` script within your devvit project GameMaker will: + - Build your game to WebAssembly - Automatically copy the necessary files to your Devvit project directory (specified in Game Options) - The build output goes to `src/client/public/` in your Devvit project @@ -175,6 +180,7 @@ GameMaker will: #### Step 3: Wait for Devvit to Detect Changes The `npm run dev` process (still running in your terminal) will shortly thereafter: + - Automatically detect the new/changed files - Re-upload your game to Reddit's platform - Display upload progress in the terminal @@ -184,6 +190,7 @@ Wait for the update to complete. Devvit will provide a link to the updated commu #### Step 4: Test on Reddit Once the upload completes: + 1. Open the link provided by `npm run dev` in your browser (or refresh if already open) 2. Test your game directly on Reddit 3. Iterate! @@ -252,10 +259,12 @@ your-project/ ## Adding Game Features ### Backend APIs + Add game-specific endpoints in `src/server/index.ts`: + ```typescript // Example: Save player score -router.post("/api/save-score", async (req, res) => { +router.post('/api/save-score', async (req, res) => { const { score } = req.body; // Save to Redis, database, etc. res.json({ success: true }); @@ -263,7 +272,9 @@ router.post("/api/save-score", async (req, res) => { ``` ### Type Definitions + Add API types in `src/shared/types/api.ts`: + ```typescript export type SaveScoreRequest = { score: number; @@ -278,6 +289,7 @@ export type SaveScoreRequest = { ### `npm run dev` doesn't detect changes **Solution:** + - Verify the Devvit Project Path in GameMaker is correct - Ensure files are being copied to the correct directory (`src/client/public/`) - Check terminal for any error messages @@ -286,6 +298,7 @@ export type SaveScoreRequest = { ### GameMaker build fails **Solution:** + - Check that the Reddit platform is properly configured - Ensure the Devvit Project Path exists and is writable - As a sanity-check, see if you can successfully build packages for the GX.games target inside GameMaker, rather than the Reddit one. If you can, check your OS file permissions/antivirus client are not blocking your Reddit-specific folders. @@ -293,6 +306,7 @@ export type SaveScoreRequest = { ### Game doesn't appear on Reddit **Solution:** + - Confirm `npm run dev` completed the upload successfully - Check the terminal for any error messages - Try a hard refresh in your browser (Ctrl+Shift+R or Cmd+Shift+R) @@ -301,6 +315,7 @@ export type SaveScoreRequest = { ### Files are in the wrong location **Solution:** + - GameMaker should copy files to `src/client/public/` in your Devvit project - Verify the Devvit Project Path setting in GameMaker Game Options - Check your OS file permissions/antivirus client are not blocking your Reddit-specific folders. @@ -308,6 +323,7 @@ export type SaveScoreRequest = { ### Node.js version issues **Solution:** + - Devvit requires Node.js 22+ - Run `node --version` to check your version - Update Node.js if necessary: [Download latest version](https://nodejs.org/) @@ -315,8 +331,9 @@ export type SaveScoreRequest = { ### setup-gamemaker-devvit.sh Permissions Issue **Solution** + - Ensure that execute permissions are enabled on the `setup-gamemaker-devvit.sh` script - - Run `ls -l setup-gamemaker-devvit.sh` to verify permissions + - Run `ls -l setup-gamemaker-devvit.sh` to verify permissions - Run `chmod +x setup-gamemaker-devvit.sh` to grant execute permissions --- diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7282254 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,68 @@ +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default defineConfig([ + tseslint.configs.recommended, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['src/server/**/*.{ts,tsx,mjs,cjs,js}'], + languageOptions: { + ecmaVersion: 2023, + globals: globals.node, + parserOptions: { + project: ['./tools/tsconfig.server.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['src/shared/**/*.{ts,tsx,mjs,cjs,js}'], + languageOptions: { + ecmaVersion: 2023, + globals: globals.browser, + parserOptions: { + project: ['./tools/tsconfig.shared.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['src/client/**/*.{ts,tsx}'], + ignores: ['src/server/**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2023, + globals: globals.browser, + parserOptions: { + project: ['./tools/tsconfig.client.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ['**/*.{js,mjs,cjs,ts,tsx}'], + rules: { + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-unused-vars': ['off'], + 'no-unused-vars': ['off'], + }, + ignores: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + 'eslint.config.js', + '**/vite.config.ts', + 'devvit.config.ts', + ], + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { js }, + extends: ['js/recommended'], + }, +]); diff --git a/package.json b/package.json index af64643..37797b4 100644 --- a/package.json +++ b/package.json @@ -5,29 +5,34 @@ "license": "BSD-3-Clause", "type": "module", "scripts": { - "postinstall": "npm run build", - "build:client": "cd src/client && vite build", - "build:server": "cd src/server && vite build", - "build": "npm run build:client && npm run build:server", - "deploy": "npm run build && devvit upload", - "dev": "concurrently -k -p \"[{name}]\" -n \"CLIENT,SERVER,DEVVIT\" -c \"blue,green,magenta\" \"npm run dev:client\" \"npm run dev:server\" \"npm run dev:devvit\"", - "dev:client": "cd src/client && vite build --watch", - "dev:devvit": "dotenv -e .env -- devvit playtest", - "dev:server": "cd src/server && vite build --watch", + "build": "vite build", + "deploy": "npm run type-check && npm run lint && npm run test && devvit upload", + "dev": "devvit playtest", + "launch": "npm run deploy && devvit publish", + "lint": "eslint 'src/**/*.{ts,tsx}'", "login": "devvit login", - "launch": "npm run build && npm run deploy && devvit publish", + "prettier": "prettier --write .", + "test": "vitest run", "type-check": "tsc --build" }, + "engines": { + "node": ">=22.12.0" + }, "dependencies": { - "@devvit/web": "0.12.8", - "devvit": "0.12.8", - "express": "5.1.0" + "@devvit/start": "0.12.11-next-2026-01-30-17-09-49-e9c512a0d.0", + "@devvit/web": "0.12.11-next-2026-01-30-17-09-49-e9c512a0d.0", + "@hono/node-server": "^1.19.9", + "devvit": "0.12.11-next-2026-01-30-17-09-49-e9c512a0d.0", + "hono": "4.11.7" }, "devDependencies": { - "@types/express": "5.0.1", - "concurrently": "9.1.2", - "dotenv-cli": "8.0.0", - "typescript": "5.8.2", - "vite": "6.2.4" + "@eslint/js": "9.39.2", + "@types/node": "^22.19.7", + "eslint": "9.39.2", + "globals": "17.2.0", + "prettier": "3.8.1", + "typescript": "5.9.3", + "typescript-eslint": "8.54.0", + "vite": "7.3.1" } -} \ No newline at end of file +} diff --git a/src/client/public/GmlSpec.xml b/public/GmlSpec.xml similarity index 100% rename from src/client/public/GmlSpec.xml rename to public/GmlSpec.xml diff --git a/src/client/public/audio-worklet.js b/public/audio-worklet.js similarity index 100% rename from src/client/public/audio-worklet.js rename to public/audio-worklet.js diff --git a/src/client/public/fnames b/public/fnames similarity index 100% rename from src/client/public/fnames rename to public/fnames diff --git a/src/client/public/game.unx b/public/game.unx similarity index 100% rename from src/client/public/game.unx rename to public/game.unx diff --git a/src/client/public/index.html b/public/index.html similarity index 100% rename from src/client/public/index.html rename to public/index.html diff --git a/src/client/public/interpolateOFF.xml b/public/interpolateOFF.xml similarity index 100% rename from src/client/public/interpolateOFF.xml rename to public/interpolateOFF.xml diff --git a/src/client/public/interpolateON.xml b/public/interpolateON.xml similarity index 100% rename from src/client/public/interpolateON.xml rename to public/interpolateON.xml diff --git a/src/client/public/run.xml b/public/run.xml similarity index 100% rename from src/client/public/run.xml rename to public/run.xml diff --git a/src/client/public/runner-sw.js b/public/runner-sw.js similarity index 100% rename from src/client/public/runner-sw.js rename to public/runner-sw.js diff --git a/src/client/public/runner.data b/public/runner.data similarity index 100% rename from src/client/public/runner.data rename to public/runner.data diff --git a/src/client/public/runner.html b/public/runner.html similarity index 100% rename from src/client/public/runner.html rename to public/runner.html diff --git a/src/client/public/runner.js b/public/runner.js similarity index 100% rename from src/client/public/runner.js rename to public/runner.js diff --git a/src/client/public/runner.json b/public/runner.json similarity index 100% rename from src/client/public/runner.json rename to public/runner.json diff --git a/src/client/public/runner.wasm b/public/runner.wasm similarity index 100% rename from src/client/public/runner.wasm rename to public/runner.wasm diff --git a/src/client/public/snoo.png b/public/snoo.png similarity index 100% rename from src/client/public/snoo.png rename to public/snoo.png diff --git a/src/client/public/sw.js b/public/sw.js similarity index 100% rename from src/client/public/sw.js rename to public/sw.js diff --git a/src/client/index.html b/src/client/index.html index f4dc719..9033798 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -1,9 +1,12 @@ - + - + Devvit Game diff --git a/src/client/main.ts b/src/client/main.ts index 2ae5b5b..48b46a5 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -1,4 +1,4 @@ -import { InitResponse } from "../shared/types/api"; +import type { InitResponse } from '../shared/api'; declare global { interface Window { @@ -37,7 +37,6 @@ type RunnerManifest = { runner?: { version?: string; yyc?: boolean }; }; - class GameLoader { private statusElement: HTMLElement; private progressElement: HTMLProgressElement; @@ -49,16 +48,18 @@ class GameLoader { private startingAspect?: number; constructor() { - this.statusElement = document.getElementById("status") as HTMLElement; - this.progressElement = document.getElementById("progress") as HTMLProgressElement; - this.spinnerElement = document.getElementById("spinner") as HTMLElement; - this.canvasElement = document.getElementById("canvas") as HTMLCanvasElement; - this.loadingElement = document.getElementById("loading") as HTMLElement; - - this.canvasElement.addEventListener("click", () => { + this.statusElement = document.getElementById('status') as HTMLElement; + this.progressElement = document.getElementById( + 'progress' + ) as HTMLProgressElement; + this.spinnerElement = document.getElementById('spinner') as HTMLElement; + this.canvasElement = document.getElementById('canvas') as HTMLCanvasElement; + this.loadingElement = document.getElementById('loading') as HTMLElement; + + this.canvasElement.addEventListener('click', () => { this.canvasElement.focus(); }); - + this.setupModule(); this.setupResizeObserver(); this.loadGame(); @@ -70,7 +71,7 @@ class GameLoader { postRun: [], print: (text: string) => { console.log(text); - if (text === "Entering main loop.") { + if (text === 'Entering main loop.') { this.ensureAspectRatio(); } }, @@ -80,58 +81,64 @@ class GameLoader { canvas: this.canvasElement, setStatus: (text: string) => { if (!window.Module.setStatus.last) { - window.Module.setStatus.last = { time: Date.now(), text: "" }; + window.Module.setStatus.last = { time: Date.now(), text: '' }; } if (text === window.Module.setStatus.last.text) return; - + const m = text.match(/([^(]+)\((\d+(?:\.\d+)?)\/(\d+)\)/); const now = Date.now(); if (m && now - window.Module.setStatus.last.time < 30) return; - + window.Module.setStatus.last.time = now; window.Module.setStatus.last.text = text; - + if (m) { - this.progressElement.value = parseInt(m[2]) * 100; - this.progressElement.max = parseInt(m[3]) * 100; + this.progressElement.value = parseInt(m[2]!) * 100; + this.progressElement.max = parseInt(m[3]!) * 100; this.progressElement.hidden = false; this.spinnerElement.hidden = false; } else { this.progressElement.value = 0; this.progressElement.max = 100; this.progressElement.hidden = true; - + if (!text) { - this.spinnerElement.style.display = "none"; - this.canvasElement.style.display = "block"; - this.loadingElement.style.display = "none"; + this.spinnerElement.style.display = 'none'; + this.canvasElement.style.display = 'block'; + this.loadingElement.style.display = 'none'; } } this.statusElement.innerHTML = text; }, totalDependencies: 0, monitorRunDependencies: (left: number) => { - window.Module.totalDependencies = Math.max(window.Module.totalDependencies, left); + window.Module.totalDependencies = Math.max( + window.Module.totalDependencies, + left + ); window.Module.setStatus( left ? `Preparing... (${window.Module.totalDependencies - left}/${window.Module.totalDependencies})` - : "All downloads complete." + : 'All downloads complete.' ); }, }; - - window.Module.setStatus("Downloading..."); - - window.onerror = (event) => { - window.Module.setStatus("Exception thrown, see JavaScript console"); - this.spinnerElement.style.display = "none"; + + window.Module.setStatus('Downloading...'); + + window.onerror = () => { + window.Module.setStatus('Exception thrown, see JavaScript console'); + this.spinnerElement.style.display = 'none'; window.Module.setStatus = (text: string) => { if (text) window.Module.printErr(`[post-exception status] ${text}`); }; }; - if (typeof window === "object") { - window.Module.arguments = window.location.search.substr(1).trim().split('&'); + if (typeof window === 'object') { + window.Module.arguments = window.location.search + .substr(1) + .trim() + .split('&'); if (!window.Module.arguments[0]) { window.Module.arguments = []; } @@ -148,12 +155,15 @@ class GameLoader { const resizeObserver = new ResizeObserver(() => { window.requestAnimationFrame(() => this.ensureAspectRatio()); - setTimeout(() => window.requestAnimationFrame(() => this.ensureAspectRatio()), 100); + setTimeout( + () => window.requestAnimationFrame(() => this.ensureAspectRatio()), + 100 + ); }); resizeObserver.observe(document.body); if (/Android|iPhone|iPod/i.test(navigator.userAgent)) { - document.body.classList.add("scrollingDisabled"); + document.body.classList.add('scrollingDisabled'); } } @@ -162,8 +172,8 @@ class GameLoader { return; } - this.canvasElement.classList.add("active"); - + this.canvasElement.classList.add('active'); + const maxWidth = window.innerWidth; const maxHeight = window.innerHeight; let newHeight: number, newWidth: number; @@ -179,78 +189,86 @@ class GameLoader { newHeight = newWidth / this.startingAspect!; } - this.canvasElement.style.height = "100%" //`${newHeight}px`; - this.canvasElement.style.width = "100%" //`${newWidth}px`; + this.canvasElement.style.height = '100%'; //`${newHeight}px`; + this.canvasElement.style.width = '100%'; //`${newWidth}px`; } private async loadRunnerManifest(): Promise { try { - const res = await fetch("/runner.json", { - credentials: "include", // keep Devvit context; same-origin - cache: "no-cache" // avoid stale manifest after deploys + const res = await fetch('/runner.json', { + credentials: 'include', // keep Devvit context; same-origin + cache: 'no-cache', // avoid stale manifest after deploys }); if (!res.ok) throw new Error(`runner.json HTTP ${res.status}`); const manifest = (await res.json()) as RunnerManifest; // Basic validation - if (!Array.isArray(manifest.manifestFiles) || !Array.isArray(manifest.manifestFilesMD5)) { - throw new Error("runner.json missing arrays"); + if ( + !Array.isArray(manifest.manifestFiles) || + !Array.isArray(manifest.manifestFilesMD5) + ) { + throw new Error('runner.json missing arrays'); } if (manifest.manifestFiles.length !== manifest.manifestFilesMD5.length) { - console.warn("[runner.json] manifestFiles and manifestFilesMD5 length mismatch"); + console.warn( + '[runner.json] manifestFiles and manifestFilesMD5 length mismatch' + ); } // Wire the global getters from the manifest - window.manifestFiles = () => manifest.manifestFiles.join(";"); + window.manifestFiles = () => manifest.manifestFiles.join(';'); window.manifestFilesMD5 = () => manifest.manifestFilesMD5.slice(); // return a copy - } catch (e) { - console.warn("Falling back to hardcoded manifest (runner.json not available):", e); + console.warn( + 'Falling back to hardcoded manifest (runner.json not available):', + e + ); // Fallback to current hardcoded values (this should never happen) window.manifestFiles = () => [ - "runner.data", - "runner.js", - "runner.wasm", - "audio-worklet.js", - "game.unx" - ].join(";"); - - window.manifestFilesMD5 = () => - [ - "585214623b669175a702fed30de7d21d", - "8669aa66d44cfb4f13a098cd6b0296e1", - "d29ac123833b56dcfbe188f10e5ecb85", - "e8f1e8db8cf996f8715a6f2164c2e44e", - "00a26996df3ce310bb5836ef7f4b0e3c" - ]; + 'runner.data', + 'runner.js', + 'runner.wasm', + 'audio-worklet.js', + 'game.unx', + ].join(';'); + + window.manifestFilesMD5 = () => [ + '585214623b669175a702fed30de7d21d', + '8669aa66d44cfb4f13a098cd6b0296e1', + 'd29ac123833b56dcfbe188f10e5ecb85', + 'e8f1e8db8cf996f8715a6f2164c2e44e', + '00a26996df3ce310bb5836ef7f4b0e3c', + ]; } } private setupGameMakerGlobals() { - // GameMaker async method support - make variables globally accessible window.g_pAddAsyncMethod = -1; window.setAddAsyncMethod = (asyncMethod: any) => { window.g_pAddAsyncMethod = asyncMethod; - console.log("setAddAsyncMethod called with:", asyncMethod); + console.log('setAddAsyncMethod called with:', asyncMethod); }; // Exception handling - make variables globally accessible window.g_pJSExceptionHandler = undefined; window.setJSExceptionHandler = (exceptionHandler: any) => { - if (typeof exceptionHandler === "function") { + if (typeof exceptionHandler === 'function') { window.g_pJSExceptionHandler = exceptionHandler; } }; window.hasJSExceptionHandler = () => { - return window.g_pJSExceptionHandler !== undefined && typeof window.g_pJSExceptionHandler === "function"; + return ( + window.g_pJSExceptionHandler !== undefined && + typeof window.g_pJSExceptionHandler === 'function' + ); }; window.doJSExceptionHandler = (exceptionJSON: string) => { - if (typeof window.g_pJSExceptionHandler === "function") { + if (typeof window.g_pJSExceptionHandler === 'function') { const exception = JSON.parse(exceptionJSON); window.g_pJSExceptionHandler(exception); } @@ -263,12 +281,12 @@ class GameLoader { }; window.onFirstFrameRendered = () => { - console.log("First frame rendered!"); + console.log('First frame rendered!'); }; // Ad system stubs window.triggerAd = (adId: string, ...callbacks: any[]) => { - console.log("triggerAd called with adId:", adId); + console.log('triggerAd called with adId:', adId); // For now, just call the callbacks to simulate ad completion if (callbacks.length > 0 && typeof callbacks[0] === 'function') { setTimeout(() => callbacks[0](), 100); @@ -276,7 +294,7 @@ class GameLoader { }; window.triggerPayment = (itemId: string, callback: any) => { - console.log("triggerPayment called with itemId:", itemId); + console.log('triggerPayment called with itemId:', itemId); // Simulate payment completion if (typeof callback === 'function') { setTimeout(() => callback({ id: itemId }), 1000); @@ -292,31 +310,34 @@ class GameLoader { }; // Multiplayer/networking stubs + // @ts-expect-error - here to show as an example let acceptable_rollback_frames = 0; window.set_acceptable_rollback = (frames: number) => { acceptable_rollback_frames = frames; - console.log("Set acceptable rollback frames:", frames); + console.log('Set acceptable rollback frames:', frames); }; window.report_stats = (statsData: any) => { - console.log("Game stats reported:", statsData); + console.log('Game stats reported:', statsData); }; window.log_next_game_state = () => { - console.log("Game state logging requested"); + console.log('Game state logging requested'); }; window.wallpaper_update_config = (config: string) => { - console.log("Wallpaper config update:", config); + console.log('Wallpaper config update:', config); }; window.wallpaper_reset_config = () => { - console.log("Wallpaper config reset"); + console.log('Wallpaper config reset'); }; // Mock accelerometer API to prevent permissions policy violations if (!('DeviceMotionEvent' in window)) { - (window as any).DeviceMotionEvent = class MockDeviceMotionEvent extends Event { + (window as any).DeviceMotionEvent = class MockDeviceMotionEvent extends ( + Event + ) { constructor(type: string, eventInitDict?: any) { super(type, eventInitDict); } @@ -324,11 +345,12 @@ class GameLoader { } if (!('DeviceOrientationEvent' in window)) { - (window as any).DeviceOrientationEvent = class MockDeviceOrientationEvent extends Event { - constructor(type: string, eventInitDict?: any) { - super(type, eventInitDict); - } - }; + (window as any).DeviceOrientationEvent = + class MockDeviceOrientationEvent extends Event { + constructor(type: string, eventInitDict?: any) { + super(type, eventInitDict); + } + }; } } @@ -336,28 +358,28 @@ class GameLoader { try { // First try to get initial data from the server await this.fetchInitialData(); - + // Load manifest data that GameMaker runtime expects await this.loadRunnerManifest(); // Setup required global functions before loading GameMaker script this.setupGameMakerGlobals(); - + // Load the GameMaker runner script const script = document.createElement('script'); script.src = '/runner.js'; script.async = true; script.type = 'text/javascript'; - + script.onload = () => { console.log('Game script loaded successfully'); }; - + script.onerror = (error) => { console.error('Failed to load game script:', error); this.statusElement.textContent = 'Failed to load game'; }; - + document.head.appendChild(script); } catch (error) { console.error('Error loading game:', error); @@ -367,18 +389,20 @@ class GameLoader { private async fetchInitialData() { try { - const response = await fetch("/api/init"); + const response = await fetch('/api/init'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = (await response.json()) as InitResponse; - if (data.type === "init") { - console.log(`Game initialized for user: ${data.username}, post: ${data.postId}`); + if (data.type === 'init') { + console.log( + `Game initialized for user: ${data.username}, post: ${data.postId}` + ); } else { - console.error("Invalid response type from /api/init", data); + console.error('Invalid response type from /api/init', data); } } catch (error) { - console.error("Error fetching initial data:", error); + console.error('Error fetching initial data:', error); } } } diff --git a/src/client/splash/splash.css b/src/client/splash.css similarity index 91% rename from src/client/splash/splash.css rename to src/client/splash.css index ed5275e..10ab4a5 100644 --- a/src/client/splash/splash.css +++ b/src/client/splash.css @@ -2,8 +2,9 @@ box-sizing: border-box; margin: 0; padding: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, - Arial, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; color: #000000; } @@ -83,4 +84,4 @@ .divider { color: rgba(0, 0, 0, 0.1); -} \ No newline at end of file +} diff --git a/src/client/splash.html b/src/client/splash.html index abbd49f..9bdff13 100644 --- a/src/client/splash.html +++ b/src/client/splash.html @@ -1,7 +1,7 @@ - + - +

- Edit src/client/splash/splash.ts to get - started. + Edit src/client/splash.ts to get started.

- + - \ No newline at end of file + diff --git a/src/client/splash.ts b/src/client/splash.ts new file mode 100644 index 0000000..e924963 --- /dev/null +++ b/src/client/splash.ts @@ -0,0 +1,17 @@ +import { context, requestExpandedMode } from '@devvit/web/client'; + +const startButton = document.getElementById( + 'start-button' +) as HTMLButtonElement; + +startButton.addEventListener('click', (e) => { + requestExpandedMode(e, 'game'); +}); + +const titleElement = document.getElementById('title') as HTMLHeadingElement; + +function init() { + titleElement.textContent = `Hey ${context.username ?? 'user'} 👋`; +} + +init(); diff --git a/src/client/splash/splash.ts b/src/client/splash/splash.ts deleted file mode 100644 index 78b788e..0000000 --- a/src/client/splash/splash.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { navigateTo, context, requestExpandedMode } from "@devvit/web/client"; - -const startButton = document.getElementById( - "start-button" -) as HTMLButtonElement; - -startButton.addEventListener("click", (e) => { - requestExpandedMode(e, "game"); -}); - -const titleElement = document.getElementById("title") as HTMLHeadingElement; - -function init() { - titleElement.textContent = `Hey ${context.username ?? "user"} 👋`; -} - -init(); \ No newline at end of file diff --git a/src/client/style.css b/src/client/style.css index d13d15b..2cd8b83 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -56,7 +56,11 @@ body { min-height: fill-available; min-height: 100svh; min-width: 100vw; - background: radial-gradient(56.63% 56.63% at 50% 43.37%, #1c1726 0%, #060612 100%); + background: radial-gradient( + 56.63% 56.63% at 50% 43.37%, + #1c1726 0%, + #060612 100% + ); display: flex; flex-direction: column; overflow-x: hidden; diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json deleted file mode 100644 index 13f82fc..0000000 --- a/src/client/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -// TypeScript config for all web view main thread code. -{ - "extends": "../../tools/tsconfig-base.json", - "compilerOptions": { - // Target the browser. - "customConditions": ["browser"], - - "lib": ["DOM", "ES2023", "esnext.disposable"], - - "outDir": "../../dist/types/client", - - // React - "jsx": "react-jsx", - - "tsBuildInfoFile": "../../dist/types/client/tsconfig.tsbuildinfo" - }, - // https://github.com/Microsoft/TypeScript/issues/25636 - "include": ["**/*", "**/*.json", "vite.config.ts"], - "exclude": ["**/*.test.ts"], - "references": [{ "path": "../shared" }] -} diff --git a/src/client/vite.config.ts b/src/client/vite.config.ts deleted file mode 100644 index a33be96..0000000 --- a/src/client/vite.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from "vite"; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [], - build: { - outDir: "../../dist/client", - rollupOptions: { - input: { - splash: "splash.html", - game: "index.html", - }, - output: { - entryFileNames: "[name].js", - chunkFileNames: "[name].js", - assetFileNames: "[name][extname]", - sourcemapFileNames: "[name].js.map", - }, - }, - }, -}); diff --git a/src/server/core/post.ts b/src/server/core/post.ts index 40d1e58..29aa66e 100644 --- a/src/server/core/post.ts +++ b/src/server/core/post.ts @@ -1,14 +1,7 @@ -import { context, reddit } from "@devvit/web/server"; +import { reddit } from '@devvit/web/server'; export const createPost = async () => { - const { subredditName } = context; - if (!subredditName) { - throw new Error("subredditName is required"); - } - return await reddit.submitCustomPost({ - subredditName: subredditName, - title: "<% name %>", - entry: 'default', + title: '<% name %>', }); }; diff --git a/src/server/index.ts b/src/server/index.ts index 95146ae..a2c55ec 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,282 +1,23 @@ -import express from "express"; -import { InitResponse } from "../shared/types/api"; -import { +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { createServer, getServerPort } from '@devvit/web/server'; +import { api } from './routes/api'; +import { forms } from './routes/forms'; +import { menu } from './routes/menu'; +import { triggers } from './routes/triggers'; + +const app = new Hono(); +const internal = new Hono(); + +internal.route('/menu', menu); +internal.route('/form', forms); +internal.route('/triggers', triggers); + +app.route('/api', api); +app.route('/internal', internal); + +serve({ + fetch: app.fetch, createServer, - context, - getServerPort, - reddit, - redis -} from "@devvit/web/server"; -import { createPost } from "./core/post"; - -const app = express(); - -// Middleware for JSON body parsing -app.use(express.json()); -// Middleware for URL-encoded body parsing -app.use(express.urlencoded({ extended: true })); -// Middleware for plain text body parsing -app.use(express.text()); - -const router = express.Router(); - -router.get< - { postId: string }, - InitResponse | { status: string; message: string } ->("/api/init", async (_req, res): Promise => { - const { postId } = context; - - if (!postId) { - console.error("API Init Error: postId not found in devvit context"); - res.status(400).json({ - status: "error", - message: "postId is required but missing from context", - }); - return; - } - - try { - const username = await reddit.getCurrentUsername(); - - res.json({ - type: "init", - postId: postId, - username: username ?? "anonymous", - }); - } catch (error) { - console.error(`API Init Error for post ${postId}:`, error); - let errorMessage = "Unknown error during initialization"; - if (error instanceof Error) { - errorMessage = `Initialization failed: ${error.message}`; - } - res.status(400).json({ status: "error", message: errorMessage }); - } -}); - -// Add your game-specific API endpoints here -// Examples: -// router.post("/api/save-score", async (req, res) => { ... }); -// router.get("/api/leaderboard", async (req, res) => { ... }); -// router.post("/api/game-event", async (req, res) => { ... }); - -// ########################################################################## -// # DEMO SAMPLE: State + Score + Leaderboard using Redis -// ########################################################################## - -type StoredState = { - username: string; - level?: number; - bestScore?: number; // <- optional; we mirror leaderboard here - data?: Record; - updatedAt: number; -}; - -function stateKey(postId: string, username: string) { - return `state:${postId}:${username}`; -} -function leaderboardKey(postId: string) { - return `lb:${postId}`; -} -async function getUsername(): Promise { - const u = await reddit.getCurrentUsername(); - return u ?? "anonymous"; -} - -// GET /api/state -> fetch current user's state for this post -router.get("/api/state", async (_req, res) => { - try { - const { postId } = context; - if (!postId) return res.status(400).json({ error: "Missing postId in context" }); - - const username = await getUsername(); - const key = stateKey(postId, username); - const json = await redis.get(key); - if (!json) return res.status(404).json({ error: "No state found" }); - - res.json(JSON.parse(json) as StoredState); - } catch (err) { - console.error("GET /api/state error:", err); - res.status(500).json({ error: "Failed to fetch state" }); - } + port: getServerPort(), }); - -// POST /api/state -> upsert current user's state for this post -router.post("/api/state", async (req, res) => { - try { - const { postId } = context; - if (!postId) return res.status(400).json({ error: "Missing postId in context" }); - - const username = await getUsername(); - if (username === "anonymous") return res.status(401).json({ error: "Login required" }); - - const { level, data } = req.body ?? {}; - if (level !== undefined && typeof level !== "number") { - return res.status(400).json({ error: "level must be a number" }); - } - if (data !== undefined && (typeof data !== "object" || data === null)) { - return res.status(400).json({ error: "data must be an object" }); - } - - const key = stateKey(postId, username); - const prevRaw = await redis.get(key); - const prev = (prevRaw ? JSON.parse(prevRaw) : {}) as Partial; - - // build the new state; only include optional fields if they exist - const next: StoredState = { - username, - updatedAt: Date.now(), - ...(typeof level === "number" ? { level } : (prev.level !== undefined ? { level: prev.level } : {})), - ...(data !== undefined ? { data } : (prev.data !== undefined ? { data: prev.data } : {})), - ...(prev.bestScore !== undefined ? { bestScore: prev.bestScore } : {}), - }; - - await redis.set(key, JSON.stringify(next)); - res.json(next); - } catch (err) { - console.error("POST /api/state error:", err); - res.status(500).json({ error: "Failed to save state" }); - } -}); - -// POST /api/score -> submit/update best score for this post -router.post("/api/score", async (req, res) => { - try { - const { postId } = context; - if (!postId) return res.status(400).json({ error: "Missing postId in context" }); - - const username = await getUsername(); - if (username === "anonymous") return res.status(401).json({ error: "Login required" }); - - const { score } = req.body ?? {}; - if (typeof score !== "number" || !Number.isFinite(score)) { - return res.status(400).json({ error: "score must be a finite number" }); - } - - // simple clamp, avoids abuse with huge numbers - const sanitized = Math.max(0, Math.min(score, 1_000_000_000)); - const lbKey = leaderboardKey(postId); - - // read old score (if any) and keep the max - const existing = await redis.zScore(lbKey, username); - const best = existing !== undefined && existing !== null - ? Math.max(Number(existing), sanitized) - : sanitized; - - // zAdd here updates the sorted set; score used for ranking, member is the username - await redis.zAdd(lbKey, { score: best, member: username }); - - // also mirror this best score into the per-user state - const sKey = stateKey(postId, username); - const prevRaw = await redis.get(sKey); - const prev = (prevRaw ? JSON.parse(prevRaw) : {}) as Partial; - - const next: StoredState = { - username, - updatedAt: Date.now(), - ...(prev.level !== undefined ? { level: prev.level } : {}), - ...(prev.data !== undefined ? { data: prev.data } : {}), - bestScore: best, - }; - - await redis.set(sKey, JSON.stringify(next)); - - res.json({ username, score: best, updatedAt: next.updatedAt }); - } catch (err) { - console.error("POST /api/score error:", err); - res.status(500).json({ error: "Failed to submit score" }); - } -}); - -// GET /api/leaderboard?limit=10 -> top N + caller's rank -router.get("/api/leaderboard", async (req, res) => { - try { - const { postId } = context; - if (!postId) { - return res.status(400).json({ error: "Missing postId in context" }); - } - - const username = await getUsername(); - const limitParam = Number(req.query.limit ?? 10); - const limit = Number.isFinite(limitParam) ? Math.max(1, Math.min(limitParam, 100)) : 10; - - const lbKey = leaderboardKey(postId); - - // zRange can return with scores when asked; our SDK does ascending by default - // so to emulate "top N", either use rev:true (if available) or flip ranks manually - const entries = await redis.zRange(lbKey, 0, limit - 1); - - const top = entries.map((e, i) => ({ - rank: i + 1, - username: e.member, - score: Number(e.score ?? 0), - })); - - // find caller's rank: only ascending zRank is guaranteed - const ascRank = await redis.zRank(lbKey, username); - const total = Number((await redis.zCard(lbKey)) ?? 0); - const meRank0 = - ascRank !== null && ascRank !== undefined && total - ? total - 1 - Number(ascRank) // flip ascending to descending - : ascRank; - - const me = - meRank0 !== undefined && meRank0 !== null - ? { - rank: Number(meRank0) + 1, - username, - score: Number((await redis.zScore(lbKey, username)) ?? 0), - } - : null; - - res.json({ - top, - me, - totalPlayers: total, - generatedAt: Date.now(), - }); - } catch (err) { - console.error("GET /api/leaderboard error:", err); - res.status(500).json({ error: "Failed to fetch leaderboard" }); - } -}); - -// ########################################################################## - -router.post("/internal/on-app-install", async (_req, res): Promise => { - try { - const post = await createPost(); - - res.json({ - status: "success", - message: `Post created in subreddit ${context.subredditName} with id ${post.id}`, - }); - } catch (error) { - console.error(`Error creating post: ${error}`); - res.status(400).json({ - status: "error", - message: "Failed to create post", - }); - } -}); - -router.post("/internal/menu/post-create", async (_req, res): Promise => { - try { - const post = await createPost(); - - res.json({ - navigateTo: `https://reddit.com/r/${context.subredditName}/comments/${post.id}`, - }); - } catch (error) { - console.error(`Error creating post: ${error}`); - res.status(400).json({ - status: "error", - message: "Failed to create post", - }); - } -}); - -app.use(router); - -const server = createServer(app); -server.on("error", (err) => console.error(`server error; ${err.stack}`)); -server.listen(getServerPort()); diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts new file mode 100644 index 0000000..6282285 --- /dev/null +++ b/src/server/routes/api.ts @@ -0,0 +1,298 @@ +import { Hono } from 'hono'; +import { context, redis, reddit } from '@devvit/web/server'; +import type { + InitResponse, + LeaderboardResponse, + ScoreSubmitRequest, + ScoreSubmitResponse, + StateUpsertRequest, + StoredState, +} from '../../shared/api'; + +type ErrorResponse = { + status: 'error'; + message: string; +}; + +type SimpleErrorResponse = { + error: string; +}; + +export const api = new Hono(); + +const stateKey = (postId: string, username: string) => + `state:${postId}:${username}`; + +const leaderboardKey = (postId: string) => `lb:${postId}`; + +const getUsername = async (): Promise => { + const username = await reddit.getCurrentUsername(); + return username ?? 'anonymous'; +}; + +api.get('/init', async (c) => { + const { postId } = context; + + if (!postId) { + console.error('API Init Error: postId not found in devvit context'); + return c.json( + { + status: 'error', + message: 'postId is required but missing from context', + }, + 400 + ); + } + + try { + const username = await reddit.getCurrentUsername(); + const currentUsername = username ?? 'anonymous'; + + let snoovatarUrl = ''; + if (username && context.userId) { + const user = await reddit.getUserById(context.userId); + if (user) { + snoovatarUrl = (await user.getSnoovatarUrl()) ?? ''; + } + } + + const redisKey = `${postId}:${currentUsername}`; + const previousTime = await redis.get(redisKey); + + return c.json({ + type: 'init', + postId: postId, + username: currentUsername, + snoovatarUrl: snoovatarUrl, + previousTime: previousTime ?? '', + }); + } catch (error) { + console.error(`API Init Error for post ${postId}:`, error); + let errorMessage = 'Unknown error during initialization'; + if (error instanceof Error) { + errorMessage = `Initialization failed: ${error.message}`; + } + return c.json( + { status: 'error', message: errorMessage }, + 400 + ); + } +}); + +// # DEMO SAMPLE: State + Score + Leaderboard using Redis +// ########################################################################## + +api.get('/state', async (c) => { + try { + const { postId } = context; + if (!postId) { + return c.json( + { error: 'Missing postId in context' }, + 400 + ); + } + + const username = await getUsername(); + const key = stateKey(postId, username); + const json = await redis.get(key); + if (!json) { + return c.json({ error: 'No state found' }, 404); + } + + return c.json(JSON.parse(json)); + } catch (error) { + console.error('GET /api/state error:', error); + return c.json({ error: 'Failed to fetch state' }, 500); + } +}); + +api.post('/state', async (c) => { + try { + const { postId } = context; + if (!postId) { + return c.json( + { error: 'Missing postId in context' }, + 400 + ); + } + + const username = await getUsername(); + if (username === 'anonymous') { + return c.json({ error: 'Login required' }, 401); + } + + let body: StateUpsertRequest; + try { + body = await c.req.json(); + } catch (error) { + console.error('Invalid JSON body for state', error); + return c.json({ error: 'Invalid JSON body' }, 400); + } + + const { level, data } = body ?? {}; + if (level !== undefined && typeof level !== 'number') { + return c.json( + { error: 'level must be a number' }, + 400 + ); + } + if (data !== undefined && (typeof data !== 'object' || data === null)) { + return c.json( + { error: 'data must be an object' }, + 400 + ); + } + + const key = stateKey(postId, username); + const prevRaw = await redis.get(key); + const prev = (prevRaw ? JSON.parse(prevRaw) : {}) as Partial; + + const next: StoredState = { + username, + updatedAt: Date.now(), + ...(typeof level === 'number' + ? { level } + : prev.level !== undefined + ? { level: prev.level } + : {}), + ...(data !== undefined + ? { data } + : prev.data !== undefined + ? { data: prev.data } + : {}), + ...(prev.bestScore !== undefined ? { bestScore: prev.bestScore } : {}), + }; + + await redis.set(key, JSON.stringify(next)); + return c.json(next); + } catch (error) { + console.error('POST /api/state error:', error); + return c.json({ error: 'Failed to save state' }, 500); + } +}); + +api.post('/score', async (c) => { + try { + const { postId } = context; + if (!postId) { + return c.json( + { error: 'Missing postId in context' }, + 400 + ); + } + + const username = await getUsername(); + if (username === 'anonymous') { + return c.json({ error: 'Login required' }, 401); + } + + let body: ScoreSubmitRequest; + try { + body = await c.req.json(); + } catch (error) { + console.error('Invalid JSON body for score', error); + return c.json({ error: 'Invalid JSON body' }, 400); + } + + const { score } = body ?? {}; + if (typeof score !== 'number' || !Number.isFinite(score)) { + return c.json( + { error: 'score must be a finite number' }, + 400 + ); + } + + const sanitized = Math.max(0, Math.min(score, 1_000_000_000)); + const lbKey = leaderboardKey(postId); + + const existing = await redis.zScore(lbKey, username); + const best = + existing !== undefined && existing !== null + ? Math.max(Number(existing), sanitized) + : sanitized; + + await redis.zAdd(lbKey, { score: best, member: username }); + + const sKey = stateKey(postId, username); + const prevRaw = await redis.get(sKey); + const prev = (prevRaw ? JSON.parse(prevRaw) : {}) as Partial; + + const next: StoredState = { + username, + updatedAt: Date.now(), + ...(prev.level !== undefined ? { level: prev.level } : {}), + ...(prev.data !== undefined ? { data: prev.data } : {}), + bestScore: best, + }; + + await redis.set(sKey, JSON.stringify(next)); + + return c.json({ + username, + score: best, + updatedAt: next.updatedAt, + }); + } catch (error) { + console.error('POST /api/score error:', error); + return c.json( + { error: 'Failed to submit score' }, + 500 + ); + } +}); + +api.get('/leaderboard', async (c) => { + try { + const { postId } = context; + if (!postId) { + return c.json( + { error: 'Missing postId in context' }, + 400 + ); + } + + const username = await getUsername(); + const limitParam = Number(c.req.query('limit') ?? 10); + const limit = Number.isFinite(limitParam) + ? Math.max(1, Math.min(limitParam, 100)) + : 10; + + const lbKey = leaderboardKey(postId); + const entries = await redis.zRange(lbKey, 0, limit - 1); + + const top = entries.map((entry, index) => ({ + rank: index + 1, + username: entry.member, + score: Number(entry.score ?? 0), + })); + + const ascRank = await redis.zRank(lbKey, username); + const total = Number((await redis.zCard(lbKey)) ?? 0); + const meRank0 = + ascRank !== null && ascRank !== undefined && total + ? total - 1 - Number(ascRank) + : ascRank; + + const me = + meRank0 !== undefined && meRank0 !== null + ? { + rank: Number(meRank0) + 1, + username, + score: Number((await redis.zScore(lbKey, username)) ?? 0), + } + : null; + + return c.json({ + top, + me, + totalPlayers: total, + generatedAt: Date.now(), + }); + } catch (error) { + console.error('GET /api/leaderboard error:', error); + return c.json( + { error: 'Failed to fetch leaderboard' }, + 500 + ); + } +}); diff --git a/src/server/routes/forms.ts b/src/server/routes/forms.ts new file mode 100644 index 0000000..7885bd2 --- /dev/null +++ b/src/server/routes/forms.ts @@ -0,0 +1,22 @@ +import { Hono } from 'hono'; +import type { UiResponse } from '@devvit/web/shared'; + +type ExampleFormValues = { + message?: string; +}; + +export const forms = new Hono(); + +forms.post('/example-submit', async (c) => { + const { message } = await c.req.json(); + const trimmedMessage = typeof message === 'string' ? message.trim() : ''; + + return c.json( + { + showToast: trimmedMessage + ? `Form says: ${trimmedMessage}` + : 'Form submitted with no message', + }, + 200 + ); +}); diff --git a/src/server/routes/menu.ts b/src/server/routes/menu.ts new file mode 100644 index 0000000..9a6def8 --- /dev/null +++ b/src/server/routes/menu.ts @@ -0,0 +1,27 @@ +import { Hono } from 'hono'; +import type { UiResponse } from '@devvit/web/shared'; +import { context } from '@devvit/web/server'; +import { createPost } from '../core/post'; + +export const menu = new Hono(); + +menu.post('/post-create', async (c) => { + try { + const post = await createPost(); + + return c.json( + { + navigateTo: `https://reddit.com/r/${context.subredditName}/comments/${post.id}`, + }, + 200 + ); + } catch (error) { + console.error(`Error creating post: ${error}`); + return c.json( + { + showToast: 'Failed to create post', + }, + 400 + ); + } +}); diff --git a/src/server/routes/triggers.ts b/src/server/routes/triggers.ts new file mode 100644 index 0000000..811c994 --- /dev/null +++ b/src/server/routes/triggers.ts @@ -0,0 +1,30 @@ +import { Hono } from 'hono'; +import type { OnAppInstallRequest, TriggerResponse } from '@devvit/web/shared'; +import { context } from '@devvit/web/server'; +import { createPost } from '../core/post'; + +export const triggers = new Hono(); + +triggers.post('/on-app-install', async (c) => { + try { + const post = await createPost(); + const input = await c.req.json(); + + return c.json( + { + status: 'success', + message: `Post created in subreddit ${context.subredditName} with id ${post.id} (trigger: ${input.type})`, + }, + 200 + ); + } catch (error) { + console.error(`Error creating post: ${error}`); + return c.json( + { + status: 'error', + message: 'Failed to create post', + }, + 400 + ); + } +}); diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json deleted file mode 100644 index 2d5c487..0000000 --- a/src/server/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -// TypeScript config for all Devvit server code. -{ - "extends": "../../tools/tsconfig-base.json", - "compilerOptions": { - "lib": ["ES2023"], - - "types": ["node"], - - "rootDir": ".", - - "outDir": "../../dist/types/server", - - "tsBuildInfoFile": "../../dist/types/server/tsconfig.tsbuildinfo" - }, - // https://github.com/Microsoft/TypeScript/issues/25636 - "include": ["**/*", "**/*.json", "../../package.json"], - "exclude": ["**/*.test.ts"], - "references": [{ "path": "../shared" }] -} diff --git a/src/server/vite.config.ts b/src/server/vite.config.ts deleted file mode 100644 index 5284c93..0000000 --- a/src/server/vite.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineConfig } from "vite"; -import { builtinModules } from "node:module"; - -export default defineConfig({ - ssr: { - noExternal: true, - }, - build: { - emptyOutDir: false, - ssr: "index.ts", - outDir: "../../dist/server", - target: "node22", - sourcemap: true, - rollupOptions: { - external: [...builtinModules], - output: { - format: "cjs", - entryFileNames: "index.cjs", - inlineDynamicImports: true, - }, - }, - }, -}); diff --git a/src/shared/api.ts b/src/shared/api.ts new file mode 100644 index 0000000..0bd6cdc --- /dev/null +++ b/src/shared/api.ts @@ -0,0 +1,43 @@ +export type InitResponse = { + type: 'init'; + postId: string; + username: string; + snoovatarUrl: string; + previousTime: string; +}; + +export type StoredState = { + username: string; + level?: number; + bestScore?: number; + data?: Record; + updatedAt: number; +}; + +export type StateUpsertRequest = { + level?: number; + data?: Record; +}; + +export type ScoreSubmitRequest = { + score: number; +}; + +export type ScoreSubmitResponse = { + username: string; + score: number; + updatedAt: number; +}; + +export type LeaderboardEntry = { + rank: number; + username: string; + score: number; +}; + +export type LeaderboardResponse = { + top: LeaderboardEntry[]; + me: LeaderboardEntry | null; + totalPlayers: number; + generatedAt: number; +}; diff --git a/src/shared/tsconfig.json b/src/shared/tsconfig.json deleted file mode 100644 index e401ebc..0000000 --- a/src/shared/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -// TypeScript config for non-devvit and shared code. -{ - "extends": "../../tools/tsconfig-base.json", - "compilerOptions": { - "lib": ["WebWorker", "ES2023"], - - "outDir": "../../dist/types/shared", - - "tsBuildInfoFile": "../../dist/types/shared/tsconfig.tsbuildinfo" - }, - // https://github.com/Microsoft/TypeScript/issues/25636 - "include": ["**/*", "**/*.json"], - "exclude": ["**/*.test.ts"] -} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts deleted file mode 100644 index 8225e0a..0000000 --- a/src/shared/types/api.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type InitResponse = { - type: "init"; - postId: string; - username: string; -}; - -// Add your game-specific API types here -// Examples: -// export type SaveScoreRequest = { -// score: number; -// level: number; -// }; -// -// export type LeaderboardResponse = { -// entries: Array<{ username: string; score: number; rank: number }>; -// }; diff --git a/tools/tsconfig-base.json b/tools/tsconfig.base.json similarity index 55% rename from tools/tsconfig-base.json rename to tools/tsconfig.base.json index 27fd2bd..738af4e 100644 --- a/tools/tsconfig-base.json +++ b/tools/tsconfig.base.json @@ -1,12 +1,7 @@ -// TypeScript config defaults for each sub-project (src, test, etc). { "$schema": "https://json.schemastore.org/tsconfig.json", - "compilerOptions": { - // Enable incremental builds. "composite": true, - - // Maximize type checking. "allowUnreachableCode": false, "allowUnusedLabels": false, "exactOptionalPropertyTypes": true, @@ -18,27 +13,15 @@ "noUnusedParameters": true, "resolveJsonModule": true, "strict": true, - - "types": [], // Projects add types needed. - - // Improve compatibility with compilers that aren't type system aware. + "types": [], "isolatedModules": true, - - // Use ESM. "esModuleInterop": true, - - // Allow JSON type-checking and imports. "module": "ESNext", "moduleResolution": "Bundler", - - // Assume library types are checked and compatible. "skipLibCheck": true, "skipDefaultLibCheck": true, - "sourceMap": true, - - "target": "ES2022" - }, - // https://github.com/microsoft/TypeScript/wiki/Performance#misconfigured-include-and-exclude - "exclude": ["dist", "node_modules", ".*/"] + "target": "ES2022", + "lib": ["DOM", "ES2023", "esnext.disposable"] + } } diff --git a/tools/tsconfig.client.json b/tools/tsconfig.client.json new file mode 100644 index 0000000..9d03b1d --- /dev/null +++ b/tools/tsconfig.client.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "../dist/types/client", + "tsBuildInfoFile": "../dist/types/client/tsconfig.tsbuildinfo", + "customConditions": ["browser"], + "rootDir": "../src/client" + }, + "include": ["../src/client/**/*"], + "exclude": [], + "references": [ + { + "path": "./tsconfig.shared.json" + } + ] +} diff --git a/tools/tsconfig.server.json b/tools/tsconfig.server.json new file mode 100644 index 0000000..eb03bbd --- /dev/null +++ b/tools/tsconfig.server.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "../dist/types/server", + "tsBuildInfoFile": "../dist/types/server/tsconfig.tsbuildinfo", + "customConditions": [], + "types": ["node"], + "exactOptionalPropertyTypes": false, + "rootDir": "../src/server" + }, + "include": ["../src/server/**/*"], + "exclude": [], + "references": [ + { + "path": "./tsconfig.shared.json" + } + ] +} diff --git a/tools/tsconfig.shared.json b/tools/tsconfig.shared.json new file mode 100644 index 0000000..a9e11ec --- /dev/null +++ b/tools/tsconfig.shared.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["WebWorker", "ES2023"], + + "outDir": "../dist/types/shared", + + "tsBuildInfoFile": "../dist/types/shared/tsconfig.tsbuildinfo", + + "rootDir": "../src/shared" + }, + "include": ["../src/shared/**/*"], + "exclude": [] +} diff --git a/tools/tsconfig.vite.json b/tools/tsconfig.vite.json new file mode 100644 index 0000000..0bb0c78 --- /dev/null +++ b/tools/tsconfig.vite.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "../dist/types/tools", + "tsBuildInfoFile": "../dist/types/tools/tsconfig.vite.tsbuildinfo", + "types": ["node"], + "rootDir": ".." + }, + "include": ["../vite.config.ts"], + "exclude": [] +} diff --git a/tsconfig.json b/tsconfig.json index ad713b8..f1edc59 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { - // Only build references. "files": [], "references": [ - { "path": "./src/client" }, - { "path": "./src/shared" }, - { "path": "./src/server" } + { "path": "./tools/tsconfig.client.json" }, + { "path": "./tools/tsconfig.server.json" }, + { "path": "./tools/tsconfig.shared.json" }, + { "path": "./tools/tsconfig.vite.json" } ] } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c4fd976 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import { devvit } from '@devvit/start/vite'; + +export default defineConfig({ + plugins: [devvit()], +});