Skip to content

Commit 58a0e68

Browse files
committed
Add N64 controller pak sync and cheat pack management
1 parent 1b12b5b commit 58a0e68

31 files changed

Lines changed: 4110 additions & 227 deletions
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"strings"
9+
"testing"
10+
)
11+
12+
func TestAgentAPICheatAdaptersAndBuiltinPacks(t *testing.T) {
13+
h := newContractHarness(t)
14+
15+
adapters := h.request(http.MethodGet, "/api/cheats/adapters", nil)
16+
assertStatus(t, adapters, http.StatusOK)
17+
assertJSONContentType(t, adapters)
18+
19+
adapterBody := decodeJSONMap(t, adapters.Body)
20+
if !mustBool(t, adapterBody["success"], "success") {
21+
t.Fatalf("expected success body=%s", adapters.Body.String())
22+
}
23+
adapterItems := mustArray(t, adapterBody["adapters"], "adapters")
24+
if len(adapterItems) == 0 {
25+
t.Fatalf("expected adapters body=%s", adapters.Body.String())
26+
}
27+
foundSM64 := false
28+
for _, item := range adapterItems {
29+
adapter := mustObject(t, item, "adapter")
30+
if mustString(t, adapter["id"], "adapter.id") == "sm64-eeprom" {
31+
foundSM64 = true
32+
break
33+
}
34+
}
35+
if !foundSM64 {
36+
t.Fatalf("expected sm64-eeprom in adapter catalog body=%s", adapters.Body.String())
37+
}
38+
39+
packs := h.request(http.MethodGet, "/api/cheats/packs", nil)
40+
assertStatus(t, packs, http.StatusOK)
41+
assertJSONContentType(t, packs)
42+
43+
packBody := decodeJSONMap(t, packs.Body)
44+
if !mustBool(t, packBody["success"], "success") {
45+
t.Fatalf("expected success body=%s", packs.Body.String())
46+
}
47+
packItems := mustArray(t, packBody["packs"], "packs")
48+
if len(packItems) == 0 {
49+
t.Fatalf("expected builtin packs body=%s", packs.Body.String())
50+
}
51+
foundBuiltin := false
52+
for _, item := range packItems {
53+
pack := mustObject(t, item, "pack")
54+
manifest := mustObject(t, pack["manifest"], "pack.manifest")
55+
if mustString(t, manifest["packId"], "pack.manifest.packId") != "n64--super-mario-64" {
56+
continue
57+
}
58+
foundBuiltin = true
59+
if !mustBool(t, pack["builtin"], "pack.builtin") {
60+
t.Fatalf("expected builtin pack body=%s", packs.Body.String())
61+
}
62+
}
63+
if !foundBuiltin {
64+
t.Fatalf("expected n64--super-mario-64 builtin pack body=%s", packs.Body.String())
65+
}
66+
}
67+
68+
func TestAgentAPICheatPackCreateValidationAndLifecycle(t *testing.T) {
69+
h := newContractHarness(t)
70+
71+
unknownAdapter := postCheatPack(t, h, cheatPackCreateRequest{
72+
YAML: `
73+
schemaVersion: 1
74+
adapterId: no-such-adapter
75+
gameId: n64/super-mario-64
76+
systemSlug: n64
77+
title: Invalid Adapter
78+
sections:
79+
- id: abilities
80+
title: Abilities
81+
fields:
82+
- id: runtimeWingCap
83+
ref: haveWingCap
84+
label: Runtime Wing Cap
85+
type: boolean
86+
`,
87+
})
88+
assertStatus(t, unknownAdapter, http.StatusUnprocessableEntity)
89+
unknownAdapterBody := decodeJSONMap(t, unknownAdapter.Body)
90+
if !strings.Contains(strings.ToLower(mustString(t, unknownAdapterBody["message"], "message")), "unknown adapterid") {
91+
t.Fatalf("unexpected adapter validation message: %s", unknownAdapter.Body.String())
92+
}
93+
94+
unknownField := postCheatPack(t, h, cheatPackCreateRequest{
95+
YAML: `
96+
schemaVersion: 1
97+
adapterId: sm64-eeprom
98+
gameId: n64/super-mario-64
99+
systemSlug: n64
100+
title: Invalid Field Ref
101+
sections:
102+
- id: abilities
103+
title: Abilities
104+
fields:
105+
- id: runtimeWingCap
106+
ref: doesNotExist
107+
label: Runtime Wing Cap
108+
type: boolean
109+
`,
110+
})
111+
assertStatus(t, unknownField, http.StatusUnprocessableEntity)
112+
unknownFieldBody := decodeJSONMap(t, unknownField.Body)
113+
if !strings.Contains(strings.ToLower(mustString(t, unknownFieldBody["message"], "message")), "unknown field ref") {
114+
t.Fatalf("unexpected field validation message: %s", unknownField.Body.String())
115+
}
116+
117+
validYAML := `
118+
packId: sm64-runtime-ui
119+
schemaVersion: 1
120+
adapterId: sm64-eeprom
121+
gameId: n64/super-mario-64
122+
systemSlug: n64
123+
title: SM64 Runtime UI
124+
match:
125+
titleAliases:
126+
- Super Mario 64
127+
sections:
128+
- id: runtime-abilities
129+
title: Runtime Abilities
130+
fields:
131+
- id: runtimeWingCap
132+
ref: haveWingCap
133+
label: Runtime Wing Cap
134+
type: boolean
135+
presets:
136+
- id: runtimePreset
137+
label: Runtime Preset
138+
updates:
139+
runtimeWingCap: true
140+
`
141+
created := postCheatPack(t, h, cheatPackCreateRequest{
142+
YAML: validYAML,
143+
Source: cheatPackSourceWorker,
144+
PublishedBy: "codex-test",
145+
Notes: "runtime lifecycle test",
146+
})
147+
assertStatus(t, created, http.StatusCreated)
148+
assertJSONContentType(t, created)
149+
150+
createdBody := decodeJSONMap(t, created.Body)
151+
if !mustBool(t, createdBody["success"], "success") {
152+
t.Fatalf("expected success body=%s", created.Body.String())
153+
}
154+
createdPack := mustObject(t, createdBody["pack"], "pack")
155+
createdManifest := mustObject(t, createdPack["manifest"], "pack.manifest")
156+
if mustString(t, createdManifest["packId"], "pack.manifest.packId") != "sm64-runtime-ui" {
157+
t.Fatalf("unexpected created packId body=%s", created.Body.String())
158+
}
159+
if mustString(t, createdManifest["status"], "pack.manifest.status") != cheatPackStatusActive {
160+
t.Fatalf("expected active status body=%s", created.Body.String())
161+
}
162+
163+
packDir, err := safeJoinUnderRoot(h.app.saveStore.root, "_rsm", "cheats", "packs", "sm64-runtime-ui")
164+
if err != nil {
165+
t.Fatalf("runtime pack dir: %v", err)
166+
}
167+
if _, err := os.Stat(packDir); err != nil {
168+
t.Fatalf("expected runtime pack directory %s: %v", packDir, err)
169+
}
170+
171+
disabled := h.request(http.MethodPost, "/api/cheats/packs/sm64-runtime-ui/disable", nil)
172+
assertStatus(t, disabled, http.StatusOK)
173+
disabledBody := decodeJSONMap(t, disabled.Body)
174+
disabledPack := mustObject(t, disabledBody["pack"], "pack")
175+
disabledManifest := mustObject(t, disabledPack["manifest"], "pack.manifest")
176+
if mustString(t, disabledManifest["status"], "pack.manifest.status") != cheatPackStatusDisabled {
177+
t.Fatalf("expected disabled status body=%s", disabled.Body.String())
178+
}
179+
180+
enabled := h.request(http.MethodPost, "/api/cheats/packs/sm64-runtime-ui/enable", nil)
181+
assertStatus(t, enabled, http.StatusOK)
182+
enabledBody := decodeJSONMap(t, enabled.Body)
183+
enabledPack := mustObject(t, enabledBody["pack"], "pack")
184+
enabledManifest := mustObject(t, enabledPack["manifest"], "pack.manifest")
185+
if mustString(t, enabledManifest["status"], "pack.manifest.status") != cheatPackStatusActive {
186+
t.Fatalf("expected active status body=%s", enabled.Body.String())
187+
}
188+
189+
deleted := h.request(http.MethodDelete, "/api/cheats/packs/sm64-runtime-ui", nil)
190+
assertStatus(t, deleted, http.StatusOK)
191+
deletedBody := decodeJSONMap(t, deleted.Body)
192+
deletedPack := mustObject(t, deletedBody["pack"], "pack")
193+
deletedManifest := mustObject(t, deletedPack["manifest"], "pack.manifest")
194+
if mustString(t, deletedManifest["status"], "pack.manifest.status") != cheatPackStatusDeleted {
195+
t.Fatalf("expected deleted status body=%s", deleted.Body.String())
196+
}
197+
198+
list := h.request(http.MethodGet, "/api/cheats/packs", nil)
199+
assertStatus(t, list, http.StatusOK)
200+
listBody := decodeJSONMap(t, list.Body)
201+
packs := mustArray(t, listBody["packs"], "packs")
202+
foundDeleted := false
203+
for _, item := range packs {
204+
pack := mustObject(t, item, "pack")
205+
manifest := mustObject(t, pack["manifest"], "pack.manifest")
206+
if mustString(t, manifest["packId"], "pack.manifest.packId") != "sm64-runtime-ui" {
207+
continue
208+
}
209+
foundDeleted = true
210+
if mustString(t, manifest["status"], "pack.manifest.status") != cheatPackStatusDeleted {
211+
t.Fatalf("expected deleted pack in list body=%s", list.Body.String())
212+
}
213+
if mustBool(t, pack["supportsSaveUi"], "pack.supportsSaveUi") {
214+
t.Fatalf("deleted pack should not support save UI body=%s", list.Body.String())
215+
}
216+
}
217+
if !foundDeleted {
218+
t.Fatalf("expected deleted runtime pack in list body=%s", list.Body.String())
219+
}
220+
}
221+
222+
func TestAgentAPIBuiltinCheatPackDeleteCreatesTombstone(t *testing.T) {
223+
h := newContractHarness(t)
224+
225+
deleted := h.request(http.MethodDelete, "/api/cheats/packs/n64--super-mario-64", nil)
226+
assertStatus(t, deleted, http.StatusOK)
227+
assertJSONContentType(t, deleted)
228+
229+
body := decodeJSONMap(t, deleted.Body)
230+
if !mustBool(t, body["success"], "success") {
231+
t.Fatalf("expected success body=%s", deleted.Body.String())
232+
}
233+
pack := mustObject(t, body["pack"], "pack")
234+
manifest := mustObject(t, pack["manifest"], "pack.manifest")
235+
if mustString(t, manifest["status"], "pack.manifest.status") != cheatPackStatusDeleted {
236+
t.Fatalf("expected deleted builtin tombstone body=%s", deleted.Body.String())
237+
}
238+
if !mustBool(t, pack["builtin"], "pack.builtin") {
239+
t.Fatalf("expected builtin flag body=%s", deleted.Body.String())
240+
}
241+
242+
tombstonePath, err := safeJoinUnderRoot(h.app.saveStore.root, "_rsm", "cheats", "tombstones", "n64--super-mario-64.json")
243+
if err != nil {
244+
t.Fatalf("tombstone path: %v", err)
245+
}
246+
if _, err := os.Stat(tombstonePath); err != nil {
247+
t.Fatalf("expected tombstone file %s: %v", tombstonePath, err)
248+
}
249+
}
250+
251+
func postCheatPack(t *testing.T, h *contractHarness, req cheatPackCreateRequest) *httptest.ResponseRecorder {
252+
t.Helper()
253+
data, err := json.Marshal(req)
254+
if err != nil {
255+
t.Fatalf("marshal cheat pack request: %v", err)
256+
}
257+
return h.json(http.MethodPost, "/api/cheats/packs", strings.NewReader(string(data)))
258+
}

