diff --git a/.env b/.env
index 8f443531d..a93779ad9 100644
--- a/.env
+++ b/.env
@@ -1,4 +1,12 @@
-# VITE_ASSET_PATH=https://M3-org.github.io/loot-assets
-# access remote (may have CORS issues) or download from https://github.com/M3-org/loot-assets and load locally
-#VITE_ASSET_PATH=./
+# Grudge Studio CharacterStudio Configuration
+# Asset path — local manifest.json or remote
VITE_ASSET_PATH=./
+
+# Grudge Studio Backend (Railway)
+VITE_GRUDGE_API=https://api.grudge-studio.com
+
+# Grudge SSO Gateway
+VITE_AUTH_GATEWAY=https://id.grudge-studio.com
+
+# R2 CDN for 3D models, textures, icons
+VITE_ASSET_CDN=https://assets.grudge-studio.com
diff --git a/.env.example b/.env.example
index c8149e04f..45c6c20f0 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,32 @@
+# ── GCS — Grudge Character Studio :: Environment Variables ───────────────────
+# Copy to .env.local for local development. Never commit .env.local.
+
+# ── Asset path (required) ─────────────────────────────────────────────────────
+# Path to the VRM/manifest assets directory.
+# Leave empty to use the default public/ bundle, or point at a CDN.
VITE_ASSET_PATH=
+
+# ── Grudge Studio API (The-ENGINE backend) ───────────────────────────────────
+# REST API for user accounts, characters, inventory, GBUX balance.
+# Production: https://api.grudge-studio.com
+# Local dev: http://localhost:5000
+VITE_GRUDGE_API=https://api.grudge-studio.com
+
+# ── Auth gateway (Grudge SSO) ─────────────────────────────────────────────────
+# SSO login endpoint. Redirects back with ?sso_token=... after login.
+# Production: https://id.grudge-studio.com
+# Local dev: http://localhost:5000 (The-ENGINE handles /login)
+VITE_AUTH_GATEWAY=https://id.grudge-studio.com
+
+# ── Asset CDN ─────────────────────────────────────────────────────────────────
+# Optional CDN for 3D assets, textures, and manifests.
+VITE_ASSET_CDN=https://assets.grudge-studio.com
+
+# ── OpenSea API key (optional — for NFT trait verification) ───────────────────
VITE_OPENSEA_KEY=
+
+# ── Helius API key (optional — for Solana RPC) ───────────────────────────────
VITE_HELIUS_KEY=
-VITE_VALIDATION_SERVER_URL=http://localhost:3000
\ No newline at end of file
+
+# ── Validation server (optional — for VRM export validation) ─────────────────
+VITE_VALIDATION_SERVER_URL=http://localhost:3000
diff --git a/.github/workflows/webpack.yml b/.github/workflows/webpack.yml
new file mode 100644
index 000000000..9626ff6d3
--- /dev/null
+++ b/.github/workflows/webpack.yml
@@ -0,0 +1,28 @@
+name: NodeJS with Webpack
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [18.x, 20.x, 22.x]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+
+ - name: Build
+ run: |
+ npm install
+ npx webpack
diff --git a/index.html b/index.html
index 15d9183b2..3105ad9a0 100644
--- a/index.html
+++ b/index.html
@@ -2,16 +2,36 @@
-
+
- Character Studio
+
+ GCS — Grudge Character Studio · HYDRA
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
diff --git a/package.json b/package.json
index 38d77283c..67facc2bf 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,9 @@
{
- "name": "@m3-org/characterstudio",
- "version": "0.5.0",
- "homepage": "https://m3-org.github.io/CharacterStudio",
+ "name": "@grudge-studio/gcs",
+ "version": "1.0.0",
+ "description": "GCS — Grudge Character Studio. HYDRA-powered 3D VRM character creator for the Grudge Studio ecosystem.",
+ "homepage": "https://character.grudge-studio.com",
"scripts": {
- "predeploy": "npm run build",
- "deploy": "gh-pages -d build",
"dev": "vite --host",
"build": "vite build",
"serve": "vite preview",
diff --git a/public/manifest.json b/public/manifest.json
index 482bf5a39..18f8d77cb 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -1,89 +1,69 @@
{
- "characters":[
+ "characters": [
{
- "name": "Anata",
- "description": "Female",
- "portrait": "./assets/portraitImages/female.jpg",
- "manifest":"https://m3-org.github.io/loot-assets/anata/female/manifest.json",
- "format": "vrm"
+ "name": "Human",
+ "description": "Balanced fighter, versatile in all roles.",
+ "portrait": "https://assets.grudge-studio.com/portraits/human.jpg",
+ "manifest": "https://assets.grudge-studio.com/manifests/human.json",
+ "format": "glb",
+ "raceId": "human",
+ "faction": "crusade"
},
{
- "name": "Anata",
- "description": "Male",
- "portrait": "./assets/portraitImages/male.jpg",
- "manifest":"./loot-assets/anata/male/manifest.json",
- "format": "vrm"
+ "name": "Barbarian",
+ "description": "Brutal berserker, raw power and fury.",
+ "portrait": "https://assets.grudge-studio.com/portraits/barbarian.jpg",
+ "manifest": "https://assets.grudge-studio.com/manifests/barbarian.json",
+ "format": "glb",
+ "raceId": "barbarian",
+ "faction": "crusade"
},
{
- "name": "ON1FORCE",
- "description": "Demon",
- "portrait": "./assets/portraitImages/0n1.jpg",
- "manifest":"https://m3-org.github.io/loot-assets/0N1/manifest.json",
- "format": "vrm"
+ "name": "Elf",
+ "description": "Agile marksman, master of precision.",
+ "portrait": "https://assets.grudge-studio.com/portraits/elf.jpg",
+ "manifest": "https://assets.grudge-studio.com/manifests/elf.json",
+ "format": "glb",
+ "raceId": "elf",
+ "faction": "fabled"
},
{
- "name": "TUBBY CAT",
- "description": "Cute",
- "portrait": "./assets/portraitImages/tubby.jpg",
- "manifest":"https://m3-org.github.io/loot-assets/tubbycats/manifest.json",
- "format": "vrm"
- }
- ],
- "loras":[
- {
- "name": "SDXL Lora 728",
- "description": "Poses and camera positions to create Lora for SDXL 726 x 726",
- "manifest":"./lora-assets/manifest.json",
- "icon": "|"
- }
- ],
- "sprites":[
- {
- "name": "Multiple Animations",
- "description": "Multiple definitions for different animations converted to sprites.",
- "manifest":"./sprite-atlas-assets/manifest.json",
- "icon": "|"
- }
-
- ],
- "thumbnails":[
- {
- "name": "Clothing Thumbnail",
- "description": "Create thumbnails for Clothing",
- "manifest":"./thumbnail-assets/manifest.json",
- "icon": "|"
+ "name": "Dwarf",
+ "description": "Stout defender, unbreakable resolve.",
+ "portrait": "https://assets.grudge-studio.com/portraits/dwarf.jpg",
+ "manifest": "https://assets.grudge-studio.com/manifests/dwarf.json",
+ "format": "glb",
+ "raceId": "dwarf",
+ "faction": "crusade"
},
{
- "name": "Clothing Thumbnail2",
- "description": "Create thumbnails for Clothing",
- "manifest":"./thumbnail-assets/manifest.json",
- "icon": "|"
- }
- ],
- "defaultAnimations":[
- {
- "name": "T-Pose",
- "description": "T-Pose",
- "location":"./animations/T-Pose.fbx",
- "icon": "|"
- },
- {
- "name": "Dancing",
- "description": "Basic Dance Animation",
- "location":"./animations/Dancing.fbx",
- "icon": "|"
+ "name": "Orc",
+ "description": "Savage warrior, strength through rage.",
+ "portrait": "https://assets.grudge-studio.com/portraits/orc.jpg",
+ "manifest": "https://assets.grudge-studio.com/manifests/orc.json",
+ "format": "glb",
+ "raceId": "orc",
+ "faction": "legion"
},
{
- "name": "Walking",
- "description": "Basic Walk Animation",
- "location":"./animations/Walking.fbx",
- "icon": "|"
- },
- {
- "name": "Waving",
- "description": "Basic Waving Animation",
- "location":"./animations/Waving.fbx",
- "icon": "|"
+ "name": "Undead",
+ "description": "Risen skeleton, relentless and fearless.",
+ "portrait": "https://assets.grudge-studio.com/portraits/undead.jpg",
+ "manifest": "https://assets.grudge-studio.com/manifests/undead.json",
+ "format": "glb",
+ "raceId": "undead",
+ "faction": "legion"
}
+ ],
+ "classes": [
+ { "id": "warrior", "name": "Warrior", "icon": "⚔️", "defaultWeapon": "unarmed" },
+ { "id": "mage", "name": "Mage", "icon": "🔮", "defaultWeapon": "unarmed" },
+ { "id": "ranger", "name": "Ranger", "icon": "🏹", "defaultWeapon": "unarmed" },
+ { "id": "worg", "name": "Worge", "icon": "🐺", "defaultWeapon": "unarmed" }
+ ],
+ "defaultAnimations": [
+ { "name": "Idle", "description": "Standing idle", "location": "https://assets.grudge-studio.com/models/animations/sword-shield/sword and shield idle.glb" },
+ { "name": "Walk", "description": "Walking forward", "location": "https://assets.grudge-studio.com/models/animations/sword-shield/sword and shield run.glb" },
+ { "name": "Attack", "description": "Basic attack", "location": "https://assets.grudge-studio.com/models/animations/sword-shield/sword and shield attack.glb" }
]
}
diff --git a/public/style.css b/public/style.css
index 6e07fca3b..9626ea986 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1,51 +1,65 @@
-/* New UI: TT Squares Condensed Font */
-@font-face {
- font-family: "TTSC-Black-Italic";
- src: url("./fonts/tt-squares/TT Squares Condensed Black Italic.otf");
-}
-@font-face {
- font-family: "TTSC-Black";
- src: url("./fonts/tt-squares/TT Squares Condensed Black.otf");
-}
-@font-face {
- font-family: "TTSC-Bold-Italic";
- src: url("./fonts/tt-squares/TT Squares Condensed Bold Italic.otf");
-}
-@font-face {
- font-family: "TTSC-Bold";
- src: url("./fonts/tt-squares/TT Squares Condensed Bold.otf");
-}
-@font-face {
- font-family: "TTSC-Light-Italic";
- src: url("./fonts/tt-squares/TT Squares Condensed Light Italic.otf");
-}
-@font-face {
- font-family: "TTSC-Light";
- src: url("./fonts/tt-squares/TT Squares Condensed Light.otf");
-}
-@font-face {
- font-family: "TTSC-Regular-Italic";
- src: url("./fonts/tt-squares/TT Squares Condensed Regular Italic.otf");
-}
-@font-face {
- font-family: "TTSC-Regular";
- src: url("./fonts/tt-squares/TT Squares Condensed Regular.otf");
-}
-@font-face {
- font-family: "TTSC-Thin-Italic";
- src: url("./fonts/tt-squares/TT Squares Condensed Thin italic.otf");
-}
-@font-face {
- font-family: "TTSC-Thin";
- src: url("./fonts/tt-squares/TT Squares Condensed Thin.otf");
+/* ── GCS — Grudge Character Studio :: Global Styles ──────────────────────────
+ Brand system: Hydra visual identity (hydralauncher.gg)
+ Primary teal: #16b195 | Secondary blue: #3e62c0
+ Dark bg: #0d0d0d | Surface: #121212 / #1a1a1a
+ Display font: Space Grotesk | Body: Inter + TT Squares Condensed
+ ────────────────────────────────────────────────────────────────────────── */
+
+:root {
+ /* Hydra brand colors (from hydralauncher/hydra globals.scss) */
+ --hydra-teal: #16b195;
+ --hydra-blue: #3e62c0;
+ --hydra-gradient: linear-gradient(270deg, #16b195 50%, #3e62c0 100%);
+ --hydra-gradient-v: linear-gradient(180deg, #16b195 0%, #3e62c0 100%);
+ --hydra-platinum: #30edc5;
+
+ /* Grudge Studio accent (established brand) */
+ --grudge-green: #4ade80;
+ --grudge-green-s: rgba(74, 222, 128, 0.12);
+
+ /* Backgrounds */
+ --bg: #0d0d0d;
+ --bg-surface: #121212;
+ --bg-raised: #1a1a1a;
+ --bg-panel: rgba(18, 18, 18, 0.92);
+
+ /* Borders */
+ --border: rgba(255, 255, 255, 0.08);
+ --border-mid: rgba(255, 255, 255, 0.12);
+
+ /* Text */
+ --text: #f0f1f7;
+ --text-body: #d0d1d7;
+ --text-muted: #7a7b8a;
+
+ /* Feedback */
+ --success: #1c9749;
+ --danger: #e11d48;
+ --warning: #ffc107;
+
+ /* Brand bar height */
+ --brand-bar-h: 40px;
}
-html,
-body {
+/* ── TT Squares Condensed (local) ── */
+@font-face { font-family: "TTSC-Black-Italic"; src: url("./fonts/tt-squares/TT Squares Condensed Black Italic.otf"); }
+@font-face { font-family: "TTSC-Black"; src: url("./fonts/tt-squares/TT Squares Condensed Black.otf"); }
+@font-face { font-family: "TTSC-Bold-Italic"; src: url("./fonts/tt-squares/TT Squares Condensed Bold Italic.otf"); }
+@font-face { font-family: "TTSC-Bold"; src: url("./fonts/tt-squares/TT Squares Condensed Bold.otf"); }
+@font-face { font-family: "TTSC-Light-Italic"; src: url("./fonts/tt-squares/TT Squares Condensed Light Italic.otf"); }
+@font-face { font-family: "TTSC-Light"; src: url("./fonts/tt-squares/TT Squares Condensed Light.otf"); }
+@font-face { font-family: "TTSC-Regular-Italic"; src: url("./fonts/tt-squares/TT Squares Condensed Regular Italic.otf"); }
+@font-face { font-family: "TTSC-Regular"; src: url("./fonts/tt-squares/TT Squares Condensed Regular.otf"); }
+@font-face { font-family: "TTSC-Thin-Italic"; src: url("./fonts/tt-squares/TT Squares Condensed Thin italic.otf"); }
+@font-face { font-family: "TTSC-Thin"; src: url("./fonts/tt-squares/TT Squares Condensed Thin.otf"); }
+
+html, body {
margin: 0;
padding: 0;
height: 100%;
- font-family: "TTSC-Regular";
+ font-family: 'Inter', "TTSC-Regular", system-ui, sans-serif;
+ background: var(--bg);
+ color: var(--text);
}
#root {
@@ -53,32 +67,20 @@ body {
overflow: hidden;
}
-::-webkit-scrollbar {
- width: 0.1em;
-}
-::-webkit-scrollbar-track {
- box-shadow: transparent;
-}
+/* ── Hydra-branded scrollbar ── */
+::-webkit-scrollbar { width: 3px; height: 3px; }
+::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
- background: rgb(0,149,100);
-background: -moz-linear-gradient(180deg, rgba(0,149,100,0) 0%, rgba(8,234,160,0.6) 50%, rgba(0,149,100,0) 100%);
-background: -webkit-linear-gradient(180deg, rgba(0,149,100,0) 0%, rgba(8,234,160,0.6) 50%, rgba(0,149,100,0) 100%);
-background: linear-gradient(180deg, rgba(0,149,100,0) 0%, rgba(8,234,160,0.6) 50%, rgba(0,149,100,0) 100%);
-filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#009564",endColorstr="#009564",GradientType=1);
+ background: var(--hydra-gradient-v);
+ border-radius: 2px;
}
+::-webkit-scrollbar-thumb:hover { background: var(--hydra-teal); }
-.horizontalScroll ::-webkit-scrollbar {
- height: 0.1em;
-}
-.horizontalScroll ::-webkit-scrollbar-track {
- box-shadow: transparent;
-}
+.horizontalScroll ::-webkit-scrollbar { height: 3px; }
+.horizontalScroll ::-webkit-scrollbar-track { background: transparent; }
.horizontalScroll ::-webkit-scrollbar-thumb {
- background: rgb(0,149,100);
-background: -moz-linear-gradient(180deg, rgba(0,149,100,0) 0%, rgba(8,234,160,0.6) 50%, rgba(0,149,100,0) 100%);
-background: -webkit-linear-gradient(180deg, rgba(0,149,100,0) 0%, rgba(8,234,160,0.6) 50%, rgba(0,149,100,0) 100%);
-background: linear-gradient(180deg, rgba(0,149,100,0) 0%, rgba(8,234,160,0.6) 50%, rgba(0,149,100,0) 100%);
-filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#009564",endColorstr="#009564",GradientType=1);
+ background: linear-gradient(90deg, #16b195 50%, #3e62c0 100%);
+ border-radius: 2px;
}
.root {
@@ -95,63 +97,177 @@ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#009564",endCo
-ms-user-select: none;
user-select: none;
outline: none;
- -webkit-tap-highlight-color: rgba(255, 255, 255, 0); /* mobile webkit */
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
+/* ── Loading indicator ── */
.loadingIndicator {
display: none;
pointer-events: none;
position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- width: 80px;
- height: 80px;
+ left: 0; right: 0; top: 0; bottom: 0;
+ width: 80px; height: 80px;
margin: auto;
border-radius: 10px;
- filter: drop-shadow(1px 1px 2px black);
-}
-.loadingIndicator.active {
- display: block;
+ filter: drop-shadow(1px 1px 4px rgba(22, 177, 149, 0.4));
}
+.loadingIndicator.active { display: block; }
.loadingIndicator > img {
position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- width: 65%;
- height: 65%;
+ left: 0; right: 0; top: 0; bottom: 0;
+ width: 65%; height: 65%;
margin: auto;
}
-.rotate{
- animation: rotate 2.2s linear infinite;
-}
+@keyframes rotate { to { transform: rotate(360deg); } }
+.rotate { animation: rotate 2.2s linear infinite; }
+/* ── Section titles (existing component class hooks) ── */
.sectionTitle {
position: absolute;
top: 34px;
left: 32px;
- color: white;
+ color: var(--text);
text-transform: uppercase;
- text-shadow: 1px 1px 2px black;
+ text-shadow: 0 1px 4px rgba(0,0,0,0.8);
font-size: 16px;
+ font-family: 'Space Grotesk', "TTSC-Bold", sans-serif;
+ font-weight: 700;
+ letter-spacing: 0.06em;
}
.generalTitle {
position: absolute;
top: 16px;
left: 32px;
- color: white;
- opacity: 0.7;
- font-size: 12px;
+ color: var(--text-muted);
+ opacity: 0.8;
+ font-size: 11px;
padding: 0;
- text-shadow: 1px 1px 2px black;
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
text-transform: uppercase;
+ font-family: 'Space Grotesk', sans-serif;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+}
+
+/* ── GCS Brand Bar ── */
+.gcs-brand-bar {
+ position: fixed;
+ top: 0; left: 0; right: 0;
+ height: var(--brand-bar-h);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 16px;
+ background: linear-gradient(90deg,
+ rgba(13,13,13,0.96) 0%,
+ rgba(13,13,13,0.82) 60%,
+ rgba(13,13,13,0.0) 100%
+ );
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ border-bottom: 1px solid rgba(22, 177, 149, 0.12);
+ pointer-events: none; /* only interactive children get pointer-events */
+}
+
+.gcs-brand-bar::before {
+ content: '';
+ position: absolute;
+ bottom: 0; left: 0; right: 0;
+ height: 1px;
+ background: var(--hydra-gradient);
+ opacity: 0.35;
+}
+
+.gcs-brand-mark {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ pointer-events: auto;
+ text-decoration: none;
+}
+
+.gcs-wordmark {
+ font-family: 'Space Grotesk', sans-serif;
+ font-weight: 800;
+ font-size: 0.85rem;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ background: var(--hydra-gradient);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.gcs-studio-name {
+ font-family: 'Space Grotesk', sans-serif;
+ font-size: 0.6rem;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ color: var(--text-muted);
+ text-transform: uppercase;
+}
+
+.gcs-divider {
+ width: 1px;
+ height: 14px;
+ background: var(--border-mid);
+ flex-shrink: 0;
+}
+
+.gcs-account-area {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ pointer-events: auto;
+}
+
+.gcs-grudge-id {
+ font-family: 'Space Grotesk', monospace;
+ font-size: 0.65rem;
+ font-weight: 600;
+ color: var(--hydra-teal);
+ letter-spacing: 0.04em;
+ opacity: 0.9;
+}
+
+.gcs-btn {
+ font-family: 'Space Grotesk', sans-serif;
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ padding: 4px 12px;
+ border-radius: 6px;
+ border: none;
+ cursor: pointer;
+ transition: all 0.1s;
+}
+
+.gcs-btn-login {
+ background: rgba(22, 177, 149, 0.12);
+ border: 1px solid rgba(22, 177, 149, 0.3);
+ color: var(--hydra-teal);
+}
+.gcs-btn-login:hover {
+ background: rgba(22, 177, 149, 0.22);
+ border-color: var(--hydra-teal);
+}
+
+.gcs-btn-logout {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+}
+.gcs-btn-logout:hover {
+ border-color: var(--border-mid);
+ color: var(--text);
}
-@keyframes rotate{
- to{ transform: rotate(360deg); }
+/* ── Canvas offset when brand bar shown ── */
+/* Shift entire React root down by brand bar height */
+#root[data-brandbar="true"] {
+ padding-top: var(--brand-bar-h);
}
diff --git a/src/App.jsx b/src/App.jsx
index 673ff5a20..7efbac4b5 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -9,6 +9,7 @@ import { EffectManager } from "./library/effectManager"
import MessageWindow from "./components/MessageWindow"
import Background from "./components/Background"
+import BrandBar from "./components/BrandBar"
import Appearance from "./pages/Appearance"
import BatchDownload from "./pages/BatchDownload"
@@ -206,8 +207,8 @@ export default function App() {
return (
-
- Character Studio
+
+
{/* */}
+
+
+
+
+
+ )
+}
+
+export default function BrandBar() {
+ const { connected, grudgeId, login, logout } = useContext(AccountContext)
+
+ return (
+
+ {/* Left: mark + wordmark */}
+
+
+ GCS
+
+ Grudge Character Studio
+
+
+ {/* Right: account state */}
+
+ {connected ? (
+ <>
+
+ {grudgeId ? `#${grudgeId}` : "Connected"}
+
+
+ >
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/src/context/AccountContext.jsx b/src/context/AccountContext.jsx
index 3334d29d5..10cafb6b1 100644
--- a/src/context/AccountContext.jsx
+++ b/src/context/AccountContext.jsx
@@ -1,23 +1,90 @@
-import React, { createContext, useState } from "react"
+import React, { createContext, useState, useEffect } from "react"
+import {
+ isAuthenticated, buildLoginUrl, logout as apiLogout,
+ getGrudgeId, getAccountId, getAuthToken, getCharacters,
+} from "../services/grudgeAPI"
+
export const AccountContext = createContext()
export const AccountProvider = (props) => {
- const [walletAddress, setWalletAddress] = useState(null)
- const [ensName, setEnsName] = useState(null)
- const [connected, setConnected] = useState(false)
- const [OTTokens, setOTTokens] = useState([])
+ const [grudgeId, setGrudgeId] = useState(getGrudgeId())
+ const [accountId, setAccountId] = useState(getAccountId())
+ const [connected, setConnected] = useState(isAuthenticated())
+ const [characters, setCharacters] = useState([])
+ const [activeCharacterId, setActiveCharacterId] = useState(
+ localStorage.getItem('grudge_active_character') || null
+ )
+
+ // Check for SSO callback token on mount
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search)
+ const ssoToken = params.get('sso_token')
+ const ssoGrudgeId = params.get('grudge_id')
+ const ssoUsername = params.get('grudge_username')
+
+ if (ssoToken) {
+ localStorage.setItem('grudge_auth_token', ssoToken)
+ if (ssoGrudgeId) localStorage.setItem('grudge_id', ssoGrudgeId)
+ if (ssoUsername) localStorage.setItem('grudge_username', ssoUsername)
+ setGrudgeId(ssoGrudgeId || '')
+ setConnected(true)
+ // Clean URL
+ window.history.replaceState({}, '', window.location.pathname)
+ }
+ }, [])
+
+ // Load characters when connected
+ useEffect(() => {
+ if (connected) {
+ getCharacters().then(chars => {
+ setCharacters(chars)
+ if (chars.length > 0 && !activeCharacterId) {
+ setActiveCharacterId(chars[0].id)
+ localStorage.setItem('grudge_active_character', chars[0].id)
+ }
+ })
+ }
+ }, [connected])
+
+ const login = () => {
+ window.location.href = buildLoginUrl(window.location.href)
+ }
+
+ const logout = () => {
+ apiLogout()
+ setConnected(false)
+ setGrudgeId('')
+ setCharacters([])
+ setActiveCharacterId(null)
+ }
+
+ const selectCharacter = (id) => {
+ setActiveCharacterId(id)
+ localStorage.setItem('grudge_active_character', id)
+ }
return (
{},
setConnected,
- OTTokens,
- setOTTokens
+ OTTokens: [],
+ setOTTokens: () => {},
}}
>
{props.children}
diff --git a/src/services/grudgeAPI.js b/src/services/grudgeAPI.js
new file mode 100644
index 000000000..5c559a909
--- /dev/null
+++ b/src/services/grudgeAPI.js
@@ -0,0 +1,241 @@
+/**
+ * grudgeAPI.js — Grudge Studio backend API client for CharacterStudio.
+ *
+ * Connects to: api.grudge-studio.com (Railway Express + PostgreSQL)
+ * Auth: Bearer JWT from Grudge SSO (id.grudge-studio.com)
+ *
+ * This is the CharacterStudio equivalent of Grudge-Builder's client/src/lib/api.ts.
+ * Same endpoints, same auth pattern, same DB.
+ */
+
+const API_BASE = import.meta.env.VITE_GRUDGE_API || 'https://api.grudge-studio.com';
+const AUTH_GATEWAY = import.meta.env.VITE_AUTH_GATEWAY || 'https://id.grudge-studio.com';
+const ASSET_CDN = import.meta.env.VITE_ASSET_CDN || 'https://assets.grudge-studio.com';
+
+// ── Auth helpers ─────────────────────────────────────────────────
+
+function getAuthToken() {
+ return localStorage.getItem('grudge_auth_token') || '';
+}
+
+function getGrudgeId() {
+ return localStorage.getItem('grudge_id') || '';
+}
+
+function getAccountId() {
+ return localStorage.getItem('grudge_account_id') || 'guest';
+}
+
+function authHeaders() {
+ const token = getAuthToken();
+ return token
+ ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
+ : { 'Content-Type': 'application/json' };
+}
+
+export function isAuthenticated() {
+ return !!getAuthToken();
+}
+
+export function buildLoginUrl(returnUrl) {
+ const redirectUri = encodeURIComponent(returnUrl || window.location.href);
+ return `${AUTH_GATEWAY}/login?redirect_uri=${redirectUri}`;
+}
+
+export function logout() {
+ const keys = [
+ 'grudge_auth_token', 'grudge_user_id', 'grudge_id', 'grudge_username',
+ 'grudge_user', 'grudge_account_id', 'grudge_session_token',
+ ];
+ keys.forEach(k => localStorage.removeItem(k));
+}
+
+// ── Generic fetch wrapper ────────────────────────────────────────
+
+async function apiFetch(path, options = {}) {
+ const res = await fetch(`${API_BASE}${path}`, {
+ ...options,
+ headers: { ...authHeaders(), ...(options.headers || {}) },
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(body?.error || `API ${res.status}: ${path}`);
+ }
+ return res.json();
+}
+
+// ── Character API ────────────────────────────────────────────────
+
+/**
+ * Starting equipment for new characters.
+ * Every character starts with unarmed + stone harvesting tool.
+ */
+const STARTING_EQUIPMENT = {
+ mainHand: null, // unarmed (no weapon equipped)
+ offHand: null,
+ head: null,
+ chest: null,
+ legs: null,
+ feet: null,
+ back: null,
+};
+
+const STARTING_INVENTORY = [
+ { itemId: 'tool_stone_axe', quantity: 1, tier: 1 },
+ { itemId: 'food_bread', quantity: 3, tier: 1 },
+];
+
+/**
+ * Create a new Grudge character with auto-UUID, model3d data,
+ * starting equipment (unarmed + harvesting tool), and cNFT mint.
+ *
+ * @param {Object} params
+ * @param {string} params.name - Character name
+ * @param {string} params.raceId - Race ID (human, barbarian, elf, dwarf, orc, undead)
+ * @param {string} params.classId - Class ID (warrior, mage, ranger, worg)
+ * @param {Object} params.model3d - 3D appearance data from CharacterStudio customization
+ * @param {Object} [params.skillLoadouts] - Initial skill selections
+ * @returns {Promise