Skip to content

Commit 45b46ea

Browse files
feat(cheats): add Cloudflare Worker cheat proxy for GameHacking.org
- Add Scripts/cheat-proxy/worker.js — Cloudflare Workers script that scrapes GameHacking.org and returns normalised JSON cheat entries - Add Scripts/cheat-proxy/wrangler.toml — worker deployment config - Add Scripts/cheat-proxy/README.md — deployment instructions - Add PVSettings keys: useCheatProxy (Bool, default true) and cheatProxyURL (String, default empty) - Update GameHackingOrgLookup to call proxy first when configured, falling back to direct scraping on empty result or network error Part of #3072 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6e14921 commit 45b46ea

5 files changed

Lines changed: 551 additions & 2 deletions

File tree

PVLibrary/Sources/PVLibrary/Cheat/GameHackingOrgLookup.swift

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import Foundation
2323
import PVLogging
24+
import PVSettings
2425

2526
// MARK: - GameHackingOrgLookup
2627

@@ -49,6 +50,10 @@ public actor GameHackingOrgLookup {
4950
private static let maxMemoryCacheEntries = 50
5051
/// Minimum seconds between requests to be polite to the server.
5152
private static let minRequestInterval: TimeInterval = 1.0
53+
/// Compile-time default proxy URL. Can be overridden via PVSettings `cheatProxyURL`.
54+
/// Set to a non-empty string after deploying the Cloudflare Worker
55+
/// (see `Scripts/cheat-proxy/README.md`).
56+
static let defaultProxyURL: String = ""
5257

5358
// MARK: - State
5459

@@ -83,9 +88,23 @@ public actor GameHackingOrgLookup {
8388
return diskHit.entries
8489
}
8590

86-
// 3. Network fetch — never throws outward; always returns empty on failure.
91+
// 3. Network fetch — try proxy first (if enabled), then fall back to direct scraping.
8792
DLOG("GameHackingOrgLookup: fetching online for title='\(title)' slug=\(systemSlug ?? "nil")")
88-
let results = await fetchWithFallback(title: title, systemSlug: systemSlug)
93+
let results: [CheatDatabaseEntry]
94+
95+
let proxyURL = resolvedProxyURL()
96+
if !proxyURL.isEmpty, Defaults[.useCheatProxy] {
97+
let proxyResults = await fetchFromProxy(title: title, systemSlug: systemSlug, proxyBaseURL: proxyURL)
98+
if !proxyResults.isEmpty {
99+
DLOG("GameHackingOrgLookup: proxy returned \(proxyResults.count) codes for '\(title)'")
100+
results = proxyResults
101+
} else {
102+
DLOG("GameHackingOrgLookup: proxy empty, falling back to direct scrape for '\(title)'")
103+
results = await fetchWithFallback(title: title, systemSlug: systemSlug)
104+
}
105+
} else {
106+
results = await fetchWithFallback(title: title, systemSlug: systemSlug)
107+
}
89108

90109
evictMemoryCacheIfNeeded()
91110
memoryCache[key] = (Date(), results)
@@ -95,8 +114,75 @@ public actor GameHackingOrgLookup {
95114
return results
96115
}
97116

117+
// MARK: - Proxy URL Resolution
118+
119+
/// Returns the effective proxy base URL, preferring the user-configured value over the compile-time default.
120+
private func resolvedProxyURL() -> String {
121+
let stored = Defaults[.cheatProxyURL].trimmingCharacters(in: .whitespacesAndNewlines)
122+
if !stored.isEmpty { return stored }
123+
return Self.defaultProxyURL
124+
}
125+
98126
// MARK: - Fetch Logic
99127

128+
/// Fetch cheat entries from the Provenance cheat proxy worker.
129+
///
130+
/// The proxy endpoint is `GET <proxyBaseURL>/cheats?title=<title>&system=<slug>`.
131+
/// Returns an empty array when the proxy is unreachable or returns no results.
132+
private func fetchFromProxy(title: String, systemSlug: String?, proxyBaseURL: String) async -> [CheatDatabaseEntry] {
133+
var components = URLComponents(string: proxyBaseURL.hasSuffix("/")
134+
? proxyBaseURL + "cheats"
135+
: proxyBaseURL + "/cheats")
136+
var queryItems = [URLQueryItem(name: "title", value: title)]
137+
if let slug = systemSlug {
138+
queryItems.append(URLQueryItem(name: "system", value: slug))
139+
}
140+
components?.queryItems = queryItems
141+
142+
guard let url = components?.url else {
143+
WLOG("GameHackingOrgLookup: invalid proxy URL '\(proxyBaseURL)'")
144+
return []
145+
}
146+
147+
do {
148+
var request = URLRequest(url: url)
149+
request.setValue("Provenance-Emu/1.0", forHTTPHeaderField: "User-Agent")
150+
request.timeoutInterval = 10
151+
let (data, response) = try await URLSession.shared.data(for: request)
152+
guard let http = response as? HTTPURLResponse,
153+
(200..<300).contains(http.statusCode) else {
154+
WLOG("GameHackingOrgLookup: proxy non-200 for '\(title)'")
155+
return []
156+
}
157+
158+
let raw = try JSONDecoder().decode([ProxyCheatEntry].self, from: data)
159+
return raw.enumerated().map { index, entry in
160+
CheatDatabaseEntry(
161+
id: Self.idOffset + index,
162+
cheatName: entry.name,
163+
cheatCode: entry.code,
164+
cheatDescription: nil,
165+
deviceName: "GameHacking.org",
166+
deviceFormat: nil,
167+
category: entry.category ?? "General",
168+
romTitle: title,
169+
systemName: systemSlug,
170+
isOnlineResult: true
171+
)
172+
}
173+
} catch {
174+
WLOG("GameHackingOrgLookup: proxy fetch error for '\(title)': \(error)")
175+
return []
176+
}
177+
}
178+
179+
/// JSON model returned by the cheat proxy worker.
180+
private struct ProxyCheatEntry: Decodable {
181+
let name: String
182+
let code: String
183+
let category: String?
184+
}
185+
100186
/// Try fetching with system filter first; fall back to no-system search on failure.
101187
private func fetchWithFallback(title: String, systemSlug: String?) async -> [CheatDatabaseEntry] {
102188
// Strategy 1: search with system filter (if we have a slug)

PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,19 @@ public extension Defaults.Keys {
301301
static let coreLanguage = Key<CoreLanguageSetting>("coreLanguage", default: .systemLocale)
302302
}
303303

304+
// MARK: Cheats
305+
public extension Defaults.Keys {
306+
/// When `true`, the app first queries the cheat proxy endpoint for GameHacking.org
307+
/// cheats instead of scraping the site directly. Falls back to direct scraping
308+
/// if the proxy returns an empty result or is unreachable.
309+
static let useCheatProxy = Key<Bool>("useCheatProxy", default: true)
310+
311+
/// Base URL of the deployed Provenance cheat proxy worker.
312+
/// Set to empty string to disable the proxy and always use direct scraping.
313+
/// See `Scripts/cheat-proxy/README.md` for deployment instructions.
314+
static let cheatProxyURL = Key<String>("cheatProxyURL", default: "")
315+
}
316+
304317
public enum ButtonPressEffect: String, Codable, Equatable, UserDefaultsRepresentable, Defaults.Serializable, CaseIterable {
305318
case bubble = "bubble"
306319
case ring = "ring"

Scripts/cheat-proxy/README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Provenance Cheat Proxy
2+
3+
A thin Cloudflare Workers middleware that scrapes [GameHacking.org](https://gamehacking.org)
4+
and returns normalized JSON cheat entries with server-side KV caching (24 h TTL).
5+
6+
## Endpoint
7+
8+
```
9+
GET /cheats?title=<game title>&system=<system slug>
10+
```
11+
12+
**Parameters:**
13+
- `title` (required) — The game title to search for.
14+
- `system` (optional) — The GameHacking.org system slug (e.g. `n64`, `gba`, `gc`).
15+
16+
**Response:**
17+
```json
18+
[
19+
{ "name": "Infinite Lives", "code": "8107A5C02400", "category": "General" },
20+
{ "name": "Max Health", "code": "8107A5C40064", "category": "General" }
21+
]
22+
```
23+
24+
Returns an empty array `[]` when no cheats are found. HTTP 4xx/5xx are not returned
25+
for failed lookups — the caller should fall back to direct scraping on an empty result.
26+
27+
## Health check
28+
29+
```
30+
GET /health → { "status": "ok" }
31+
```
32+
33+
## Deployment (manual — requires a Cloudflare account)
34+
35+
> **Note:** Deployment is manual and requires a free [Cloudflare](https://cloudflare.com)
36+
> account and the [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/).
37+
38+
1. **Install Wrangler:**
39+
```bash
40+
npm install -g wrangler
41+
wrangler login
42+
```
43+
44+
2. **Create the KV namespace:**
45+
```bash
46+
wrangler kv:namespace create CHEAT_CACHE
47+
```
48+
Copy the `id` from the output and paste it into `wrangler.toml` replacing
49+
`REPLACE_WITH_YOUR_KV_NAMESPACE_ID`.
50+
51+
3. **Deploy:**
52+
```bash
53+
cd Scripts/cheat-proxy
54+
wrangler deploy
55+
```
56+
Wrangler will print the worker URL, e.g.:
57+
`https://provenance-cheat-proxy.<your-subdomain>.workers.dev`
58+
59+
4. **Configure the app:**
60+
Set the proxy URL in Provenance settings (Settings → Cheats → Proxy URL) or
61+
update the compile-time default in
62+
`PVLibrary/Sources/PVLibrary/Cheat/GameHackingOrgLookup.swift`:
63+
```swift
64+
static let defaultProxyURL = "https://provenance-cheat-proxy.<your-subdomain>.workers.dev"
65+
```
66+
67+
## Local development
68+
69+
```bash
70+
cd Scripts/cheat-proxy
71+
wrangler dev
72+
```
73+
74+
The worker is then available at `http://localhost:8787/cheats?title=Mario&system=n64`.
75+
76+
## Rate limits (Cloudflare free tier)
77+
78+
| Limit | Value |
79+
|------------------|-------------------|
80+
| Requests/day | 100,000 |
81+
| KV reads/day | 100,000 |
82+
| KV writes/day | 1,000 |
83+
| CPU time/request | 10 ms (bundled) |
84+
85+
The 24 h KV cache ensures that repeated lookups for the same title+system pair
86+
use only one KV write per day and hit the fast read path for all subsequent requests.

0 commit comments

Comments
 (0)