@tinyburg/pocket-trains-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Pocket Trains (game code `pt`) NBSync api endpoints.
3
+ *
4
+ * Signing: a random `salt` (signed int32) goes in the path, and `hash` is
5
+ * `md5(<preimage> + <game secret salt>)` with the preimage
6
+ * `pt/<playerId>/<salt><playerAuthKey>` (variants noted per endpoint). The
7
+ * secret salt is the value extracted from `NBSync._ss`.
8
+ *
9
+ * Verification (live server + Frida DTO inspection of the game, 2026-08-26):
10
+ * - Signing is MD5, server-validated (same scheme as Tiny Tower / Pocket
11
+ * Planes; the game computes SHA-256 only for the unauthenticated `register`).
12
+ * - Response envelopes VERIFIED by reading the game's DTO classes at runtime:
13
+ * register, player_details, sync/pull (NBSyncGameSaveResponse), current_version,
14
+ * verify_device, friend/pull_game, friend/pull_meta (meta is a flat
15
+ * Dictionary<String,Object> on pt), send_item (NBSyncItemsSendResponse),
16
+ * get_items (NBSyncItemsGetResponse: success/gifts, NO `total` unlike pp).
17
+ * `get_items` was also exercised live with a burnbot.
18
+ * - pt-specific gifting: `get_gifts_pt` -> NBSyncGetPTGiftResponse with
19
+ * NBSyncGetPTGift elements, plus a gift-id flow (NBSyncGetGiftIDResponse,
20
+ * `friend/pull_meta_gift_id`). The get_gifts_pt REQUEST params are UNCONFIRMED
21
+ * (the tried `/get_gifts_pt/pt/:playerId/:salt/:hash` form was rejected), so
22
+ * that endpoint is marked accordingly.
23
+ *
24
+ * Game-specific save/meta schemas are left as `Schema.Unknown` placeholders.
25
+ *
26
+ * @since 1.0.0
27
+ * @category Endpoints
28
+ */
29
+
30
+ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/http-api";
31
+ import * as Schema from "effect/Schema";
32
+
33
+ import * as NimblebitConfig from "@tinyburg/nimblebit-sdk/NimblebitConfig";
34
+ import * as OAuthScope from "@yielded/auth/OAuthScope";
35
+
36
+ import * as Scopes from "./Scopes.ts";
37
+
38
+ /** @internal */
39
+ export const U32 = Schema.Int.check(Schema.isUint32());
40
+
41
+ /** @internal */
42
+ export const ApiErrors = [
43
+ HttpApiError.Forbidden,
44
+ HttpApiError.BadRequest,
45
+ HttpApiError.Unauthorized,
46
+ HttpApiError.InternalServerError,
47
+ ] as const;
48
+
49
+ /** @internal */
50
+ export const ErrorResponse = Schema.Struct({ error: Schema.String });
51
+
52
+ /** @internal */
53
+ export const NotFoundResponse = Schema.Struct({ success: Schema.Literal("NotFound") });
54
+
55
+ /** @internal */
56
+ export const SentResponse = Schema.Struct({ success: Schema.Literal("Sent") });
57
+
58
+ /** @internal */
59
+ export const NotSentResponse = Schema.Struct({ success: Schema.Literal("NotSent") });
60
+
61
+ /**
62
+ * A gift/item in the general item channel (get_items / send_item). Verified DTO
63
+ * NBSyncItemsGift on pt: `{gift_id, gift_to, gift_from, gift_type, item_type,
64
+ * gift_str, h}` (no `c`, unlike pp). Left open for the data-schema work.
65
+ *
66
+ * @since 1.0.0
67
+ * @category Schemas
68
+ */
69
+ export const Gift = Schema.Unknown;
70
+
71
+ /**
72
+ * A Pocket Trains gift (get_gifts_pt channel). Verified DTO NBSyncGetPTGift:
73
+ * `{gift_id, gift_to, gift_from, alt_id, gift_type, gift_str: int, gift_quantity:
74
+ * int, h}`. Left open for the data-schema work.
75
+ *
76
+ * @since 1.0.0
77
+ * @category Schemas
78
+ */
79
+ export const PTGift = Schema.Unknown;
80
+
81
+ /** @internal */
82
+ export const DeviceNewPlayerEndpoint = HttpApiEndpoint.get("DeviceNewPlayer", "/register/pt/:salt1/:salt2/:hash", {
83
+ params: { salt1: U32, salt2: U32, hash: Schema.String },
84
+ error: ApiErrors,
85
+ success: [
86
+ ErrorResponse,
87
+ Schema.Struct({
88
+ playerId: NimblebitConfig.PlayerIdSchema,
89
+ playerSs: NimblebitConfig.PlayerAuthKeySchema,
90
+ }).pipe(Schema.encodeKeys({ playerId: "player_id", playerSs: "player_ss" })),
91
+ ],
92
+ });
93
+
94
+ /** @internal */
95
+ export const DevicePlayerDetailsEndpoint = HttpApiEndpoint.get(
96
+ "DevicePlayerDetails",
97
+ "/player_details/pt/:playerId/:salt/:hash",
98
+ {
99
+ params: { playerId: NimblebitConfig.PlayerIdSchema, salt: U32, hash: Schema.String },
100
+ error: ApiErrors,
101
+ success: [
102
+ ErrorResponse,
103
+ Schema.Struct({
104
+ success: Schema.Literal("Found"),
105
+ player: Schema.Struct({
106
+ playerId: NimblebitConfig.PlayerIdSchema,
107
+ playerEmail: NimblebitConfig.PlayerEmailSchema,
108
+ registered: Schema.NumberFromString.pipe(Schema.decodeTo(Schema.BooleanFromBit)),
109
+ blacklisted: Schema.NumberFromString.pipe(Schema.decodeTo(Schema.BooleanFromBit)),
110
+ saveVersion: Schema.NumberFromString.pipe(Schema.decodeTo(Schema.Int)),
111
+ checksum: Schema.String,
112
+ }).pipe(Schema.encodeKeys({ playerId: "player_id", playerEmail: "email", checksum: "h" })),
113
+ }),
114
+ ],
115
+ }
116
+ ).annotate(OAuthScope.Required, Scopes.Device.read.player_details.required);
117
+
118
+ /** @internal */
119
+ export const DeviceVerifyDeviceEndpoint = HttpApiEndpoint.get(
120
+ "DeviceVerifyDevice",
121
+ "/verify_device/pt/:playerId/:verificationCode",
122
+ {
123
+ params: { playerId: NimblebitConfig.PlayerIdSchema, verificationCode: Schema.String },
124
+ error: ApiErrors,
125
+ success: [
126
+ ErrorResponse,
127
+ Schema.Struct({
128
+ success: Schema.Literal("NewDevice"),
129
+ playerId: NimblebitConfig.PlayerIdSchema,
130
+ playerAuthKey: NimblebitConfig.PlayerAuthKeySchema,
131
+ playerEmail: NimblebitConfig.PlayerEmailSchema,
132
+ }).pipe(
133
+ Schema.encodeKeys({ playerId: "player_id", playerAuthKey: "player_ss", playerEmail: "player_email" })
134
+ ),
135
+ ],
136
+ }
137
+ ).annotate(OAuthScope.Required, Scopes.Device.write.verify_device.required);
138
+
139
+ /**
140
+ * Pull the player's cloud save. VERIFIED envelope (NBSyncGameSaveResponse). No-save
141
+ * case returns `{ error: "SaveNotFound" }`. `data` is base64 pako-deflated bytes.
142
+ *
143
+ * @internal
144
+ */
145
+ export const SyncPullSaveEndpoint = HttpApiEndpoint.get("SyncPullSave", "/sync/pull/pt/:playerId/:salt/:hash", {
146
+ params: { playerId: NimblebitConfig.PlayerIdSchema, salt: U32, hash: Schema.String },
147
+ error: ApiErrors,
148
+ success: [
149
+ ErrorResponse,
150
+ NotFoundResponse,
151
+ Schema.Struct({
152
+ success: Schema.Literal("Found"),
153
+ data: Schema.Uint8ArrayFromBase64,
154
+ checksum: Schema.String,
155
+ saveId: Schema.NumberFromString,
156
+ }).pipe(Schema.encodeKeys({ checksum: "h", saveId: "id" })),
157
+ ],
158
+ });
159
+
160
+ /**
161
+ * VERIFIED envelope (NBSyncCurrentCheckResponse); no-save returns `{ error: "NoSave" }`.
162
+ *
163
+ * @internal
164
+ */
165
+ export const SyncCheckForNewerSaveEndpoint = HttpApiEndpoint.get(
166
+ "SyncCheckForNewerSave",
167
+ "/sync/current_version/pt/:playerId/:salt/:hash",
168
+ {
169
+ params: { playerId: NimblebitConfig.PlayerIdSchema, salt: U32, hash: Schema.String },
170
+ error: ApiErrors,
171
+ success: [
172
+ ErrorResponse,
173
+ NotFoundResponse,
174
+ Schema.Struct({
175
+ success: Schema.Literal("Found"),
176
+ checksum: Schema.String,
177
+ saveId: Schema.NumberFromString,
178
+ }).pipe(Schema.encodeKeys({ checksum: "h", saveId: "id" })),
179
+ ],
180
+ }
181
+ );
182
+
183
+ /**
184
+ * VERIFIED live: `/get_items/pt/:playerId/:salt/:hash` -> `{ success:"Found",
185
+ * gifts:[...] }` (NBSyncItemsGetResponse; no `total` on pt).
186
+ *
187
+ * @internal
188
+ */
189
+ export const SocialGetItemsEndpoint = HttpApiEndpoint.get("SocialGetItems", "/get_items/pt/:playerId/:salt/:hash", {
190
+ params: { playerId: NimblebitConfig.PlayerIdSchema, salt: U32, hash: Schema.String },
191
+ error: ApiErrors,
192
+ success: [
193
+ ErrorResponse,
194
+ NotFoundResponse,
195
+ Schema.Struct({ success: Schema.Literal("Found"), gifts: Schema.Array(Gift) }),
196
+ ],
197
+ }).annotate(OAuthScope.Required, Scopes.Social.read.get_items.required);
198
+
199
+ /**
200
+ * Pocket Trains gift channel. Response VERIFIED (NBSyncGetPTGiftResponse) but the
201
+ * REQUEST params are UNCONFIRMED - the `/get_gifts_pt/pt/:playerId/:salt/:hash`
202
+ * form was rejected with "Invalid Request", so pt's gift request likely needs a
203
+ * gift-id (see NBSyncGetGiftIDResponse / `friend/pull_meta_gift_id`). Path/params
204
+ * below are a placeholder to be confirmed by capturing the live request.
205
+ *
206
+ * @internal
207
+ */
208
+ export const SocialGetGiftsPtEndpoint = HttpApiEndpoint.get(
209
+ "SocialGetGiftsPt",
210
+ "/get_gifts_pt/pt/:playerId/:salt/:hash",
211
+ {
212
+ params: { playerId: NimblebitConfig.PlayerIdSchema, salt: U32, hash: Schema.String },
213
+ error: ApiErrors,
214
+ success: [
215
+ ErrorResponse,
216
+ NotFoundResponse,
217
+ Schema.Struct({ success: Schema.Literal("Found"), gifts: Schema.Array(PTGift) }),
218
+ ],
219
+ }
220
+ ).annotate(OAuthScope.Required, Scopes.Social.read.get_gifts_pt.required);
221
+
222
+ /**
223
+ * DERIVED from Tiny Tower. Sends an item to a friend. Response VERIFIED
224
+ * (NBSyncItemsSendResponse: success/error). pt also has a `/send_pt/` variant.
225
+ *
226
+ * @internal
227
+ */
228
+ export const SocialSendItemEndpoint = HttpApiEndpoint.post(
229
+ "SocialSendItem",
230
+ "/send_item/pt/:itemType/:playerId/:friendId/:salt/:hash",
231
+ {
232
+ params: {
233
+ itemType: Schema.String,
234
+ playerId: NimblebitConfig.PlayerIdSchema,
235
+ friendId: NimblebitConfig.PlayerIdSchema,
236
+ salt: U32,
237
+ hash: Schema.String,
238
+ },
239
+ payload: Schema.Struct({ itemStr: Schema.String }),
240
+ error: ApiErrors,
241
+ success: [ErrorResponse, SentResponse, NotSentResponse],
242
+ }
243
+ ).annotate(OAuthScope.Required, Scopes.Social.write.send_item.required);
244
+
245
+ /**
246
+ * VERIFIED envelope (NBSyncFriendGameSaveResponse); preimage
247
+ * `pt/<playerId>/<friendId>/<salt><playerAuthKey>`.
248
+ *
249
+ * @internal
250
+ */
251
+ export const SocialPullFriendGameEndpoint = HttpApiEndpoint.get(
252
+ "SocialPullFriendGame",
253
+ "/friend/pull_game/pt/:playerId/:friendId/:salt/:hash",
254
+ {
255
+ params: {
256
+ playerId: NimblebitConfig.PlayerIdSchema,
257
+ friendId: NimblebitConfig.PlayerIdSchema,
258
+ salt: U32,
259
+ hash: Schema.String,
260
+ },
261
+ error: ApiErrors,
262
+ success: [
263
+ ErrorResponse,
264
+ NotFoundResponse,
265
+ Schema.Struct({
266
+ success: Schema.Literal("Found"),
267
+ data: Schema.Uint8ArrayFromBase64,
268
+ checksum: Schema.String,
269
+ saveId: Schema.NumberFromString,
270
+ playerId: NimblebitConfig.PlayerIdSchema,
271
+ }).pipe(Schema.encodeKeys({ checksum: "h", saveId: "id", playerId: "player_id" })),
272
+ ],
273
+ }
274
+ ).annotate(OAuthScope.Required, Scopes.Social.read.pull_friend_game.required);
275
+
276
+ /**
277
+ * VERIFIED envelope (NBSyncFriendGameMetaResponse; `meta` is a flat
278
+ * Dictionary<String,Object> on pt). Preimage `pt/<playerId>/<salt><friendId><playerAuthKey>`.
279
+ *
280
+ * @internal
281
+ */
282
+ export const SocialPullFriendMetaEndpoint = HttpApiEndpoint.post(
283
+ "SocialPullFriendMeta",
284
+ "/friend/pull_meta/pt/:playerId/:salt/:hash",
285
+ {
286
+ params: { playerId: NimblebitConfig.PlayerIdSchema, salt: U32, hash: Schema.String },
287
+ payload: Schema.Struct({ friends: NimblebitConfig.PlayerIdSchema }),
288
+ error: ApiErrors,
289
+ success: [
290
+ ErrorResponse,
291
+ NotFoundResponse,
292
+ Schema.Struct({
293
+ success: Schema.Literal("Found"),
294
+ meta: Schema.Record(NimblebitConfig.PlayerIdSchema, Schema.Unknown),
295
+ }),
296
+ ],
297
+ }
298
+ ).annotate(OAuthScope.Required, Scopes.Social.read.pull_friend_meta.required);
299
+
300
+ /** @internal */
301
+ export const DeviceManagementGroup = HttpApiGroup.make("DeviceManagementGroup")
302
+ .add(DeviceNewPlayerEndpoint)
303
+ .add(DevicePlayerDetailsEndpoint)
304
+ .add(DeviceVerifyDeviceEndpoint);
305
+
306
+ /** @internal */
307
+ export const SyncManagementGroup = HttpApiGroup.make("SyncManagementGroup")
308
+ .add(SyncPullSaveEndpoint)
309
+ .add(SyncCheckForNewerSaveEndpoint);
310
+
311
+ /** @internal */
312
+ export const SocialGroup = HttpApiGroup.make("SocialGroup")
313
+ .add(SocialGetItemsEndpoint)
314
+ .add(SocialGetGiftsPtEndpoint)
315
+ .add(SocialSendItemEndpoint)
316
+ .add(SocialPullFriendGameEndpoint)
317
+ .add(SocialPullFriendMetaEndpoint);
318
+
319
+ /**
320
+ * The Pocket Trains NBSync api.
321
+ *
322
+ * @since 1.0.0
323
+ * @category Api
324
+ */
325
+ export const Api = HttpApi.make("PocketTrainsApi").add(DeviceManagementGroup).add(SyncManagementGroup).add(SocialGroup);
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Pocket Trains SDK: signed calls against Nimblebit's NBSync service.
3
+ *
4
+ * Signing reuses `@tinyburg/nimblebit-sdk` (`NimblebitAuth`): the game secret
5
+ * salt (from `NBSync._ss`) is provided as the auth key, and each request is
6
+ * `md5(<preimage> + <secret salt>)` with a random uint32 `salt` in the path.
7
+ * All method preimages below are the Tiny Tower formula with the `pt` game code.
8
+ *
9
+ * The methods that were exercised live against sync.nimblebit.com are marked
10
+ * VERIFIED; save-bearing / write paths follow the same pattern but were not
11
+ * exercised. Save bytes are returned raw (base64-decoded); inflating (pako) and
12
+ * parsing into a save schema is left to the caller.
13
+ *
14
+ * @since 1.0.0
15
+ * @category SDK
16
+ */
17
+
18
+ import type * as Schema from "effect/Schema";
19
+
20
+ import * as Effect from "effect/Effect";
21
+ import * as Function from "effect/Function";
22
+ import { HttpClient, HttpClientRequest } from "effect/http";
23
+ import { HttpApiClient } from "effect/http-api";
24
+ import * as Redacted from "effect/Redacted";
25
+
26
+ import type { NimblebitConfig } from "@tinyburg/nimblebit-sdk";
27
+
28
+ import { NimblebitAuth, NimblebitError } from "@tinyburg/nimblebit-sdk";
29
+
30
+ import * as Endpoints from "./Endpoints.ts";
31
+
32
+ /**
33
+ * Requests a new player ("burnbot") from the Nimblebit servers. VERIFIED.
34
+ *
35
+ * @since 1.0.0
36
+ * @category SDK
37
+ */
38
+ export const device_newPlayer = Effect.gen(function* () {
39
+ const nimblebitAuth = yield* NimblebitAuth.NimblebitAuth;
40
+ const httpClient = yield* HttpClient.HttpClient;
41
+
42
+ const maybeAddBearerToken =
43
+ nimblebitAuth.host !== "https://sync.nimblebit.com"
44
+ ? HttpClientRequest.bearerToken(nimblebitAuth.authKey)
45
+ : Function.identity;
46
+
47
+ const endpoint = yield* HttpApiClient.endpoint(Endpoints.Api, {
48
+ baseUrl: nimblebitAuth.host,
49
+ group: "DeviceManagementGroup",
50
+ endpoint: "DeviceNewPlayer",
51
+ httpClient: HttpClient.mapRequest(httpClient, maybeAddBearerToken),
52
+ });
53
+
54
+ const salt1 = yield* nimblebitAuth.salt;
55
+ const salt2 = yield* nimblebitAuth.salt;
56
+ const hash = yield* nimblebitAuth.sign(`pt/${salt1}/${salt2}`);
57
+ const response = yield* endpoint({ params: { salt1, salt2, hash } });
58
+
59
+ if ("error" in response) {
60
+ return yield* new NimblebitError.NimblebitError({
61
+ method: "DeviceNewPlayer",
62
+ module: "DeviceManagementGroup",
63
+ cause: response.error,
64
+ });
65
+ }
66
+ return response;
67
+ });
68
+
69
+ /**
70
+ * Retrieves player details. VERIFIED.
71
+ *
72
+ * @since 1.0.0
73
+ * @category SDK
74
+ */
75
+ export const device_playerDetails = Effect.fn("device_playerDetails")(function* ({
76
+ playerAuthKey,
77
+ playerId,
78
+ }: Schema.Schema.Type<typeof NimblebitConfig.AuthenticatedPlayerSchema>) {
79
+ const nimblebitAuth = yield* NimblebitAuth.NimblebitAuth;
80
+ const httpClient = yield* HttpClient.HttpClient;
81
+
82
+ const maybeAddBearerToken =
83
+ nimblebitAuth.host !== "https://sync.nimblebit.com"
84
+ ? HttpClientRequest.bearerToken(nimblebitAuth.authKey)
85
+ : Function.identity;
86
+
87
+ const endpoint = yield* HttpApiClient.endpoint(Endpoints.Api, {
88
+ baseUrl: nimblebitAuth.host,
89
+ group: "DeviceManagementGroup",
90
+ endpoint: "DevicePlayerDetails",
91
+ httpClient: HttpClient.mapRequest(httpClient, maybeAddBearerToken),
92
+ });
93
+
94
+ const salt = yield* nimblebitAuth.salt;
95
+ const hash = yield* nimblebitAuth.sign(`pt/${playerId}/${salt}${Redacted.value(playerAuthKey)}`);
96
+ const response = yield* endpoint({ params: { playerId, salt, hash } });
97
+
98
+ if ("error" in response) {
99
+ return yield* new NimblebitError.NimblebitError({
100
+ method: "DevicePlayerDetails",
101
+ module: "DeviceManagementGroup",
102
+ cause: response.error,
103
+ });
104
+ }
105
+ return response.player;
106
+ });
107
+
108
+ /**
109
+ * Pulls the player's cloud save. Returns raw base64-decoded bytes (inflate +
110
+ * parse separately). Success envelope DERIVED from Tiny Tower.
111
+ *
112
+ * @since 1.0.0
113
+ * @category SDK
114
+ */
115
+ export const sync_pullSave = Effect.fn("sync_pullSave")(function* ({
116
+ playerAuthKey,
117
+ playerId,
118
+ }: Schema.Schema.Type<typeof NimblebitConfig.AuthenticatedPlayerSchema>) {
119
+ const nimblebitAuth = yield* NimblebitAuth.NimblebitAuth;
120
+ const httpClient = yield* HttpClient.HttpClient;
121
+
122
+ const maybeAddBearerToken =
123
+ nimblebitAuth.host !== "https://sync.nimblebit.com"
124
+ ? HttpClientRequest.bearerToken(nimblebitAuth.authKey)
125
+ : Function.identity;
126
+
127
+ const endpoint = yield* HttpApiClient.endpoint(Endpoints.Api, {
128
+ baseUrl: nimblebitAuth.host,
129
+ group: "SyncManagementGroup",
130
+ endpoint: "SyncPullSave",
131
+ httpClient: HttpClient.mapRequest(httpClient, maybeAddBearerToken),
132
+ });
133
+
134
+ const salt = yield* nimblebitAuth.salt;
135
+ const hash = yield* nimblebitAuth.sign(`pt/${playerId}/${salt}${Redacted.value(playerAuthKey)}`);
136
+ const response = yield* endpoint({ params: { playerId, salt, hash } });
137
+
138
+ if ("error" in response) {
139
+ return yield* new NimblebitError.NimblebitError({
140
+ method: "SyncPullSave",
141
+ module: "SyncManagementGroup",
142
+ cause: response.error,
143
+ });
144
+ }
145
+ return response;
146
+ });
147
+
148
+ /**
149
+ * Lists items (parts/gifts) waiting for the player. VERIFIED
150
+ * (`/get_items/pt/...` -> `{ success:"Found", gifts:[...] }`, no total).
151
+ *
152
+ * @since 1.0.0
153
+ * @category SDK
154
+ */
155
+ export const social_getItems = Effect.fn("social_getItems")(function* ({
156
+ playerAuthKey,
157
+ playerId,
158
+ }: Schema.Schema.Type<typeof NimblebitConfig.AuthenticatedPlayerSchema>) {
159
+ const nimblebitAuth = yield* NimblebitAuth.NimblebitAuth;
160
+ const httpClient = yield* HttpClient.HttpClient;
161
+
162
+ const maybeAddBearerToken =
163
+ nimblebitAuth.host !== "https://sync.nimblebit.com"
164
+ ? HttpClientRequest.bearerToken(nimblebitAuth.authKey)
165
+ : Function.identity;
166
+
167
+ const endpoint = yield* HttpApiClient.endpoint(Endpoints.Api, {
168
+ baseUrl: nimblebitAuth.host,
169
+ group: "SocialGroup",
170
+ endpoint: "SocialGetItems",
171
+ httpClient: HttpClient.mapRequest(httpClient, maybeAddBearerToken),
172
+ });
173
+
174
+ const salt = yield* nimblebitAuth.salt;
175
+ const hash = yield* nimblebitAuth.sign(`pt/${playerId}/${salt}${Redacted.value(playerAuthKey)}`);
176
+ const response = yield* endpoint({ params: { playerId, salt, hash } });
177
+
178
+ if ("error" in response) {
179
+ return yield* new NimblebitError.NimblebitError({
180
+ method: "SocialGetItems",
181
+ module: "SocialGroup",
182
+ cause: response.error,
183
+ });
184
+ }
185
+ return response;
186
+ });
package/src/Scopes.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The scopes the Pocket Trains api is guarded by, as a game-rooted tree, built with
3
+ * `@tinyburg/nimblebit-sdk`'s `defineGame`. Only the endpoints this sdk defines
4
+ * are covered; areas without both a read and a write side are left out until
5
+ * their endpoints exist.
6
+ *
7
+ * @since 1.0.0
8
+ * @category Scopes
9
+ */
10
+
11
+ import { type Game, type ScopeDescription, defineGame } from "@tinyburg/nimblebit-sdk/NimblebitScopes";
12
+
13
+ /**
14
+ * @since 1.0.0
15
+ * @category Games
16
+ */
17
+ export const PocketTrains = defineGame({
18
+ name: "pockettrains",
19
+ description: "Pocket Trains",
20
+ read: { description: "Everything in Pocket Trains that only looks" },
21
+ write: { description: "Everything in Pocket Trains that changes a player's game" },
22
+ areas: {
23
+ device: {
24
+ description: "Player accounts and their devices",
25
+ read: {
26
+ description: "Look up player accounts",
27
+ leaves: {
28
+ player_details: "Look up a player's public profile details",
29
+ },
30
+ },
31
+ write: {
32
+ description: "Change a player's account or devices",
33
+ leaves: {
34
+ verify_device: "Complete a device verification code",
35
+ },
36
+ },
37
+ },
38
+ social: {
39
+ description: "Gifts, visits and friends",
40
+ read: {
41
+ description: "See gifts, visits and friends' games",
42
+ leaves: {
43
+ get_items: "See the items waiting for a player",
44
+ get_gifts_pt: "See the gifts waiting for a player",
45
+ pull_friend_game: "Download a friend's game data",
46
+ pull_friend_meta: "Fetch metadata about a player's friends",
47
+ },
48
+ },
49
+ write: {
50
+ description: "Send and accept gifts and items",
51
+ leaves: {
52
+ send_item: "Send items to another player",
53
+ },
54
+ },
55
+ },
56
+ },
57
+ });
58
+
59
+ /**
60
+ * The game's areas, by name, for annotating endpoints.
61
+ *
62
+ * @since 1.0.0
63
+ * @category Areas
64
+ */
65
+ export const { device: Device, social: Social } = PocketTrains.areas;
66
+
67
+ /**
68
+ * @since 1.0.0
69
+ * @category Tree
70
+ */
71
+ export const Games: ReadonlyArray<Game> = [PocketTrains];
72
+
73
+ /**
74
+ * @since 1.0.0
75
+ * @category Tree
76
+ */
77
+ export const all = (): ReadonlyArray<ScopeDescription> =>
78
+ Games.flatMap((game) => [
79
+ game,
80
+ game.read,
81
+ game.write,
82
+ ...Object.values(game.areas).flatMap((area) => [
83
+ area,
84
+ area.read,
85
+ ...area.read.children,
86
+ area.write,
87
+ ...area.write.children,
88
+ ]),
89
+ ]);
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
4
+
5
+ /**
6
+ * Pocket Trains (game code `pt`) NBSync api endpoints.
7
+ *
8
+ * Signing: a random `salt` (signed int32) goes in the path, and `hash` is
9
+ * `md5(<preimage> + <game secret salt>)` with the preimage
10
+ * `pt/<playerId>/<salt><playerAuthKey>` (variants noted per endpoint). The
11
+ * secret salt is the value extracted from `NBSync._ss`.
12
+ *
13
+ * Verification (live server + Frida DTO inspection of the game, 2026-08-26):
14
+ * - Signing is MD5, server-validated (same scheme as Tiny Tower / Pocket
15
+ * Planes; the game computes SHA-256 only for the unauthenticated `register`).
16
+ * - Response envelopes VERIFIED by reading the game's DTO classes at runtime:
17
+ * register, player_details, sync/pull (NBSyncGameSaveResponse), current_version,
18
+ * verify_device, friend/pull_game, friend/pull_meta (meta is a flat
19
+ * Dictionary<String,Object> on pt), send_item (NBSyncItemsSendResponse),
20
+ * get_items (NBSyncItemsGetResponse: success/gifts, NO `total` unlike pp).
21
+ * `get_items` was also exercised live with a burnbot.
22
+ * - pt-specific gifting: `get_gifts_pt` -> NBSyncGetPTGiftResponse with
23
+ * NBSyncGetPTGift elements, plus a gift-id flow (NBSyncGetGiftIDResponse,
24
+ * `friend/pull_meta_gift_id`). The get_gifts_pt REQUEST params are UNCONFIRMED
25
+ * (the tried `/get_gifts_pt/pt/:playerId/:salt/:hash` form was rejected), so
26
+ * that endpoint is marked accordingly.
27
+ *
28
+ * Game-specific save/meta schemas are left as `Schema.Unknown` placeholders.
29
+ *
30
+ * @since 1.0.0
31
+ * @category Endpoints
32
+ */
33
+ export * as Endpoints from "./Endpoints.ts"
34
+
35
+ /**
36
+ * Pocket Trains SDK: signed calls against Nimblebit's NBSync service.
37
+ *
38
+ * Signing reuses `@tinyburg/nimblebit-sdk` (`NimblebitAuth`): the game secret
39
+ * salt (from `NBSync._ss`) is provided as the auth key, and each request is
40
+ * `md5(<preimage> + <secret salt>)` with a random uint32 `salt` in the path.
41
+ * All method preimages below are the Tiny Tower formula with the `pt` game code.
42
+ *
43
+ * The methods that were exercised live against sync.nimblebit.com are marked
44
+ * VERIFIED; save-bearing / write paths follow the same pattern but were not
45
+ * exercised. Save bytes are returned raw (base64-decoded); inflating (pako) and
46
+ * parsing into a save schema is left to the caller.
47
+ *
48
+ * @since 1.0.0
49
+ * @category SDK
50
+ */
51
+ export * as PocketTrains from "./PocketTrains.ts"
52
+
53
+ /**
54
+ * The scopes the Pocket Trains api is guarded by, as a game-rooted tree, built with
55
+ * `@tinyburg/nimblebit-sdk`'s `defineGame`. Only the endpoints this sdk defines
56
+ * are covered; areas without both a read and a write side are left out until
57
+ * their endpoints exist.
58
+ *
59
+ * @since 1.0.0
60
+ * @category Scopes
61
+ */
62
+ export * as Scopes from "./Scopes.ts"