diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9133fdb..8e3f4bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,33 @@ jobs: - name: Quality checks run: npm run check + + sonarcloud: + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@v5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.sourcery.yaml b/.sourcery.yaml new file mode 100644 index 0000000..ef78bfe --- /dev/null +++ b/.sourcery.yaml @@ -0,0 +1,10 @@ +reviews: + request_review: true + approve: false + auto_merge: false + ignore_paths: + - dist/** + - node_modules/** + - plugin/** + - "*.json" + - "*.md" diff --git a/README.md b/README.md index 2c5d243..0f0d3f4 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ Current report areas: - Package drift - Performance hotspots +### Genre-Specific Checks (Opt-In) + +- **Shooter/Sniper** — weapon remote trust analysis, spawn fairness heuristics, combat content review, weapon equip and respawn cycle smoke tests + +Genre checks are opt-in and heuristic. They use pattern matching and static analysis, not runtime simulation. See [docs/shooter-checks.md](docs/shooter-checks.md) for details. + Each finding includes: - Severity: `blocker`, `warning`, or `info` - Confidence: `high`, `medium`, `heuristic`, or `manual_review` @@ -136,20 +142,22 @@ See [examples/](examples/) for full sample reports in Markdown and JSON. - The verdict is a scoring rule based on issue counts, not a comprehensive release policy. - It can miss issues and it can raise false positives. A passing report means "nothing obvious was flagged," not "safe to publish." - Some checks depend on Open Cloud API keys for full coverage. Without them, metadata-based checks are skipped. +- Genre-specific checks use keyword and pattern matching. They work best with conventional naming and value-instance configs. Unconventional architectures may produce false positives or missed detections. ## Studio-Tested -All 43 tools have been integration-tested against a live Roblox Studio session (2026-03-29). +All 46 tools have been integration-tested against a live Roblox Studio session (2026-03-29). | Category | Tools | Pass | Skip | Partial | |----------|-------|------|------|---------| | Core | 18 | 17 | 0 | 1 | | Shipcheck | 14 | 14 | 0 | 0 | +| Shooter Genre | 3 | 3 | 0 | 0 | | Automation | 4 | 3 | 1 | 0 | | Building | 3 | 3 | 0 | 0 | | Cloud | 3 | 0 | 3 | 0 | | Playtester | 1 | 1 | 0 | 0 | -| **Total** | **43** | **38** | **4** | **1** | +| **Total** | **46** | **41** | **4** | **1** | - **Skip:** Cloud tools require an Open Cloud API key (schema validated, not callable without credentials). - **Partial:** `start_playtest` returns a plugin capability error (`StartDecal`). Playtest control may require manual interaction in some Studio configurations. diff --git a/TESTING.md b/TESTING.md index 310832a..21af124 100644 --- a/TESTING.md +++ b/TESTING.md @@ -77,6 +77,18 @@ All 43 tools tested against a live Roblox Studio session on 2026-03-29. |------|--------|-------| | `rbx_playtester` | PASS | list_scenarios, run_scenario (spawn_flow PASS), get_result all working | +## Shooter Genre (3 tools + 2 presets) + +Tested against a non-shooter Roblox project (expected: clean output, no false positives). + +| Tool | Result | Notes | +|------|--------|-------| +| `rbx_shooter_weapon_remote_trust` | PASS | 0 weapon remotes found (correct for non-shooter) | +| `rbx_shooter_spawn_clustering` | PASS | 0 spawns found (correct) | +| `rbx_shooter_combat_content_maturity` | PASS | 30 scripts + 37 UI elements scanned, 0 findings | +| `shooter_weapon_equip` preset | PARTIAL | No weapons in StarterPack (expected for non-shooter) | +| `shooter_respawn_cycle` preset | PASS | CharacterAutoLoads=true, RespawnTime=3, 7 CharacterAdded handlers | + ## Known Limitations - **`start_playtest`**: Plugin cannot invoke playtest in all Studio configurations due to `StartDecal` capability error. Use manual Play button as a workaround. diff --git a/docs/shooter-checks.md b/docs/shooter-checks.md new file mode 100644 index 0000000..5b5b71b --- /dev/null +++ b/docs/shooter-checks.md @@ -0,0 +1,107 @@ +# Shooter/Sniper Genre Checks + +Genre-specific checks for Roblox shooter and sniper games. All checks are **opt-in** and **heuristic** — they use pattern matching and static analysis, not runtime simulation. + +## Shipcheck Rules + +### `rbx_shooter_weapon_remote_trust` + +Audits weapon-related RemoteEvents and RemoteFunctions for server-side validation. + +**What it checks:** +- Finds remotes with weapon-related names (fire, shoot, damage, hit, reload, equip, weapon, gun, bullet, projectile) +- Verifies each remote has a server-side handler in ServerScriptService +- Checks handlers for argument type validation (typeof, tonumber, assert, etc.) +- Checks handlers for rate limiting patterns (tick, cooldown, debounce, throttle) + +**Issues raised:** + +| Rule | Severity | Confidence | Description | +|------|----------|------------|-------------| +| `no_server_handler` | medium | medium | Weapon remote has no server-side handler | +| `missing_type_validation` | medium | heuristic | Handler lacks argument type checks | +| `no_rate_limiting` | low | heuristic | Handler lacks rate limiting patterns | + +**Limitations:** +- Pattern-based name matching — non-standard naming may be missed +- Cannot verify validation is *correct*, only that patterns *exist* +- Obfuscated or minified scripts won't be analyzed effectively +- Only scans ServerScriptService for handlers + +--- + +### `rbx_shooter_spawn_clustering` + +Analyzes SpawnLocation distribution for fairness issues. + +**What it checks:** +- Measures pairwise distances between all SpawnLocations +- Flags clustering when average spread is below threshold (default: 30 studs) +- Checks team balance — flags if team spawn counts differ by more than 2x +- Detects suspicious spawn heights (Y < -10 or Y > 1000) + +**Issues raised:** + +| Rule | Severity | Confidence | Description | +|------|----------|------------|-------------| +| `spawn_clustering` | warning | heuristic | Spawns are clustered below minimum spread | +| `team_spawn_imbalance` | warning | medium | Teams have unequal spawn point counts | +| `suspicious_spawn_height` | info | medium | Spawn at extreme Y position | + +**Limitations:** +- Position-only heuristic — cannot assess line-of-sight or cover +- Intentionally clustered spawns (lobby areas) will be flagged +- FFA games without teams may trigger false positives on team balance + +--- + +### `rbx_shooter_combat_content_maturity` + +Scans scripts and UI text for combat-related content that may affect age rating. + +**What it checks:** +- Reads all script source code and scans for keyword categories +- Scans TextLabel and TextButton text properties +- Categories: violence_explicit, violence_moderate, weapon_refs, social_risk + +**Keyword categories:** + +| Category | Examples | Severity | +|----------|----------|----------| +| violence_explicit | gore, dismember, decapitate, torture | warning | +| violence_moderate | blood, bleed, corpse, dead body | info | +| weapon_refs | AK-47, shotgun, sniper rifle, RPG | info | +| social_risk | discord.gg, youtube.com, twitter.com | warning | + +**Limitations:** +- Keyword-based only — no semantic understanding +- Common game terms may be flagged (e.g., "headshot" is normal in shooters) +- All findings are `manual_review` confidence — flags for human review, not violations + +## Playtester Presets + +### `shooter_weapon_equip` + +Verifies weapon Tools exist with proper configuration. + +**Flow:** +1. Check StarterPack for Tool instances +2. Verify weapons have Handle parts +3. Check for config values (Damage, Ammo, FireRate) +4. Confirm at least one configured weapon exists + +**Expected result:** PASS for shooter projects with value-instance weapon configs. PARTIAL or FAIL for non-shooter projects or script-based configs. + +--- + +### `shooter_respawn_cycle` + +Validates respawn infrastructure. + +**Flow:** +1. Check Players.CharacterAutoLoads and RespawnTime +2. Count SpawnLocations and team assignments +3. Search for CharacterAdded handlers in scripts +4. Confirm spawn infrastructure exists + +**Expected result:** PASS for most games with standard respawn setup. PARTIAL if CharacterAutoLoads is disabled (may be intentional for custom respawn). diff --git a/examples/shooter-report.md b/examples/shooter-report.md new file mode 100644 index 0000000..efeae0d --- /dev/null +++ b/examples/shooter-report.md @@ -0,0 +1,58 @@ +# Example: Shooter Audit Report + +Sample output from running shooter-specific checks on a Roblox FPS project. + +## Shipcheck Report — MyShooterGame + +**Date:** 2026-03-29T20:00:00Z +**Verdict:** REVIEW — Score: 72/100 + +### Summary +- Blockers: 0 +- Warnings: 3 +- Info: 1 +- Manual review needed: 2 + +### Shooter-Specific Findings + +#### [shooter-weapon-001] Unvalidated weapon remote +**Confidence:** medium | **Category:** security | **Remediation:** assisted +Handler for "FireWeapon" in ServerScriptService.WeaponHandler may lack argument type checks. +**Evidence:** ServerScriptService.WeaponHandler — no typeof/tonumber patterns found +**Recommendation:** Validate argument types (typeof, tonumber, etc.) in OnServerEvent handlers. + +#### [shooter-weapon-002] No rate limiting on damage remote +**Confidence:** heuristic | **Category:** security | **Remediation:** assisted +No rate limiting patterns detected for "ApplyDamage" handler. +**Evidence:** ServerScriptService.DamageHandler — no tick()/cooldown/debounce patterns +**Recommendation:** Add server-side rate limiting to prevent fire-rate exploitation. + +#### [shooter-spawn-001] Spawn point clustering detected +**Confidence:** heuristic | **Category:** gameplay | **Remediation:** manual +Average spawn spread is 18.4 studs, below the 30-stud minimum. +**Evidence:** 8 SpawnLocations in Workspace.Spawns, avg distance: 18.4 studs +**Recommendation:** Spread spawn points to reduce spawn-kill risk. + +#### [shooter-spawn-002] Team spawn imbalance +**Confidence:** medium | **Category:** gameplay | **Remediation:** manual +Team "Red" has 5 spawns, team "Blue" has 2 spawns. +**Evidence:** SpawnLocation TeamColor distribution +**Recommendation:** Balance spawn counts across teams. + +### Playtester Results + +#### shooter_weapon_equip — PASS +- StarterPack: 3 Tools found (Rifle, Pistol, Knife) +- All weapons have Handle parts +- Config values: Rifle (Damage=25, Ammo=30), Pistol (Damage=15, Ammo=12) + +#### shooter_respawn_cycle — PASS +- CharacterAutoLoads: true +- RespawnTime: 5 seconds +- SpawnLocations: 8 (Red: 5, Blue: 2, Neutral: 1) +- CharacterAdded handlers: 3 found + +--- + +*This is a sample report. Actual output depends on your project's structure and configuration.* +*All findings are heuristic — they flag review candidates, not definitive issues.* diff --git a/src/tools/building/lighting-preset.ts b/src/tools/building/lighting-preset.ts index 8dd3d74..f57b7d0 100644 --- a/src/tools/building/lighting-preset.ts +++ b/src/tools/building/lighting-preset.ts @@ -166,8 +166,9 @@ const lightingResultSchema = z.object({ function filterTechnology(config: PresetConfig): PresetConfig { if (config["lighting"]) { - const { Technology: _, ...safe } = config["lighting"]; - return { ...config, lighting: safe }; + const filtered = { ...config["lighting"] }; + delete filtered["Technology"]; + return { ...config, lighting: filtered }; } return config; } @@ -180,17 +181,13 @@ registerTool({ const client = new StudioBridgeClient({ port: input.studio_port }); let config: PresetConfig; - let presetName: string; if (input.preset && input.preset !== "custom" && PRESETS[input.preset]) { config = filterTechnology({ ...PRESETS[input.preset] }); - presetName = input.preset; } else if (input.custom_config) { config = filterTechnology({ ...input.custom_config } as PresetConfig); - presetName = input.preset ?? "custom"; } else { config = {}; - presetName = input.preset ?? "custom"; } const result = await client.applyLighting(undefined, config); diff --git a/src/tools/playtester/playtester.ts b/src/tools/playtester/playtester.ts index 0f34c61..f92e76b 100644 --- a/src/tools/playtester/playtester.ts +++ b/src/tools/playtester/playtester.ts @@ -32,7 +32,16 @@ const schema = z.object({ studio_port: z.number().int().positive().default(33796), action: z.enum(["run_scenario", "list_scenarios", "get_result"]), scenario: playtestScenarioSchema.optional(), - scenario_preset: z.enum(["spawn_flow", "shop_flow", "tutorial_flow", "mobile_ux"]).optional(), + scenario_preset: z + .enum([ + "spawn_flow", + "shop_flow", + "tutorial_flow", + "mobile_ux", + "shooter_weapon_equip", + "shooter_respawn_cycle", + ]) + .optional(), result_id: z.string().min(1).optional(), }); @@ -179,6 +188,70 @@ function scenarioPresets(): Record { ], timeout_seconds: 45, }, + shooter_weapon_equip: { + name: "shooter_weapon_equip", + description: "Verify weapon tools exist with proper configuration for a shooter game.", + steps: [ + { + type: "execute_code", + description: "Check StarterPack for Tool instances", + code: "local starterPack = game:FindFirstChild('StarterPack'); local names = {}; local count = 0; if starterPack then for _, child in ipairs(starterPack:GetChildren()) do if child:IsA('Tool') then count += 1; table.insert(names, child.Name) end end end return { ok = count > 0, count = count, names = names }", + }, + { + type: "execute_code", + description: "Verify weapons have Handle parts", + code: "local starterPack = game:FindFirstChild('StarterPack'); local missing = {}; if starterPack then for _, child in ipairs(starterPack:GetChildren()) do if child:IsA('Tool') and child:FindFirstChild('Handle') == nil then table.insert(missing, child.Name) end end end return { ok = #missing == 0, missing_handles = missing }", + }, + { + type: "execute_code", + description: "Check for weapon config values", + code: "local starterPack = game:FindFirstChild('StarterPack'); local weaponConfigs = {}; if starterPack then for _, child in ipairs(starterPack:GetChildren()) do if child:IsA('Tool') then local config = { name = child.Name, Damage = false, Ammo = false, FireRate = false }; for _, descendant in ipairs(child:GetDescendants()) do if descendant:IsA('NumberValue') or descendant:IsA('IntValue') then local lowered = string.lower(descendant.Name); if lowered == 'damage' then config.Damage = true end; if lowered == 'ammo' then config.Ammo = true end; if lowered == 'firerate' or lowered == 'fire_rate' then config.FireRate = true end end end; if config.Damage or config.Ammo or config.FireRate then table.insert(weaponConfigs, config) end end end end return { ok = #weaponConfigs > 0, weapon_configs = weaponConfigs }", + }, + { + type: "verify_state", + description: "Confirm at least one configured weapon exists", + expected: '"ok":true', + }, + { + type: "note", + description: "Record weapon equip note", + note: "Weapon equip flow verified", + }, + ], + timeout_seconds: 30, + }, + shooter_respawn_cycle: { + name: "shooter_respawn_cycle", + description: "Validate respawn infrastructure for a shooter game.", + steps: [ + { + type: "execute_code", + description: "Check Players.CharacterAutoLoads and RespawnTime", + code: "local players = game:GetService('Players'); return { auto_loads = players.CharacterAutoLoads, respawn_time = players.RespawnTime }", + }, + { + type: "execute_code", + description: "Check SpawnLocation count and team assignment", + code: "local count = 0; local teams = {}; for _, descendant in ipairs(game:GetDescendants()) do if descendant:IsA('SpawnLocation') then count += 1; local key = descendant.Neutral and 'Neutral' or tostring(descendant.TeamColor); teams[key] = (teams[key] or 0) + 1 end end return { ok = count > 0, count = count, teams = teams }", + }, + { + type: "execute_code", + description: "Search for CharacterAdded handlers in scripts", + code: "local handlers = 0; for _, descendant in ipairs(game:GetDescendants()) do if descendant:IsA('LuaSourceContainer') and string.find(string.lower(descendant.Source), 'characteradded') then handlers += 1 end end return { ok = handlers > 0, handlers_found = handlers }", + }, + { + type: "verify_state", + description: "Confirm spawn infrastructure exists", + expected: '"ok":true', + }, + { + type: "note", + description: "Record respawn cycle note", + note: "Respawn cycle infrastructure verified", + }, + ], + timeout_seconds: 30, + }, }; } diff --git a/src/tools/register-all.ts b/src/tools/register-all.ts index fee54c9..989dc9a 100644 --- a/src/tools/register-all.ts +++ b/src/tools/register-all.ts @@ -41,3 +41,4 @@ import "./building/terrain-generate.js"; import "./building/ui-builder.js"; import "./shipcheck/validate-mobile-ui.js"; import "./shipcheck/shipcheck-report.js"; +import "./shipcheck/genre/shooter/index.js"; diff --git a/src/tools/shipcheck/genre/shooter/combat-content-maturity.ts b/src/tools/shipcheck/genre/shooter/combat-content-maturity.ts new file mode 100644 index 0000000..0051a20 --- /dev/null +++ b/src/tools/shipcheck/genre/shooter/combat-content-maturity.ts @@ -0,0 +1,197 @@ +import { z } from "zod"; +import { StudioBridgeClient } from "../../../../roblox/studio-bridge-client.js"; +import { createResponseEnvelope, sourceInfo, traverseInstances } from "../../../../shared.js"; +import type { InstanceNode, RobloxPropertyValue } from "../../../../types/roblox.js"; +import type { ResponseEnvelope } from "../../../../types/tools.js"; +import { registerTool } from "../../../registry.js"; + +const categoryKeys = [ + "violence_explicit", + "violence_moderate", + "weapon_refs", + "social_risk", +] as const; + +type CategoryKey = (typeof categoryKeys)[number]; +type CustomKeywords = { + [Key in CategoryKey]?: string[] | undefined; +}; + +const schema = z.object({ + studio_port: z.number().int().positive().default(33796), + custom_keywords: z + .object({ + violence_explicit: z.array(z.string()).optional(), + violence_moderate: z.array(z.string()).optional(), + weapon_refs: z.array(z.string()).optional(), + social_risk: z.array(z.string()).optional(), + } satisfies z.ZodRawShape) + .optional(), +}); + +interface CombatContentMaturityIssue { + severity: "warning" | "info"; + category: CategoryKey; + rule: "combat_content_match"; + message: string; + element_path: string; + confidence: "manual_review"; +} + +interface CombatContentMaturityResult { + score: number; + scripts_scanned: number; + ui_elements_scanned: number; + findings_by_category: Record; + issues: CombatContentMaturityIssue[]; +} + +const defaultCategories: Record = { + violence_explicit: ["gore", "dismember", "decapitat", "mutilat", "torture", "execution"], + violence_moderate: ["blood", "bleed", "corpse", "dead body", "body bag"], + weapon_refs: [ + "assault rifle", + "ak-47", + "ak47", + "m16", + "shotgun", + "sniper rifle", + "grenade launcher", + "rpg", + "rocket launcher", + ], + social_risk: ["discord.gg", "discord.com/invite", "youtube.com", "twitter.com", "tiktok.com"], +}; + +function buildCategories(customKeywords?: CustomKeywords): Record { + return Object.fromEntries( + categoryKeys.map((category) => [ + category, + [...defaultCategories[category], ...(customKeywords?.[category] ?? [])], + ]), + ) as Record; +} + +function renderValue(value: RobloxPropertyValue | undefined): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return ""; +} + +function textForUiNode(node: InstanceNode): string { + return renderValue(node.properties?.["Text"]); +} + +function collectScriptPaths(root: InstanceNode): string[] { + const scriptPaths: string[] = []; + traverseInstances(root, (node, currentPath) => { + if ( + node.className === "Script" || + node.className === "LocalScript" || + node.className === "ModuleScript" + ) { + scriptPaths.push(currentPath); + } + }); + return scriptPaths; +} + +function pushIssue( + issues: CombatContentMaturityIssue[], + findingsByCategory: CombatContentMaturityResult["findings_by_category"], + category: CategoryKey, + elementPath: string, + match: string, +): void { + findingsByCategory[category] += 1; + issues.push({ + severity: category === "violence_explicit" || category === "social_risk" ? "warning" : "info", + category, + rule: "combat_content_match", + message: `${elementPath} contains "${match}" which may affect shooter content maturity review.`, + element_path: elementPath, + confidence: "manual_review", + }); +} + +function scanText( + content: string, + elementPath: string, + categories: Record, + issues: CombatContentMaturityIssue[], + findingsByCategory: CombatContentMaturityResult["findings_by_category"], +): void { + const normalized = content.toLowerCase(); + for (const [category, keywords] of Object.entries(categories) as Array<[CategoryKey, string[]]>) { + for (const keyword of keywords) { + if (normalized.includes(keyword.toLowerCase())) { + pushIssue(issues, findingsByCategory, category, elementPath, keyword); + } + } + } +} + +export async function runCombatContentMaturity( + input: z.infer, +): Promise> { + const client = new StudioBridgeClient({ port: input.studio_port }); + await client.ping(); + const categories = buildCategories(input.custom_keywords); + + const root = (await client.getChildren("game", 10)) as InstanceNode; + const scriptPaths = collectScriptPaths(root); + const issues: CombatContentMaturityIssue[] = []; + const findingsByCategory: CombatContentMaturityResult["findings_by_category"] = { + violence_explicit: 0, + violence_moderate: 0, + weapon_refs: 0, + social_risk: 0, + }; + + for (const scriptPath of scriptPaths) { + try { + const script = await client.getScriptSource(scriptPath); + scanText(script.source, scriptPath, categories, issues, findingsByCategory); + } catch { + continue; + } + } + + let uiElementsScanned = 0; + traverseInstances(root, (node, currentPath) => { + if (node.className !== "TextLabel" && node.className !== "TextButton") { + return; + } + uiElementsScanned += 1; + const text = textForUiNode(node); + if (text.trim().length === 0) { + return; + } + scanText(text, currentPath, categories, issues, findingsByCategory); + }); + + return createResponseEnvelope( + { + score: Math.max(0, 100 - issues.length * 15), + scripts_scanned: scriptPaths.length, + ui_elements_scanned: uiElementsScanned, + findings_by_category: findingsByCategory, + issues, + }, + { + source: sourceInfo({ studio_port: input.studio_port }), + }, + ); +} + +registerTool({ + name: "rbx_shooter_combat_content_maturity", + description: + "Scan scripts and UI for combat-specific content that may affect age rating or content maturity classification.", + schema, + handler: runCombatContentMaturity, +}); diff --git a/src/tools/shipcheck/genre/shooter/index.ts b/src/tools/shipcheck/genre/shooter/index.ts new file mode 100644 index 0000000..a0be443 --- /dev/null +++ b/src/tools/shipcheck/genre/shooter/index.ts @@ -0,0 +1,3 @@ +import "./weapon-remote-trust.js"; +import "./spawn-clustering.js"; +import "./combat-content-maturity.js"; diff --git a/src/tools/shipcheck/genre/shooter/spawn-clustering.ts b/src/tools/shipcheck/genre/shooter/spawn-clustering.ts new file mode 100644 index 0000000..2f34d88 --- /dev/null +++ b/src/tools/shipcheck/genre/shooter/spawn-clustering.ts @@ -0,0 +1,206 @@ +import { z } from "zod"; +import { StudioBridgeClient } from "../../../../roblox/studio-bridge-client.js"; +import { createResponseEnvelope, sourceInfo } from "../../../../shared.js"; +import type { RobloxPropertyValue, StudioSearchMatch } from "../../../../types/roblox.js"; +import type { ResponseEnvelope } from "../../../../types/tools.js"; +import { registerTool } from "../../../registry.js"; + +const schema = z.object({ + studio_port: z.number().int().positive().default(33796), + min_spread_studs: z.number().min(1).default(30), + check_team_balance: z.boolean().default(true), + min_height: z.number().default(-10), + max_height: z.number().default(1000), +}); + +interface SpawnClusteringIssue { + severity: "warning" | "info"; + rule: "spawn_clustering" | "team_spawn_imbalance" | "suspicious_spawn_height"; + message: string; + spawn_path?: string; + confidence?: "heuristic"; +} + +interface SpawnClusteringResult { + score: number; + spawn_count: number; + team_distribution: Record; + avg_spread_studs: number; + issues: SpawnClusteringIssue[]; +} + +interface Vector3Like { + x: number; + y: number; + z: number; +} + +const searchMatchSchema = z.array( + z.object({ + path: z.string(), + className: z.string(), + snippet: z.string(), + matchType: z.enum(["name", "class", "property", "script_content"]), + }), +); + +function parseMatches(raw: unknown): StudioSearchMatch[] { + const parsed = searchMatchSchema.safeParse(raw); + if (!parsed.success) { + throw new Error(`Failed to parse spawn search results: ${parsed.error.message}`); + } + return parsed.data; +} + +function getVector3(value: RobloxPropertyValue | undefined): Vector3Like | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const x = typeof value["x"] === "number" ? value["x"] : null; + const y = typeof value["y"] === "number" ? value["y"] : null; + const z = typeof value["z"] === "number" ? value["z"] : null; + if (x === null || y === null || z === null) { + return null; + } + return { x, y, z }; +} + +function stringifyProperty(value: RobloxPropertyValue | undefined): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (value && typeof value === "object") { + return JSON.stringify(value); + } + return "Unknown"; +} + +function distance(a: Vector3Like, b: Vector3Like): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + const dz = a.z - b.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} + +export async function runSpawnClustering( + input: z.infer, +): Promise> { + const client = new StudioBridgeClient({ port: input.studio_port }); + await client.ping(); + + const matches = parseMatches( + await client.searchInstances({ + query: "SpawnLocation", + search_type: "class", + case_sensitive: false, + max_results: 200, + }), + ); + + const issues: SpawnClusteringIssue[] = []; + const spawns: Array<{ + path: string; + name: string; + position: Vector3Like | null; + teamColor: string; + neutral: boolean; + }> = []; + + for (const match of matches) { + try { + const properties = await client.getProperties(match.path); + const position = getVector3(properties["Position"]); + const teamColor = stringifyProperty(properties["TeamColor"]); + const neutral = typeof properties["Neutral"] === "boolean" ? properties["Neutral"] : false; + const name = typeof properties["Name"] === "string" ? properties["Name"] : match.path; + spawns.push({ path: match.path, name, position, teamColor, neutral }); + + if (position && (position.y < input.min_height || position.y > input.max_height)) { + issues.push({ + severity: "info", + rule: "suspicious_spawn_height", + message: `${match.path} is placed at a suspicious height (${position.y}).`, + spawn_path: match.path, + }); + } + } catch { + continue; + } + } + + const positionedSpawns = spawns.filter((spawn) => spawn.position !== null); + let totalDistance = 0; + let pairCount = 0; + for (let index = 0; index < positionedSpawns.length; index += 1) { + const current = positionedSpawns[index]?.position; + if (!current) { + continue; + } + for (let otherIndex = index + 1; otherIndex < positionedSpawns.length; otherIndex += 1) { + const other = positionedSpawns[otherIndex]?.position; + if (!other) { + continue; + } + totalDistance += distance(current, other); + pairCount += 1; + } + } + const avgSpread = pairCount === 0 ? 0 : Number((totalDistance / pairCount).toFixed(2)); + + if (pairCount > 0 && avgSpread < input.min_spread_studs) { + issues.push({ + severity: "warning", + rule: "spawn_clustering", + message: `Average spawn spread is ${avgSpread} studs, below the ${input.min_spread_studs} stud threshold.`, + confidence: "heuristic", + }); + } + + const teamDistribution: Record = {}; + for (const spawn of spawns) { + const key = spawn.neutral ? "Neutral" : spawn.teamColor; + teamDistribution[key] = (teamDistribution[key] ?? 0) + 1; + } + + if (input.check_team_balance) { + const nonNeutralCounts = Object.entries(teamDistribution) + .filter(([team]) => team !== "Neutral") + .map(([, count]) => count) + .filter((count) => count > 0); + if (nonNeutralCounts.length >= 2) { + const minCount = Math.min(...nonNeutralCounts); + const maxCount = Math.max(...nonNeutralCounts); + if (minCount > 0 && maxCount / minCount > 2) { + issues.push({ + severity: "warning", + rule: "team_spawn_imbalance", + message: "Team-based spawn counts differ by more than 2x across teams.", + }); + } + } + } + + return createResponseEnvelope( + { + score: Math.max(0, 100 - issues.length * 15), + spawn_count: spawns.length, + team_distribution: teamDistribution, + avg_spread_studs: avgSpread, + issues, + }, + { + source: sourceInfo({ studio_port: input.studio_port }), + }, + ); +} + +registerTool({ + name: "rbx_shooter_spawn_clustering", + description: + "Analyze SpawnLocation distribution for clustering, team balance, and placement issues in shooter games.", + schema, + handler: runSpawnClustering, +}); diff --git a/src/tools/shipcheck/genre/shooter/weapon-remote-trust.ts b/src/tools/shipcheck/genre/shooter/weapon-remote-trust.ts new file mode 100644 index 0000000..fa547c0 --- /dev/null +++ b/src/tools/shipcheck/genre/shooter/weapon-remote-trust.ts @@ -0,0 +1,177 @@ +import { z } from "zod"; +import { StudioBridgeClient } from "../../../../roblox/studio-bridge-client.js"; +import { createResponseEnvelope, sourceInfo } from "../../../../shared.js"; +import type { StudioSearchMatch } from "../../../../types/roblox.js"; +import type { ResponseEnvelope } from "../../../../types/tools.js"; +import { registerTool } from "../../../registry.js"; + +const schema = z.object({ + studio_port: z.number().int().positive().default(33796), + check_rate_limiting: z.boolean().default(true), + check_type_validation: z.boolean().default(true), + server_script_root: z.string().min(1).default("ServerScriptService"), +}); + +interface WeaponRemoteTrustIssue { + severity: "low" | "medium"; + remote_path: string; + rule: "no_server_handler" | "missing_type_validation" | "no_rate_limiting"; + message: string; + suggestion: string; + script_path?: string; +} + +interface WeaponRemoteTrustResult { + score: number; + weapon_remotes_found: number; + issues: WeaponRemoteTrustIssue[]; +} + +const searchMatchSchema = z.array( + z.object({ + path: z.string(), + className: z.string(), + snippet: z.string(), + matchType: z.enum(["name", "class", "property", "script_content"]), + }), +); + +const weaponRemotePattern = + /\b(fire|shoot|damage|hit|reload|equip|weapon|gun|bullet|projectile)\b/iu; +const typeValidationPattern = + /(typeof\s*\(|type\s*\(|tonumber\s*\(|tostring\s*\(|assert\s*\(|~=\s*"number"|~=\s*"string")/iu; +const rateLimitingPattern = + /(tick\s*\(|os\.clock\s*\(|throttle|cooldown|debounce|lastFire|last_fire)/iu; + +function parseMatches(raw: unknown): StudioSearchMatch[] { + return searchMatchSchema.safeParse(raw).data ?? []; +} + +function uniqueByPath(matches: StudioSearchMatch[]): StudioSearchMatch[] { + const seen = new Set(); + const deduped: StudioSearchMatch[] = []; + for (const match of matches) { + if (seen.has(match.path)) { + continue; + } + seen.add(match.path); + deduped.push(match); + } + return deduped; +} + +export async function runWeaponRemoteTrust( + input: z.infer, +): Promise> { + const client = new StudioBridgeClient({ port: input.studio_port }); + await client.ping(); + const serverScriptRoot = input.server_script_root.startsWith("game.") + ? input.server_script_root + : `game.${input.server_script_root}`; + + const remoteEventMatches = parseMatches( + await client.searchInstances({ + query: "RemoteEvent", + search_type: "class", + case_sensitive: false, + max_results: 200, + }), + ); + const remoteFunctionMatches = parseMatches( + await client.searchInstances({ + query: "RemoteFunction", + search_type: "class", + case_sensitive: false, + max_results: 200, + }), + ); + + const weaponRemotes = uniqueByPath([...remoteEventMatches, ...remoteFunctionMatches]).filter( + (match) => { + const remoteName = match.path.split(".").pop() ?? match.path; + return weaponRemotePattern.test(remoteName); + }, + ); + const issues: WeaponRemoteTrustIssue[] = []; + + for (const remote of weaponRemotes) { + const remoteName = remote.path.split(".").pop() ?? remote.path; + const handlers = parseMatches( + await client.searchInstances({ + query: remoteName, + search_type: "script_content", + case_sensitive: false, + max_results: 50, + root_path: serverScriptRoot, + }), + ); + + if (handlers.length === 0) { + issues.push({ + severity: "medium", + remote_path: remote.path, + rule: "no_server_handler", + message: `No ${input.server_script_root} handler referencing ${remoteName} was found.`, + suggestion: + "Add a server-side remote handler and validate weapon actions before applying them.", + }); + continue; + } + + let combinedSource = ""; + let firstScriptPath: string | undefined; + for (const handler of uniqueByPath(handlers)) { + if (!firstScriptPath) { + firstScriptPath = handler.path; + } + try { + const script = await client.getScriptSource(handler.path); + combinedSource += `\n${script.source}`; + } catch { + continue; + } + } + + if (input.check_type_validation && !typeValidationPattern.test(combinedSource)) { + issues.push({ + severity: "medium", + remote_path: remote.path, + rule: "missing_type_validation", + message: `Handler scripts for ${remoteName} do not show clear argument type validation.`, + suggestion: + "Validate remote payload types with typeof, type, tonumber, or assert before use.", + ...(firstScriptPath ? { script_path: firstScriptPath } : {}), + }); + } + + if (input.check_rate_limiting && !rateLimitingPattern.test(combinedSource)) { + issues.push({ + severity: "low", + remote_path: remote.path, + rule: "no_rate_limiting", + message: `Handler scripts for ${remoteName} do not show a throttle, cooldown, or debounce.`, + suggestion: "Add per-player fire-rate limiting to prevent remote spam.", + ...(firstScriptPath ? { script_path: firstScriptPath } : {}), + }); + } + } + + return createResponseEnvelope( + { + score: Math.max(0, 100 - issues.length * 15), + weapon_remotes_found: weaponRemotes.length, + issues, + }, + { + source: sourceInfo({ studio_port: input.studio_port }), + }, + ); +} + +registerTool({ + name: "rbx_shooter_weapon_remote_trust", + description: + "Audit weapon-related RemoteEvents for server-side validation and rate limiting in shooter games.", + schema, + handler: runWeaponRemoteTrust, +}); diff --git a/src/tools/shipcheck/shipcheck-report.ts b/src/tools/shipcheck/shipcheck-report.ts index a3fba68..7cafada 100644 --- a/src/tools/shipcheck/shipcheck-report.ts +++ b/src/tools/shipcheck/shipcheck-report.ts @@ -508,10 +508,17 @@ registerTool({ const client = new StudioBridgeClient({ port: input.studio_port }); await client.ping(); const rawRoot = await client.getDataModel(); - const excludedServices = new Set(["CoreGui", "CorePackages", "PluginGuiService", "PluginDebugService"]); + const excludedServices = new Set([ + "CoreGui", + "CorePackages", + "PluginGuiService", + "PluginDebugService", + ]); const root: typeof rawRoot = { ...rawRoot, - children: rawRoot.children.filter((c) => !excludedServices.has(c.name) && !excludedServices.has(c.className)), + children: rawRoot.children.filter( + (c) => !excludedServices.has(c.name) && !excludedServices.has(c.className), + ), }; const openCloudClient =