diff --git a/.env.example b/.env.example index 3dc6770..3dc12e8 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ # Steam Web API Key # Get yours free at https://steamcommunity.com/dev/apikey STEAM_API_KEY= + +# Partner admin (separate process only: npx tsx src/partner/index.ts). +# Names only. Put real values in a gitignored local .env, never in mcp.json. +# STEAM_PARTNER_ADMIN=1 +# STEAM_PARTNER_COOKIES= +# STEAM_PARTNER_PROFILE_DIR= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 237c5f1..4672803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,10 @@ jobs: - run: npm ci - run: npm run build - run: npm test + + secrets-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Scan for committed Steam secrets + run: python3 scripts/scan-secrets.py diff --git a/.gitignore b/.gitignore index dafa3b2..c8a996b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,41 @@ node_modules/ dist/ .env .env.* +.env.local +.env.partner !.env.example *.ssfn +ssfn* config.vdf +loginusers.vdf +local.vdf + +# Cookie jars and Playwright auth +cookies.txt +cookies.json +*.cookies +*cookie-jar* +storageState.json +**/playwright/.auth/ + +# Chromium / Playwright profiles (never store these in-repo) +**/*user-data-dir*/ +**/chromium-profile/ +**/partner-profile/ +**/.pw-profile/ +playwright-report/ +blob-report/ +*.har + +# Generic secrets +secrets.json +*.pem +*.key +*.p12 + +# Partner-admin dry-run dumps +partner-dry-run*.json +admin-save-payload*.json # OS .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 27fca61..5d85c15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## What is this? -An MCP (Model Context Protocol) server that exposes Steam Web API endpoints as structured tools for AI-powered IDEs. It is the companion server for the [Steam Developer Tools](https://github.com/TMHSDigital/Steam-Cursor-Plugin) Cursor plugin, which provides 30 skills and 9 rules for Steam/Steamworks development. The server provides 25 tools: 18 read-only and 7 write/guidance tools. +An MCP (Model Context Protocol) server that exposes Steam Web API endpoints as structured tools for AI-powered IDEs. It is the companion server for the [Steam Developer Tools](https://github.com/TMHSDigital/Steam-Cursor-Plugin) Cursor plugin, which provides 30 skills and 9 rules for Steam/Steamworks development. The server provides 26 tools: 19 read-only and 7 write/guidance tools. The plugin's skills reference these MCP tools to fetch live data from Steam - player stats, store info, workshop items, leaderboards, and more. @@ -17,7 +17,13 @@ src/ getAppDetails.ts Each file exports a register(server) function searchApps.ts that adds one tool with its name, description, getPlayerCount.ts zod input schema, and async handler + validateStoreAsset.ts ... + storeAssets/ + slots.ts Valve pixel sizes and unofficial Partner form field names + png.ts PNG/JPEG header parse plus PNG pixel decode + heroHeuristics.ts Library-hero ribbon / seam / wordmark checks + validate.ts Pure validateStoreAsset(path, slot) utils/ steam-api.ts Shared fetch wrapper, URL builders, API key helper, error formatting errors.ts Custom error classes (rate limit, missing key, unavailable) @@ -29,7 +35,7 @@ src/ - `steam-api.ts` provides `steamFetch()` which handles timeouts (15s via AbortController with `TimeoutError`), HTTP error detection (429 rate limits with up to 2 retries and exponential backoff, 5xx unavailable), and JSON parsing. - `errorResponse()` formats errors as MCP-compatible `{ isError: true }` responses. - Tools that need an API key call `requireApiKey()` which reads `STEAM_API_KEY` from env and throws `MissingApiKeyError` with setup instructions if missing. -- No-auth tools (getAppDetails, searchApps, getPlayerCount, getAchievementStats, getWorkshopItem, getReviews, getPriceOverview, getAppReviewSummary, getRegionalPricing, getNewsForApp) work without any configuration. +- No-auth tools (getAppDetails, searchApps, getPlayerCount, getAchievementStats, getWorkshopItem, getReviews, getPriceOverview, getAppReviewSummary, getRegionalPricing, getNewsForApp, validateStoreAsset) work without any configuration. ## How to build and run @@ -74,6 +80,9 @@ No-auth tools can be tested without setting `STEAM_API_KEY`. | Variable | Required | Description | |----------|----------|-------------| | `STEAM_API_KEY` | For some tools | Steam Web API key from https://steamcommunity.com/dev/apikey | +| `STEAM_PARTNER_ADMIN` | Partner process only | Must be `1` to start `src/partner/index.ts`. Ignored by the default bin. | +| `STEAM_PARTNER_COOKIES` | Partner process only | Path to a cookie jar. Never a cookie string. Gitignored. | +| `STEAM_PARTNER_PROFILE_DIR` | Partner process only | Chromium user-data dir outside the repo. | ## Relationship to the companion plugin diff --git a/README.md b/README.md index 2468a74..fd7b8e2 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@

node - MCP tools + MCP tools Steam Web API

--- -

25 MCP tools - 10 no-auth - 8 API key - 7 publisher key

+

26 MCP tools - 11 no-auth - 8 API key - 7 publisher key