backend/cmd/server/agent_api_handlers.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ func mountAgentRoutes(r chi.Router, app *app) {
6161
r.Get("/helpers/auto-enroll", app.handleAuthAppPasswordsAutoStatus)
6262
r.Post("/helpers/auto-enroll", app.handleAuthAppPasswordsAutoEnable)
6363

64+
r.Get("/cheats/packs", app.handleCheatPacksList)
65+
r.Post("/cheats/packs", app.handleCheatPackCreate)
66+
r.Get("/cheats/packs/{id}", app.handleCheatPackGet)
67+
r.Delete("/cheats/packs/{id}", app.handleCheatPackDelete)
68+
r.Post("/cheats/packs/{id}/disable", app.handleCheatPackDisable)
69+
r.Post("/cheats/packs/{id}/enable", app.handleCheatPackEnable)
70+
r.Get("/cheats/adapters", app.handleCheatAdaptersList)
71+
r.Get("/cheats/adapters/{id}", app.handleCheatAdapterGet)
72+
6473
r.Get("/events", app.handleEvents)
6574
}
6675

@@ -90,6 +99,8 @@ func (a *app) handleAgentAPIIndex(w http.ResponseWriter, r *http.Request) {
9099
"romLookup": basePath + "/roms/lookup",
91100
"conflicts": basePath + "/conflicts",
92101
"autoEnroll": basePath + "/helpers/auto-enroll",
102+
"cheatPacks": basePath + "/cheats/packs",
103+
"cheatAdapters": basePath + "/cheats/adapters",
93104
"events": basePath + "/events",
94105
"bulkDownload": basePath + "/saves/download-many",
95106
"saveRescan": basePath + "/saves/rescan",

backend/cmd/server/app_state.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ type app struct {
5555
saveStore *saveStore
5656
cheats *cheatService
5757
playStationStore *playStationStore
58+
n64ControllerPakStoreRef *n64ControllerPakStore
5859
saveRecords []saveRecord
5960
enricher *gameEnricher
6061
conflicts map[string]conflictRecord
@@ -195,6 +196,14 @@ func (a *app) initSaveStore() error {
195196
a.playStationStore = psStore
196197
a.mu.Unlock()
197198

199+
n64ControllerPakStore, err := newN64ControllerPakStore(store.root)
200+
if err != nil {
201+
return err
202+
}
203+
a.mu.Lock()
204+
a.n64ControllerPakStoreRef = n64ControllerPakStore
205+
a.mu.Unlock()
206+
198207
isEmpty, err := store.isEmpty()
199208
if err != nil {
200209
return err

0 commit comments

Comments
 (0)