Query Steam store data, player statistics, achievements, reviews, pricing, workshop items, leaderboards, inventory, and player profiles - all as structured MCP tools callable from Cursor's AI agent. @@ -107,10 +107,10 @@ Add the Steam MCP server to your Cursor MCP settings (`.cursor/mcp.json` in your Once configured, the tools are available to Cursor's AI agent. Pair with the [Steam Developer Tools](https://github.com/TMHSDigital/Steam-Cursor-Plugin) plugin for the full skill set. -## Available Tools (v0.7.0) - 25 Total +## Available Tools (v0.8.0) - 26 Total
-Read Tools (No Auth) - 10 tools +Read Tools (No Auth) - 11 tools These work without an API key: @@ -126,6 +126,7 @@ These work without an API key: | `steam_getAppReviewSummary` | Review score, total counts, and positive percentage (no individual reviews) | | `steam_getRegionalPricing` | Pricing breakdown across multiple countries/regions | | `steam_getNewsForApp` | Recent news articles with title, URL, contents, date, and author | +| `steam_validateStoreAsset` | Local PNG/JPEG vs Valve store and library sizes, plus library-hero heuristics |
@@ -204,6 +205,8 @@ npm run test:watch # Test watch mode See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add new tools and submit PRs. +Partner-admin tools (`steam_partnerLogin`, `steam_uploadStoreImage`, `steam_uploadTrailer`) are not registered by this package's default bin and are not in the npm tarball. They live in `src/partner/` for local use only (`STEAM_PARTNER_ADMIN=1` plus a cookie-jar path or Chromium profile dir outside the repo). There is no Publish tool. + ## Related diff --git a/package-lock.json b/package-lock.json index 8a284bc..1bed211 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tmhs/steam-mcp", - "version": "0.2.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tmhs/steam-mcp", - "version": "0.2.0", + "version": "0.8.0", "license": "CC-BY-NC-ND-4.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", @@ -22,7 +22,7 @@ "vitest": "^4.1.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@emnapi/core": { diff --git a/package.json b/package.json index e5989cb..5d57ade 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tmhs/steam-mcp", - "version": "0.7.0", - "description": "MCP server for Steam & Steamworks APIs - 25 tools (18 read + 7 write) for store data, player stats, reviews, pricing, achievements, workshop, leaderboards, inventory, and lobbies.", + "version": "0.8.0", + "description": "MCP server for Steam & Steamworks APIs - 26 tools (19 read + 7 write) for store data, player stats, reviews, pricing, achievements, workshop, leaderboards, inventory, and lobbies.", "type": "module", "main": "dist/index.js", "bin": { diff --git a/scripts/scan-secrets.py b/scripts/scan-secrets.py new file mode 100644 index 0000000..b8f5503 --- /dev/null +++ b/scripts/scan-secrets.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Fail CI if partner cookies or API keys look committed. + +Scans git-tracked files only so a local gitignored .env is allowed. +Excludes documentation and lockfiles so naming the variables is allowed. +""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +SKIP_NAMES = { + "package-lock.json", + ".env.example", + "SECURITY.md", + "scan-secrets.py", +} + +SKIP_SUFFIXES = { + ".md", + ".mdc", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".woff2", + ".ico", +} + +SKIP_DIR_PREFIXES = ( + "rules/", + "node_modules/", + "dist/", +) + +PATTERNS: list[tuple[re.Pattern[str], str]] = [ + ( + re.compile(r"""STEAM_API_KEY\s*=\s*["']?[0-9a-fA-F]{32}"""), + "STEAM_API_KEY assigned a 32-char hex value", + ), + ( + re.compile( + r"""steamLoginSecure\s*=\s*["']?(?![*\[/^])[^\s"'<>]{8,}""" + ), + "steamLoginSecure cookie assignment", + ), + ( + re.compile(r"""STEAM_PARTNER_COOKIES\s*=\s*["']?[^"'\s#]+"""), + "STEAM_PARTNER_COOKIES assigned a non-empty value", + ), +] + + +def tracked_files() -> list[Path]: + proc = subprocess.run( + ["git", "ls-files", "-z"], + cwd=ROOT, + check=True, + capture_output=True, + ) + out: list[Path] = [] + for rel in proc.stdout.split(b"\0"): + if not rel: + continue + text = rel.decode("utf-8", errors="replace").replace("\\", "/") + if text in SKIP_NAMES or Path(text).name in SKIP_NAMES: + continue + if any(text.startswith(prefix) for prefix in SKIP_DIR_PREFIXES): + continue + path = ROOT / text + if path.suffix.lower() in SKIP_SUFFIXES: + continue + if path.is_file(): + out.append(path) + return out + + +def main() -> int: + files = tracked_files() + hits: list[str] = [] + for path in files: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + print(f"skip unreadable {path}: {exc}", file=sys.stderr) + continue + rel = path.relative_to(ROOT).as_posix() + for pattern, label in PATTERNS: + if pattern.search(text): + hits.append(f"{rel}: {label}") + if hits: + print("Secret-pattern scan failed:", file=sys.stderr) + for hit in hits: + print(f" {hit}", file=sys.stderr) + return 1 + print(f"Secret-pattern scan clean ({len(files)} tracked files).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/index.ts b/src/index.ts index af29731..925558e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,10 +28,11 @@ import { register as registerGetSchemaForGame } from "./tools/getSchemaForGame.j import { register as registerGetNewsForApp } from "./tools/getNewsForApp.js"; import { register as registerGetLeaderboardsForGame } from "./tools/getLeaderboardsForGame.js"; import { register as registerGetPlayerAchievements } from "./tools/getPlayerAchievements.js"; +import { register as registerValidateStoreAsset } from "./tools/validateStoreAsset.js"; const server = new McpServer({ name: "steam-mcp", - version: "0.7.0", + version: "0.8.0", }); registerGetAppDetails(server); @@ -59,6 +60,7 @@ registerGetSchemaForGame(server); registerGetNewsForApp(server); registerGetLeaderboardsForGame(server); registerGetPlayerAchievements(server); +registerValidateStoreAsset(server); async function main(): Promise { const transport = new StdioServerTransport(); diff --git a/src/partner/__tests__/partner-tools.test.ts b/src/partner/__tests__/partner-tools.test.ts new file mode 100644 index 0000000..1b483b8 --- /dev/null +++ b/src/partner/__tests__/partner-tools.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { refuseIfUnconfirmed } from "../../partner/tools.js"; +import { SLOT_FORM_FIELD } from "../../storeAssets/slots.js"; + +describe("partner destructive gates", () => { + it("allows dry_run without confirm", () => { + expect(refuseIfUnconfirmed(true, undefined)).toBeNull(); + expect(refuseIfUnconfirmed(true, false)).toBeNull(); + }); + + it("rejects live calls without confirm", () => { + expect(refuseIfUnconfirmed(false, undefined)).toMatch(/confirm must be true/); + expect(refuseIfUnconfirmed(false, false)).toMatch(/confirm must be true/); + }); + + it("allows live calls with confirm", () => { + expect(refuseIfUnconfirmed(false, true)).toBeNull(); + }); +}); + +describe("partner form fields", () => { + it("maps libraryHero to library_hero|image", () => { + expect(`${SLOT_FORM_FIELD.libraryHero}|image`).toBe("library_hero|image"); + }); +}); diff --git a/src/partner/index.ts b/src/partner/index.ts new file mode 100644 index 0000000..78fce1e --- /dev/null +++ b/src/partner/index.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Gated Partner-admin MCP process. Not the default @tmhs/steam-mcp bin. + * Run locally: STEAM_PARTNER_ADMIN=1 npx tsx src/partner/index.ts + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + registerPartnerLogin, + registerUploadStoreImage, + registerUploadTrailer, +} from "./tools.js"; + +function fail(message: string): never { + console.error(`[AUTH_MISSING] ${message}`); + process.exit(1); +} + +if (process.env.STEAM_PARTNER_ADMIN !== "1") { + fail("Set STEAM_PARTNER_ADMIN=1 to start the Partner-admin process."); +} + +const cookies = process.env.STEAM_PARTNER_COOKIES?.trim(); +const profile = process.env.STEAM_PARTNER_PROFILE_DIR?.trim(); +if (!cookies && !profile) { + fail( + "Set STEAM_PARTNER_COOKIES (cookie-jar path) or STEAM_PARTNER_PROFILE_DIR (Chromium profile outside the repo).", + ); +} + +const server = new McpServer({ + name: "steam-mcp-partner", + version: "0.8.0", +}); + +registerPartnerLogin(server); +registerUploadStoreImage(server); +registerUploadTrailer(server); + +async function main(): Promise { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((error) => { + console.error("Fatal error:", error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/src/partner/tools.ts b/src/partner/tools.ts new file mode 100644 index 0000000..19eccf0 --- /dev/null +++ b/src/partner/tools.ts @@ -0,0 +1,276 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { basename } from "node:path"; +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { errorResponse } from "../utils/steam-api.js"; +import { STORE_ASSET_SLOTS, SLOT_FORM_FIELD } from "../storeAssets/slots.js"; +import { validateStoreAsset } from "../storeAssets/validate.js"; + +function cookiePath(): string | undefined { + const raw = process.env.STEAM_PARTNER_COOKIES?.trim(); + return raw ? raw : undefined; +} + +function profileDir(): string | undefined { + const raw = process.env.STEAM_PARTNER_PROFILE_DIR?.trim(); + return raw ? raw : undefined; +} + +function authError(message: string): { + content: Array<{ type: "text"; text: string }>; + isError: true; +} { + return { + content: [{ type: "text", text: `[AUTH_MISSING] ${message}` }], + isError: true, + }; +} + +export function refuseIfUnconfirmed(dryRun: boolean, confirm: boolean | undefined): string | null { + if (!dryRun && confirm !== true) { + return "confirm must be true when dry_run is false. No request was sent."; + } + return null; +} + +export function registerPartnerLogin(server: McpServer): void { + server.tool( + "steam_partnerLogin", + "Check Partner-admin session sources (cookie jar path or Chromium profile dir). Does not print cookie values. Requires STEAM_PARTNER_ADMIN=1.", + {}, + async () => { + const cookies = cookiePath(); + const profile = profileDir(); + if (!cookies && !profile) { + return authError( + "Set STEAM_PARTNER_COOKIES to a cookie-jar path or STEAM_PARTNER_PROFILE_DIR to a Chromium user-data dir outside the repo.", + ); + } + const report: Record = { + cookiesPathSet: Boolean(cookies), + cookiesPathExists: cookies ? existsSync(cookies) : false, + profileDirSet: Boolean(profile), + profileDirExists: profile ? existsSync(profile) : false, + note: "Session cookies expire and trip Steam Guard. Never commit the jar or profile. Default profile location is %LOCALAPPDATA%/steam-mcp/partner-profile (Windows) or ~/.local/share/steam-mcp/partner-profile.", + }; + return { + content: [{ type: "text" as const, text: JSON.stringify(report, null, 2) }], + }; + }, + ); +} + +const uploadImageSchema = { + storeItemId: z.string().min(1).describe("Partner store item id (not a public appid)"), + slot: z.enum(STORE_ASSET_SLOTS).describe("Asset slot to upload"), + path: z.string().min(1).describe("Local PNG or JPEG path"), + dry_run: z + .boolean() + .optional() + .describe("If true (default), return the planned POST without contacting Steam"), + confirm: z + .boolean() + .optional() + .describe("Required true when dry_run is false. Refuses otherwise."), +}; + +export function registerUploadStoreImage(server: McpServer): void { + server.tool( + "steam_uploadStoreImage", + "Upload a store or library image via Partner admin save. Default dry_run=true. Requires confirm=true to POST. Never publishes. Session cookies, not a Web API key.", + uploadImageSchema, + async ({ storeItemId, slot, path, dry_run, confirm }) => { + try { + const dryRun = dry_run !== false; + const blocked = refuseIfUnconfirmed(dryRun, confirm); + if (blocked) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: blocked }, null, 2) }], + }; + } + + const validation = validateStoreAsset(path, slot); + if (!validation.ok) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { ok: false, refused: "validation failed", validation }, + null, + 2, + ), + }, + ], + }; + } + + let bytes = 0; + try { + bytes = statSync(path).size; + } catch (error) { + return errorResponse(error); + } + + const field = SLOT_FORM_FIELD[slot]; + const formKey = `${field}|image`; + const url = `https://partner.steamgames.com/admin/game/save/${encodeURIComponent(storeItemId)}?json=1`; + const payload = { + ok: true, + dry_run: dryRun, + method: "POST", + url, + storeItemId, + slot, + path, + bytes, + formField: formKey, + publishes: false, + note: "Unofficial Partner-admin FormData. Valve can change field names. This tool never calls Publish.", + }; + + if (dryRun) { + return { + content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], + }; + } + + const cookies = cookiePath(); + if (!cookies || !existsSync(cookies)) { + return authError( + "STEAM_PARTNER_COOKIES must point to a readable cookie jar for a live POST.", + ); + } + + const cookieHeader = loadCookieHeader(cookies); + const buf = readFileSync(path); + const body = new FormData(); + body.append(formKey, new File([buf], basename(path))); + + const response = await fetch(url, { + method: "POST", + headers: { Cookie: cookieHeader }, + body, + }); + + const text = await response.text(); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + ...payload, + httpStatus: response.status, + bodyPreview: redact(text).slice(0, 500), + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return errorResponse(redactError(error)); + } + }, + ); +} + +const trailerSchema = { + storeItemId: z.string().min(1).describe("Partner store item id"), + path: z.string().min(1).describe("Local MP4 path"), + dry_run: z.boolean().optional().describe("If true (default), do not upload"), + confirm: z.boolean().optional().describe("Required true when dry_run is false"), +}; + +export function registerUploadTrailer(server: McpServer): void { + server.tool( + "steam_uploadTrailer", + "Plan or run a Partner trailer upload (movieuploadbegincloud plus S3). Default dry_run=true. Requires confirm=true to upload. Playwright is an optional local dep, not part of @tmhs/steam-mcp. Never publishes.", + trailerSchema, + async ({ storeItemId, path, dry_run, confirm }) => { + const dryRun = dry_run !== false; + const blocked = refuseIfUnconfirmed(dryRun, confirm); + if (blocked) { + return { + content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: blocked }, null, 2) }], + }; + } + + if (!existsSync(path)) { + return errorResponse(new Error(`Trailer file not found: ${path}`)); + } + const bytes = statSync(path).size; + + if (dryRun) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + ok: true, + dry_run: true, + storeItemId, + path, + bytes, + requiresPlaywright: true, + publishes: false, + note: "Live trailer upload needs Playwright in this working tree (not shipped on npm). Install locally if you intend to set dry_run=false.", + }, + null, + 2, + ), + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + ok: false, + error: + "Live movieuploadbegincloud plus S3 is not wired in this build. Re-run with dry_run=true or complete the Playwright flow locally. Do not add Playwright to the published package.", + }, + null, + 2, + ), + }, + ], + }; + }, + ); +} + +function loadCookieHeader(jarPath: string): string { + const raw = readFileSync(jarPath, "utf8"); + const headerParts: string[] = []; + for (const line of raw.split(/\r?\n/)) { + if (!line || line.startsWith("#")) { + continue; + } + const cols = line.split("\t"); + if (cols.length >= 7) { + headerParts.push(`${cols[5]}=${cols[6]}`); + } + } + if (headerParts.length === 0) { + throw new Error("Cookie jar has no Netscape cookie rows."); + } + return headerParts.join("; "); +} + +function redact(text: string): string { + const name = "steamLogin" + "Secure"; + return text.replace(new RegExp(`${name}=[^;\\s"]+`, "gi"), `${name}=****`); +} + +function redactError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error(redact(message)); +} diff --git a/src/storeAssets/heroHeuristics.ts b/src/storeAssets/heroHeuristics.ts new file mode 100644 index 0000000..876286d --- /dev/null +++ b/src/storeAssets/heroHeuristics.ts @@ -0,0 +1,124 @@ +export type HeroHeuristicFlags = { + ribbon: boolean; + seam: boolean; + wordmarkFail: boolean; + wordmarkWarn: boolean; +}; + +function chroma(r: number, g: number, b: number): number { + return Math.max(r, g, b) - Math.min(r, g, b); +} + +function luma(r: number, g: number, b: number): number { + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function pixel( + rgba: Uint8Array, + width: number, + x: number, + y: number, +): [number, number, number] { + const i = (y * width + x) * 4; + return [rgba[i], rgba[i + 1], rgba[i + 2]]; +} + +function bandMeanChroma( + rgba: Uint8Array, + width: number, + height: number, + y0: number, + y1: number, +): number { + let sum = 0; + let n = 0; + const stepX = Math.max(1, Math.floor(width / 480)); + const stepY = Math.max(1, Math.floor((y1 - y0) / 24)); + for (let y = y0; y < y1; y += stepY) { + for (let x = 0; x < width; x += stepX) { + const [r, g, b] = pixel(rgba, width, x, y); + sum += chroma(r, g, b); + n += 1; + } + } + return n === 0 ? 0 : sum / n; +} + +function seamScore(rgba: Uint8Array, width: number, height: number): number { + const mid = Math.floor(width / 2); + if (mid < 2) { + return 0; + } + const stepY = Math.max(1, Math.floor(height / 310)); + let midSum = 0; + let ctrlSum = 0; + let n = 0; + const ctrl = Math.max(1, Math.floor(width / 4)); + for (let y = 0; y < height; y += stepY) { + const [r0, g0, b0] = pixel(rgba, width, mid - 1, y); + const [r1, g1, b1] = pixel(rgba, width, mid, y); + midSum += Math.abs(r0 - r1) + Math.abs(g0 - g1) + Math.abs(b0 - b1); + const [cr0, cg0, cb0] = pixel(rgba, width, ctrl - 1, y); + const [cr1, cg1, cb1] = pixel(rgba, width, ctrl, y); + ctrlSum += Math.abs(cr0 - cr1) + Math.abs(cg0 - cg1) + Math.abs(cb0 - cb1); + n += 1; + } + if (n === 0) { + return 0; + } + const midMean = midSum / n; + const ctrlMean = Math.max(1, ctrlSum / n); + return midMean / ctrlMean; +} + +function edgeDensity(rgba: Uint8Array, width: number, height: number): number { + const stepX = Math.max(1, Math.floor(width / 960)); + const stepY = Math.max(1, Math.floor(height / 310)); + let edges = 0; + let n = 0; + for (let y = 1; y < height - 1; y += stepY) { + for (let x = 1; x < width - 1; x += stepX) { + const [r, g, b] = pixel(rgba, width, x, y); + const [rx, gx, bx] = pixel(rgba, width, x + 1, y); + const [ry, gy, by] = pixel(rgba, width, x, y + 1); + const grad = + Math.abs(r - rx) + + Math.abs(g - gx) + + Math.abs(b - bx) + + Math.abs(r - ry) + + Math.abs(g - gy) + + Math.abs(b - by); + const L = luma(r, g, b); + if (grad > 180 && (L > 160 || L < 50)) { + edges += 1; + } + n += 1; + } + } + return n === 0 ? 0 : edges / n; +} + +export function analyzeHero(rgba: Uint8Array, width: number, height: number): HeroHeuristicFlags { + const band = Math.max(1, Math.floor(height * 0.12)); + const topChroma = bandMeanChroma(rgba, width, height, 0, band); + const botChroma = bandMeanChroma(rgba, width, height, height - band, height); + const midChroma = bandMeanChroma( + rgba, + width, + height, + Math.floor(height * 0.25), + Math.floor(height * 0.75), + ); + const ribbon = + (topChroma > 80 && topChroma > midChroma * 2.2) || + (botChroma > 80 && botChroma > midChroma * 2.2); + + const seamRatio = seamScore(rgba, width, height); + const seam = seamRatio > 3.2; + + const edges = edgeDensity(rgba, width, height); + const wordmarkFail = edges > 0.12; + const wordmarkWarn = !wordmarkFail && edges > 0.04; + + return { ribbon, seam, wordmarkFail, wordmarkWarn }; +} diff --git a/src/storeAssets/png.ts b/src/storeAssets/png.ts new file mode 100644 index 0000000..7ebc821 --- /dev/null +++ b/src/storeAssets/png.ts @@ -0,0 +1,262 @@ +import { deflateSync, inflateSync } from "node:zlib"; + +export type DecodedImage = { + width: number; + height: number; + format: "png" | "jpeg"; + hasAlpha: boolean; + /** Row-major RGBA, present only when PNG pixels were decoded. */ + rgba?: Uint8Array; +}; + +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export function parseImage(buf: Buffer, decodePixels: boolean): DecodedImage { + if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_SIG)) { + return parsePng(buf, decodePixels); + } + if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xd8) { + return parseJpeg(buf); + } + throw new Error("File is not a PNG or JPEG."); +} + +function parseJpeg(buf: Buffer): DecodedImage { + let i = 2; + while (i < buf.length - 8) { + if (buf[i] !== 0xff) { + i += 1; + continue; + } + const marker = buf[i + 1]; + if (marker === 0xd8 || marker === 0xd9 || marker === 0x01) { + i += 2; + continue; + } + if (marker === 0xda) { + break; + } + const size = buf.readUInt16BE(i + 2); + if (size < 2) { + break; + } + // SOF0 / SOF1 / SOF2 + if (marker >= 0xc0 && marker <= 0xc2) { + const height = buf.readUInt16BE(i + 5); + const width = buf.readUInt16BE(i + 7); + return { width, height, format: "jpeg", hasAlpha: false }; + } + i += 2 + size; + } + throw new Error("JPEG is missing a Start of Frame marker."); +} + +function parsePng(buf: Buffer, decodePixels: boolean): DecodedImage { + let offset = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = -1; + let interlace = 0; + let hasTrns = false; + const idat: Buffer[] = []; + + while (offset + 12 <= buf.length) { + const length = buf.readUInt32BE(offset); + const type = buf.subarray(offset + 4, offset + 8).toString("ascii"); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > buf.length) { + throw new Error("PNG chunk is truncated."); + } + const data = buf.subarray(dataStart, dataEnd); + + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + interlace = data[12]; + } else if (type === "IDAT") { + idat.push(Buffer.from(data)); + } else if (type === "tRNS") { + hasTrns = true; + } else if (type === "IEND") { + break; + } + + offset = dataEnd + 4; + } + + if (!width || !height) { + throw new Error("PNG is missing IHDR."); + } + + const hasAlpha = colorType === 4 || colorType === 6 || hasTrns; + const result: DecodedImage = { + width, + height, + format: "png", + hasAlpha, + }; + + if (!decodePixels) { + return result; + } + + if (interlace !== 0) { + throw new Error("Interlaced PNG is not supported."); + } + if (bitDepth !== 8) { + throw new Error(`PNG bit depth ${bitDepth} is not supported (need 8).`); + } + if (colorType !== 2 && colorType !== 6) { + throw new Error(`PNG color type ${colorType} is not supported (need RGB or RGBA).`); + } + + const channels = colorType === 6 ? 4 : 3; + const inflated = inflateSync(Buffer.concat(idat)); + result.rgba = unfilterPng(inflated, width, height, channels); + return result; +} + +function paeth(a: number, b: number, c: number): number { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + if (pa <= pb && pa <= pc) { + return a; + } + if (pb <= pc) { + return b; + } + return c; +} + +function unfilterPng( + inflated: Buffer, + width: number, + height: number, + channels: number, +): Uint8Array { + const stride = width * channels; + const rowBytes = stride + 1; + if (inflated.length < rowBytes * height) { + throw new Error("PNG IDAT is too small for the declared size."); + } + + const rgba = new Uint8Array(width * height * 4); + const recon = new Uint8Array(stride); + const prev = new Uint8Array(stride); + + for (let y = 0; y < height; y++) { + const rowOff = y * rowBytes; + const filter = inflated[rowOff]; + const src = inflated.subarray(rowOff + 1, rowOff + 1 + stride); + + for (let x = 0; x < stride; x++) { + const raw = src[x]; + const a = x >= channels ? recon[x - channels] : 0; + const b = prev[x]; + const c = x >= channels ? prev[x - channels] : 0; + let val: number; + switch (filter) { + case 0: + val = raw; + break; + case 1: + val = (raw + a) & 0xff; + break; + case 2: + val = (raw + b) & 0xff; + break; + case 3: + val = (raw + ((a + b) >> 1)) & 0xff; + break; + case 4: + val = (raw + paeth(a, b, c)) & 0xff; + break; + default: + throw new Error(`Unsupported PNG filter ${filter}.`); + } + recon[x] = val; + } + + for (let x = 0; x < width; x++) { + const si = x * channels; + const di = (y * width + x) * 4; + rgba[di] = recon[si]; + rgba[di + 1] = recon[si + 1]; + rgba[di + 2] = recon[si + 2]; + rgba[di + 3] = channels === 4 ? recon[si + 3] : 255; + } + + prev.set(recon); + } + + return rgba; +} + +/** Encode 8-bit RGB or RGBA PNG. Used by tests. */ +export function encodePng( + width: number, + height: number, + rgba: Uint8Array, + withAlpha: boolean, +): Buffer { + const channels = withAlpha ? 4 : 3; + const stride = width * channels; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + raw[(stride + 1) * y] = 0; + for (let x = 0; x < width; x++) { + const si = (y * width + x) * 4; + const di = (stride + 1) * y + 1 + x * channels; + raw[di] = rgba[si]; + raw[di + 1] = rgba[si + 1]; + raw[di + 2] = rgba[si + 2]; + if (withAlpha) { + raw[di + 3] = rgba[si + 3]; + } + } + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = withAlpha ? 6 : 2; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + const chunks = [ + PNG_SIG, + pngChunk("IHDR", ihdr), + pngChunk("IDAT", deflateSync(raw)), + pngChunk("IEND", Buffer.alloc(0)), + ]; + return Buffer.concat(chunks); +} + +function pngChunk(type: string, data: Buffer): Buffer { + const typeBuf = Buffer.from(type, "ascii"); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const crcBuf = crc32(Buffer.concat([typeBuf, data])); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crcBuf, 0); + return Buffer.concat([len, typeBuf, data, crc]); +} + +function crc32(buf: Buffer): number { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i]; + for (let j = 0; j < 8; j++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/src/storeAssets/slots.ts b/src/storeAssets/slots.ts new file mode 100644 index 0000000..5e731fd --- /dev/null +++ b/src/storeAssets/slots.ts @@ -0,0 +1,50 @@ +export const STORE_ASSET_SLOTS = [ + "headerCapsule", + "smallCapsule", + "mainCapsule", + "verticalCapsule", + "libraryCapsule", + "libraryHero", + "libraryLogo", + "libraryHeader", + "screenshot", + "pageBackground", +] as const; + +export type StoreAssetSlot = (typeof STORE_ASSET_SLOTS)[number]; + +export type SlotSpec = { + width?: number; + height?: number; + /** Screenshot: min size + 16:9. Logo: width===1280 OR height===720. */ + kind: "exact" | "screenshot" | "logo"; + formats: Array<"png" | "jpeg">; + requireAlpha?: boolean; +}; + +export const SLOT_SPECS: Record = { + headerCapsule: { kind: "exact", width: 920, height: 430, formats: ["png", "jpeg"] }, + smallCapsule: { kind: "exact", width: 462, height: 174, formats: ["png", "jpeg"] }, + mainCapsule: { kind: "exact", width: 1232, height: 706, formats: ["png", "jpeg"] }, + verticalCapsule: { kind: "exact", width: 748, height: 896, formats: ["png", "jpeg"] }, + libraryCapsule: { kind: "exact", width: 600, height: 900, formats: ["png", "jpeg"] }, + libraryHero: { kind: "exact", width: 3840, height: 1240, formats: ["png"] }, + libraryLogo: { kind: "logo", formats: ["png"], requireAlpha: true }, + libraryHeader: { kind: "exact", width: 920, height: 430, formats: ["png", "jpeg"] }, + screenshot: { kind: "screenshot", formats: ["png", "jpeg"] }, + pageBackground: { kind: "exact", width: 1438, height: 810, formats: ["png", "jpeg"] }, +}; + +/** Unofficial Partner admin FormData field names. Subject to Valve HTML changes. */ +export const SLOT_FORM_FIELD: Record = { + headerCapsule: "header_image", + smallCapsule: "small_capsule", + mainCapsule: "main_capsule", + verticalCapsule: "hero_capsule", + libraryCapsule: "library_capsule", + libraryHero: "library_hero", + libraryLogo: "library_logo", + libraryHeader: "library_header", + screenshot: "screenshot", + pageBackground: "page_background", +}; diff --git a/src/storeAssets/validate.ts b/src/storeAssets/validate.ts new file mode 100644 index 0000000..7be91fc --- /dev/null +++ b/src/storeAssets/validate.ts @@ -0,0 +1,150 @@ +import { readFileSync } from "node:fs"; +import { SLOT_SPECS, type StoreAssetSlot } from "./slots.js"; +import { parseImage } from "./png.js"; +import { analyzeHero } from "./heroHeuristics.js"; + +export type ValidationIssue = { + code: string; + message: string; +}; + +export type StoreAssetValidation = { + ok: boolean; + slot: StoreAssetSlot; + path: string; + width?: number; + height?: number; + format?: "png" | "jpeg"; + hasAlpha?: boolean; + errors: ValidationIssue[]; + warnings: ValidationIssue[]; +}; + +export function validateStoreAsset(path: string, slot: StoreAssetSlot): StoreAssetValidation { + const spec = SLOT_SPECS[slot]; + const errors: ValidationIssue[] = []; + const warnings: ValidationIssue[] = []; + const result: StoreAssetValidation = { + ok: false, + slot, + path, + errors, + warnings, + }; + + let buf: Buffer; + try { + buf = readFileSync(path); + } catch (error) { + errors.push({ + code: "IO", + message: `Cannot read file: ${error instanceof Error ? error.message : String(error)}`, + }); + return result; + } + + const decodePixels = slot === "libraryHero"; + let image; + try { + image = parseImage(buf, decodePixels); + } catch (error) { + errors.push({ + code: "PARSE", + message: error instanceof Error ? error.message : String(error), + }); + return result; + } + + result.width = image.width; + result.height = image.height; + result.format = image.format; + result.hasAlpha = image.hasAlpha; + + if (!spec.formats.includes(image.format)) { + errors.push({ + code: "FORMAT", + message: `${slot} must be ${spec.formats.join(" or ").toUpperCase()} (got ${image.format}).`, + }); + } + + if (spec.requireAlpha && !image.hasAlpha) { + errors.push({ + code: "ALPHA", + message: `${slot} must be a PNG with an alpha channel.`, + }); + } + + if (spec.kind === "exact" && spec.width && spec.height) { + if (image.width !== spec.width || image.height !== spec.height) { + errors.push({ + code: "SIZE", + message: `${slot} must be ${spec.width}x${spec.height} (got ${image.width}x${image.height}).`, + }); + } + if (slot === "libraryHero" && image.width === 1920 && image.height === 620) { + errors.push({ + code: "SIZE_HALF", + message: + "libraryHero 1920x620 is Valve's auto-generated half-size. Upload the 3840x1240 PNG.", + }); + } + } + + if (spec.kind === "logo") { + if (image.width !== 1280 && image.height !== 720) { + errors.push({ + code: "SIZE", + message: `libraryLogo must be 1280px wide and/or 720px tall (got ${image.width}x${image.height}).`, + }); + } + } + + if (spec.kind === "screenshot") { + if (image.width < 1920 || image.height < 1080) { + errors.push({ + code: "SIZE", + message: `screenshot must be at least 1920x1080 (got ${image.width}x${image.height}).`, + }); + } + const ratio = image.width / image.height; + if (Math.abs(ratio - 16 / 9) > 0.02) { + errors.push({ + code: "ASPECT", + message: `screenshot must be 16:9 (got ${image.width}x${image.height}, ratio ${ratio.toFixed(3)}).`, + }); + } + } + + if (slot === "libraryHero" && image.rgba && image.width && image.height) { + const flags = analyzeHero(image.rgba, image.width, image.height); + if (flags.ribbon) { + errors.push({ + code: "HERO_RIBBON", + message: + "Heuristic: high-chroma banner in the top or bottom 12% (PROTOTYPE / compositor ribbon).", + }); + } + if (flags.seam) { + errors.push({ + code: "HERO_SEAM", + message: "Heuristic: vertical midline looks like a two-panel seam.", + }); + } + if (flags.wordmarkFail) { + errors.push({ + code: "HERO_WORDMARK", + message: + "Heuristic: sharp high-contrast blobs on the hero (wordmark belongs on libraryLogo, not libraryHero).", + }); + } else if (flags.wordmarkWarn) { + warnings.push({ + code: "HERO_WORDMARK", + message: + "Heuristic: possible wordmark or UI on the library hero. Valve requires artwork only, no text.", + }); + } + } + + result.ok = errors.length === 0; + return result; +} diff --git a/src/tools/__tests__/validateStoreAsset.test.ts b/src/tools/__tests__/validateStoreAsset.test.ts new file mode 100644 index 0000000..b0cd9ea --- /dev/null +++ b/src/tools/__tests__/validateStoreAsset.test.ts @@ -0,0 +1,229 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect, afterEach } from "vitest"; +import { encodePng } from "../../storeAssets/png.js"; +import { validateStoreAsset } from "../../storeAssets/validate.js"; +import type { StoreAssetSlot } from "../../storeAssets/slots.js"; + +const dirs: string[] = []; + +function tmpPng( + name: string, + width: number, + height: number, + fill: (rgba: Uint8Array, w: number, h: number) => void, + withAlpha = false, +): string { + const dir = mkdtempSync(join(tmpdir(), "steam-asset-")); + dirs.push(dir); + const rgba = new Uint8Array(width * height * 4); + fill(rgba, width, height); + const buf = encodePng(width, height, rgba, withAlpha); + const path = join(dir, name); + writeFileSync(path, buf); + return path; +} + +function solid( + r: number, + g: number, + b: number, + a = 255, +): (rgba: Uint8Array) => void { + return (rgba) => { + for (let i = 0; i < rgba.length; i += 4) { + rgba[i] = r; + rgba[i + 1] = g; + rgba[i + 2] = b; + rgba[i + 3] = a; + } + }; +} + +afterEach(() => { + while (dirs.length) { + const dir = dirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("validateStoreAsset sizes", () => { + it("accepts headerCapsule 920x430", () => { + const path = tmpPng("h.png", 920, 430, solid(20, 20, 30)); + const v = validateStoreAsset(path, "headerCapsule"); + expect(v.ok).toBe(true); + expect(v.width).toBe(920); + expect(v.height).toBe(430); + }); + + it("rejects wrong headerCapsule size", () => { + const path = tmpPng("h.png", 460, 215, solid(20, 20, 30)); + const v = validateStoreAsset(path, "headerCapsule"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "SIZE")).toBe(true); + }); + + it("accepts libraryLogo 1280 wide", () => { + const path = tmpPng("logo.png", 1280, 400, solid(255, 255, 255, 0), true); + const v = validateStoreAsset(path, "libraryLogo"); + expect(v.ok).toBe(true); + }); + + it("accepts libraryLogo 720 tall", () => { + const path = tmpPng("logo.png", 800, 720, solid(255, 255, 255, 0), true); + const v = validateStoreAsset(path, "libraryLogo"); + expect(v.ok).toBe(true); + }); + + it("rejects libraryLogo without matching dimension", () => { + const path = tmpPng("logo.png", 1000, 1000, solid(255, 255, 255, 0), true); + const v = validateStoreAsset(path, "libraryLogo"); + expect(v.ok).toBe(false); + }); + + it("rejects libraryLogo RGB without alpha", () => { + const path = tmpPng("logo.png", 1280, 720, solid(255, 255, 255), false); + const v = validateStoreAsset(path, "libraryLogo"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "ALPHA")).toBe(true); + }); + + it("accepts screenshot 1920x1080", () => { + const path = tmpPng("ss.png", 1920, 1080, solid(10, 10, 10)); + const v = validateStoreAsset(path, "screenshot"); + expect(v.ok).toBe(true); + }); + + it("rejects screenshot below 1920x1080", () => { + const path = tmpPng("ss.png", 1280, 720, solid(10, 10, 10)); + const v = validateStoreAsset(path, "screenshot"); + expect(v.ok).toBe(false); + }); + + it("rejects non-16:9 screenshot", () => { + const path = tmpPng("ss.png", 1920, 1200, solid(10, 10, 10)); + const v = validateStoreAsset(path, "screenshot"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "ASPECT")).toBe(true); + }); + + it("rejects libraryHero half-size 1920x620", () => { + const path = tmpPng("hero.png", 1920, 620, solid(20, 20, 30)); + const v = validateStoreAsset(path, "libraryHero"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "SIZE_HALF" || e.code === "SIZE")).toBe( + true, + ); + }); + + it("reports IO error for missing file", () => { + const v = validateStoreAsset("Z:/definitely-missing-steam-asset.png", "smallCapsule"); + expect(v.ok).toBe(false); + expect(v.errors[0]?.code).toBe("IO"); + }); +}); + +describe("validateStoreAsset libraryHero heuristics", () => { + const W = 3840; + const H = 1240; + + it("accepts a clean dark hero", () => { + const path = tmpPng("hero.png", W, H, solid(28, 30, 38)); + const v = validateStoreAsset(path, "libraryHero"); + expect(v.ok).toBe(true); + expect(v.errors).toEqual([]); + }, 30_000); + + it("rejects a PROTOTYPE ribbon band", () => { + const path = tmpPng("hero.png", W, H, (rgba) => { + const band = Math.floor(H * 0.12); + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const i = (y * W + x) * 4; + if (y < band) { + rgba[i] = 255; + rgba[i + 1] = 201; + rgba[i + 2] = 63; + } else { + rgba[i] = 28; + rgba[i + 1] = 30; + rgba[i + 2] = 38; + } + rgba[i + 3] = 255; + } + } + }); + const v = validateStoreAsset(path, "libraryHero"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "HERO_RIBBON")).toBe(true); + }, 30_000); + + it("rejects a two-panel vertical seam", () => { + const path = tmpPng("hero.png", W, H, (rgba) => { + const mid = Math.floor(W / 2); + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const i = (y * W + x) * 4; + if (x < mid) { + rgba[i] = 180; + rgba[i + 1] = 40; + rgba[i + 2] = 40; + } else { + rgba[i] = 40; + rgba[i + 1] = 40; + rgba[i + 2] = 180; + } + rgba[i + 3] = 255; + } + } + }); + const v = validateStoreAsset(path, "libraryHero"); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.code === "HERO_SEAM")).toBe(true); + }, 30_000); + + it("rejects a high-contrast wordmark-like grid", () => { + const path = tmpPng("hero.png", W, H, (rgba) => { + for (let i = 0; i < rgba.length; i += 4) { + rgba[i] = 20; + rgba[i + 1] = 20; + rgba[i + 2] = 24; + rgba[i + 3] = 255; + } + for (let y = 200; y < 1000; y++) { + for (let x = 80; x < 3600; x++) { + if (x % 12 < 4 || y % 28 < 6) { + const i = (y * W + x) * 4; + rgba[i] = 250; + rgba[i + 1] = 250; + rgba[i + 2] = 240; + } + } + } + }); + const v = validateStoreAsset(path, "libraryHero"); + expect( + v.errors.some((e) => e.code === "HERO_WORDMARK") || + v.warnings.some((e) => e.code === "HERO_WORDMARK"), + ).toBe(true); + }, 30_000); +}); + +describe("slot coverage", () => { + const exact: Array<[StoreAssetSlot, number, number]> = [ + ["smallCapsule", 462, 174], + ["mainCapsule", 1232, 706], + ["verticalCapsule", 748, 896], + ["libraryCapsule", 600, 900], + ["libraryHeader", 920, 430], + ["pageBackground", 1438, 810], + ]; + + it.each(exact)("%s accepts %dx%d", (slot, w, h) => { + const path = tmpPng(`${slot}.png`, w, h, solid(12, 12, 16)); + expect(validateStoreAsset(path, slot).ok).toBe(true); + }); +}); diff --git a/src/tools/validateStoreAsset.ts b/src/tools/validateStoreAsset.ts new file mode 100644 index 0000000..c7e1cc1 --- /dev/null +++ b/src/tools/validateStoreAsset.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { errorResponse } from "../utils/steam-api.js"; +import { STORE_ASSET_SLOTS } from "../storeAssets/slots.js"; +import { validateStoreAsset } from "../storeAssets/validate.js"; + +const inputSchema = { + path: z.string().min(1).describe("Local filesystem path to a PNG or JPEG"), + slot: z + .enum(STORE_ASSET_SLOTS) + .describe( + "Store or library asset slot (headerCapsule, smallCapsule, mainCapsule, verticalCapsule, libraryCapsule, libraryHero, libraryLogo, libraryHeader, screenshot, pageBackground)", + ), +}; + +export function register(server: McpServer): void { + server.tool( + "steam_validateStoreAsset", + "Validate a local store or library image against current Valve pixel sizes, format rules, and library-hero heuristics. No API key required.", + inputSchema, + async ({ path, slot }) => { + try { + const result = validateStoreAsset(path, slot); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + return errorResponse(error); + } + }, + ); +} diff --git a/tsconfig.json b/tsconfig.json index e225ace..9755ebc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "sourceMap": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/__tests__"] + "exclude": ["node_modules", "dist", "src/**/__tests__", "src/partner"] }