stovesdk.js 0.0.0-stage → 0.3.2
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.
- package/LICENSE +21 -0
- package/README.md +223 -2
- package/binding.gyp +73 -0
- package/index.d.ts +241 -0
- package/index.js +117 -0
- package/lib/sdk-dir.js +58 -0
- package/package.json +52 -4
- package/prebuilds/win32-x64/SHA256SUMS +1 -0
- package/prebuilds/win32-x64/stovesdk.node +0 -0
- package/src/stovesdk.cc +904 -0
- package/tools/check-tarball.js +60 -0
- package/tools/fetch-sdk.js +193 -0
- package/tools/sdk-dir.js +36 -0
- package/tools/sdk-manifest.json +19 -0
- package/tools/smoke.js +102 -0
- package/tools/syntax-check.sh +40 -0
package/src/stovesdk.cc
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
// stovesdk.js — Node-API bridge to the STOVE PC SDK (Windows x64).
|
|
2
|
+
//
|
|
3
|
+
// Scope: a thin, policy-free wrapper. Every exported function maps onto one SDK entry point (or
|
|
4
|
+
// the fixed Base init chain) and reports what the SDK answered. What a host application does
|
|
5
|
+
// with a failed ownership check, a restart request or an SDK error is entirely up to the caller.
|
|
6
|
+
//
|
|
7
|
+
// Call model
|
|
8
|
+
// The SDK delivers every asynchronous result through Base_RunCallback(), which the host must
|
|
9
|
+
// call periodically on the same thread that initialized the SDK (the vendor guide suggests
|
|
10
|
+
// ~16 ms). `runCallbacks()` is that pump; every asynchronous export returns a Promise that is
|
|
11
|
+
// settled from inside a later `runCallbacks()` call. Synchronous SDK calls (GetUser, GetGds,
|
|
12
|
+
// GetVersion, the UnInitialize family) return plain objects immediately.
|
|
13
|
+
//
|
|
14
|
+
// Threading model — why nothing touches N-API inside an SDK callback
|
|
15
|
+
// The SDK callbacks are plain C function pointers without a user-data slot, and the vendor
|
|
16
|
+
// documentation does not state which thread they fire on. So the callbacks only copy the
|
|
17
|
+
// result into a plain C++ queue under a mutex. `runCallbacks()` calls Base_RunCallback() and
|
|
18
|
+
// then drains that queue on the JS thread, where it is safe to advance the init chain, call
|
|
19
|
+
// the next SDK function and settle Promises. No SDK object outlives its callback.
|
|
20
|
+
//
|
|
21
|
+
// Strings
|
|
22
|
+
// The SDK speaks wchar_t (UTF-16 on Windows). JS strings cross as UTF-16 via Utf16Value();
|
|
23
|
+
// SDK strings come back through WideCharToMultiByte(CP_UTF8). 64-bit integers (member
|
|
24
|
+
// numbers, timestamps) cross as decimal strings so no precision is lost above 2^53.
|
|
25
|
+
//
|
|
26
|
+
// Credentials
|
|
27
|
+
// Environment, Game ID and Application Key are runtime arguments of initialize(); nothing is
|
|
28
|
+
// compiled in. The Application Secret is not an input of any client-side SDK call and must
|
|
29
|
+
// never ship in a client build.
|
|
30
|
+
//
|
|
31
|
+
// SDK headers are included from the user-supplied SDK (see binding.gyp); none are vendored here.
|
|
32
|
+
|
|
33
|
+
// STOVE_PCSDK_SYNTAX_CHECK lets a non-Windows compiler run `-fsyntax-only` over this file with a
|
|
34
|
+
// stub <windows.h> (see tools/syntax-check.sh). It is never defined in a real build.
|
|
35
|
+
#if !defined(_WIN32) && !defined(STOVE_PCSDK_SYNTAX_CHECK)
|
|
36
|
+
#error "stovesdk.js is Windows-only; binding.gyp skips this target on other platforms."
|
|
37
|
+
#endif
|
|
38
|
+
|
|
39
|
+
#include <windows.h>
|
|
40
|
+
|
|
41
|
+
// Legacy SAL annotation used by the SDK headers (`__out wchar_t*`). The Windows headers normally
|
|
42
|
+
// define it; keep a no-op fallback so the file parses when an SDK header is reached first.
|
|
43
|
+
#ifndef __out
|
|
44
|
+
#define __out
|
|
45
|
+
#endif
|
|
46
|
+
|
|
47
|
+
#include <napi.h>
|
|
48
|
+
|
|
49
|
+
#include <cstdint>
|
|
50
|
+
#include <deque>
|
|
51
|
+
#include <mutex>
|
|
52
|
+
#include <optional>
|
|
53
|
+
#include <string>
|
|
54
|
+
#include <utility>
|
|
55
|
+
#include <vector>
|
|
56
|
+
|
|
57
|
+
#include "BaseSDK.h"
|
|
58
|
+
#include "OwnershipSDK.h"
|
|
59
|
+
#include "GameSupportSDK.h"
|
|
60
|
+
|
|
61
|
+
#ifndef STOVE_PCSDK_SDK_VERSION
|
|
62
|
+
#define STOVE_PCSDK_SDK_VERSION "unknown"
|
|
63
|
+
#endif
|
|
64
|
+
|
|
65
|
+
namespace {
|
|
66
|
+
|
|
67
|
+
namespace Base = Stove::PCSDK::Base;
|
|
68
|
+
namespace Ownership = Stove::PCSDK::Ownership;
|
|
69
|
+
namespace GameSupport = Stove::PCSDK::GameSupport;
|
|
70
|
+
|
|
71
|
+
// Addon-level error codes (negative, so they never collide with SDK result codes, which are
|
|
72
|
+
// unsigned). Mirrored in index.d.ts / index.js as `ErrorCode`.
|
|
73
|
+
constexpr int32_t kErrNotInitialized = -1;
|
|
74
|
+
constexpr int32_t kErrAlreadyInitialized = -2;
|
|
75
|
+
constexpr int32_t kErrInProgress = -3;
|
|
76
|
+
constexpr int32_t kErrAborted = -4;
|
|
77
|
+
|
|
78
|
+
// ── String conversion ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
std::string WideToUtf8(const wchar_t* wide) {
|
|
81
|
+
if (wide == nullptr || *wide == L'\0') return std::string();
|
|
82
|
+
const int needed = WideCharToMultiByte(CP_UTF8, 0, wide, -1, nullptr, 0, nullptr, nullptr);
|
|
83
|
+
if (needed <= 1) return std::string();
|
|
84
|
+
std::string out(static_cast<size_t>(needed - 1), '\0');
|
|
85
|
+
WideCharToMultiByte(CP_UTF8, 0, wide, -1, &out[0], needed, nullptr, nullptr);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// JS → wchar_t. On MSVC wchar_t is 16-bit, so UTF-16 code units map 1:1.
|
|
90
|
+
std::wstring JsToWide(const Napi::Value& value) {
|
|
91
|
+
const std::u16string u16 = value.As<Napi::String>().Utf16Value();
|
|
92
|
+
static_assert(sizeof(wchar_t) == sizeof(char16_t), "wchar_t must be 16-bit for this bridge");
|
|
93
|
+
return std::wstring(u16.begin(), u16.end());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── SDK result snapshots (plain C++, safe to keep after the callback returns) ───────────
|
|
97
|
+
|
|
98
|
+
struct ResultSnapshot {
|
|
99
|
+
bool ok = false;
|
|
100
|
+
std::string sdkName;
|
|
101
|
+
uint32_t methodCode = 0;
|
|
102
|
+
uint32_t resultCode = 0;
|
|
103
|
+
// CallbackResult-only fields
|
|
104
|
+
std::string errorMessage;
|
|
105
|
+
int32_t externalError = 0;
|
|
106
|
+
|
|
107
|
+
static ResultSnapshot From(const Stove::PCSDK::Result& r) {
|
|
108
|
+
ResultSnapshot s;
|
|
109
|
+
s.ok = r.IsSuccessful();
|
|
110
|
+
s.sdkName = WideToUtf8(r.GetSDKName());
|
|
111
|
+
s.methodCode = r.GetMethodCode();
|
|
112
|
+
s.resultCode = r.GetResultCode();
|
|
113
|
+
return s;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static ResultSnapshot From(const Stove::PCSDK::CallbackResult& cr) {
|
|
117
|
+
ResultSnapshot s = From(cr.GetResult());
|
|
118
|
+
s.errorMessage = WideToUtf8(cr.GetErrorMessage());
|
|
119
|
+
s.externalError = cr.GetExternalError();
|
|
120
|
+
return s;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
struct OwnershipSnapshot {
|
|
125
|
+
std::string gameId;
|
|
126
|
+
uint32_t gameCode = 0; // Ownership::OwnershipGameCode
|
|
127
|
+
uint32_t ownershipCode = 0; // Ownership::OwnershipCode
|
|
128
|
+
uint64_t purchaseDate = 0;
|
|
129
|
+
uint64_t memberNumber = 0;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
struct AchievementSnapshot {
|
|
133
|
+
std::string achievementId;
|
|
134
|
+
std::string name;
|
|
135
|
+
std::string description;
|
|
136
|
+
std::string status;
|
|
137
|
+
std::string defaultImageUrl;
|
|
138
|
+
std::string achievedImageUrl;
|
|
139
|
+
int32_t value = 0;
|
|
140
|
+
// Condition (may be absent on error results)
|
|
141
|
+
bool hasCondition = false;
|
|
142
|
+
std::string conditionValueOperation;
|
|
143
|
+
std::string conditionType;
|
|
144
|
+
int32_t conditionGoalValue = 0;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
AchievementSnapshot SnapshotAchievement(const GameSupport::StovePCAchievement& a) {
|
|
148
|
+
AchievementSnapshot s;
|
|
149
|
+
s.achievementId = WideToUtf8(a.GetAchievementId());
|
|
150
|
+
s.name = WideToUtf8(a.GetName());
|
|
151
|
+
s.description = WideToUtf8(a.GetDescription());
|
|
152
|
+
s.status = WideToUtf8(a.GetStatus());
|
|
153
|
+
s.defaultImageUrl = WideToUtf8(a.GetDefaultImageUrl());
|
|
154
|
+
s.achievedImageUrl = WideToUtf8(a.GetAchievedImageUrl());
|
|
155
|
+
s.value = a.GetValue();
|
|
156
|
+
const GameSupport::StovePCAchievementCondition* cond = a.GetCondition();
|
|
157
|
+
if (cond != nullptr) {
|
|
158
|
+
s.hasCondition = true;
|
|
159
|
+
s.conditionValueOperation = WideToUtf8(cond->GetValueOperation());
|
|
160
|
+
s.conditionType = WideToUtf8(cond->GetType());
|
|
161
|
+
s.conditionGoalValue = cond->GetGoalValue();
|
|
162
|
+
}
|
|
163
|
+
return s;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// GameSupport_Stat result.
|
|
167
|
+
struct StatSnapshot {
|
|
168
|
+
std::string gameId;
|
|
169
|
+
std::string statId;
|
|
170
|
+
uint64_t memberNumber = 0;
|
|
171
|
+
uint64_t updatedAt = 0;
|
|
172
|
+
int32_t currentValue = 0;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// GameSupport_ModifyStat result. The SDK struct does not echo the stat id back.
|
|
176
|
+
struct ModifyStatSnapshot {
|
|
177
|
+
int32_t currentValue = 0;
|
|
178
|
+
bool updated = false;
|
|
179
|
+
std::string errorMessage;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// ── Event queue (SDK callback → JS thread) ─────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
enum class EventKind { RestartCheck, BaseInit, OwnershipList, Achievement, AllAchievements, Stat, ModifyStat };
|
|
185
|
+
|
|
186
|
+
struct Event {
|
|
187
|
+
EventKind kind = EventKind::RestartCheck;
|
|
188
|
+
ResultSnapshot result;
|
|
189
|
+
bool restartRequired = false; // RestartCheck
|
|
190
|
+
std::vector<OwnershipSnapshot> ownerships; // OwnershipList
|
|
191
|
+
AchievementSnapshot achievement; // Achievement
|
|
192
|
+
std::vector<AchievementSnapshot> achievements; // AllAchievements
|
|
193
|
+
StatSnapshot stat; // Stat
|
|
194
|
+
ModifyStatSnapshot modifyStat; // ModifyStat
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
std::mutex g_queueMutex;
|
|
198
|
+
std::deque<Event> g_queue;
|
|
199
|
+
|
|
200
|
+
void PushEvent(Event&& e) {
|
|
201
|
+
std::lock_guard<std::mutex> lock(g_queueMutex);
|
|
202
|
+
g_queue.push_back(std::move(e));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
Event MakeEvent(EventKind kind, ResultSnapshot result) {
|
|
206
|
+
Event e;
|
|
207
|
+
e.kind = kind;
|
|
208
|
+
e.result = std::move(result);
|
|
209
|
+
return e;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── SDK callbacks — copy only, no N-API, no SDK calls ─────────────────────────────────
|
|
213
|
+
|
|
214
|
+
void __cdecl OnRestartAppIfNecessary(Stove::PCSDK::CallbackResult cr, bool restartRequired) {
|
|
215
|
+
Event e = MakeEvent(EventKind::RestartCheck, ResultSnapshot::From(cr));
|
|
216
|
+
e.restartRequired = restartRequired;
|
|
217
|
+
PushEvent(std::move(e));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
void __cdecl OnBaseInitialize(Stove::PCSDK::CallbackResult cr) {
|
|
221
|
+
PushEvent(MakeEvent(EventKind::BaseInit, ResultSnapshot::From(cr)));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
void __cdecl OnOwnershipList(Stove::PCSDK::CallbackResult cr, Ownership::StovePCOwnership* list, uint32_t size) {
|
|
225
|
+
Event e = MakeEvent(EventKind::OwnershipList, ResultSnapshot::From(cr));
|
|
226
|
+
if (list != nullptr) {
|
|
227
|
+
e.ownerships.reserve(size);
|
|
228
|
+
for (uint32_t i = 0; i < size; ++i) {
|
|
229
|
+
OwnershipSnapshot o;
|
|
230
|
+
o.gameId = WideToUtf8(list[i].GetGameId());
|
|
231
|
+
o.gameCode = static_cast<uint32_t>(list[i].GetGameCode());
|
|
232
|
+
o.ownershipCode = static_cast<uint32_t>(list[i].GetOwnershipCode());
|
|
233
|
+
o.purchaseDate = list[i].GetPurchaseDate();
|
|
234
|
+
o.memberNumber = list[i].GetMemberNumber();
|
|
235
|
+
e.ownerships.push_back(std::move(o));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
PushEvent(std::move(e));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
void __cdecl OnAchievement(Stove::PCSDK::CallbackResult cr, GameSupport::StovePCAchievement achievement) {
|
|
242
|
+
Event e = MakeEvent(EventKind::Achievement, ResultSnapshot::From(cr));
|
|
243
|
+
e.achievement = SnapshotAchievement(achievement);
|
|
244
|
+
PushEvent(std::move(e));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
void __cdecl OnAllAchievements(Stove::PCSDK::CallbackResult cr, GameSupport::StovePCAchievement* list, uint32_t size) {
|
|
248
|
+
Event e = MakeEvent(EventKind::AllAchievements, ResultSnapshot::From(cr));
|
|
249
|
+
if (list != nullptr) {
|
|
250
|
+
e.achievements.reserve(size);
|
|
251
|
+
for (uint32_t i = 0; i < size; ++i) e.achievements.push_back(SnapshotAchievement(list[i]));
|
|
252
|
+
}
|
|
253
|
+
PushEvent(std::move(e));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
void __cdecl OnStat(Stove::PCSDK::CallbackResult cr, GameSupport::StovePCStat stat) {
|
|
257
|
+
Event e = MakeEvent(EventKind::Stat, ResultSnapshot::From(cr));
|
|
258
|
+
const GameSupport::StovePCStatFullId* fullId = stat.GetStatFullId();
|
|
259
|
+
if (fullId != nullptr) {
|
|
260
|
+
e.stat.gameId = WideToUtf8(fullId->GetGameId());
|
|
261
|
+
e.stat.statId = WideToUtf8(fullId->GetStatId());
|
|
262
|
+
}
|
|
263
|
+
e.stat.memberNumber = stat.GetMemberNumber();
|
|
264
|
+
e.stat.updatedAt = stat.GetUpdatedAt();
|
|
265
|
+
e.stat.currentValue = stat.GetCurrentValue();
|
|
266
|
+
PushEvent(std::move(e));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
void __cdecl OnModifyStat(Stove::PCSDK::CallbackResult cr, GameSupport::StovePCModifyStatValue statValue) {
|
|
270
|
+
Event e = MakeEvent(EventKind::ModifyStat, ResultSnapshot::From(cr));
|
|
271
|
+
e.modifyStat.currentValue = statValue.GetCurrentValue();
|
|
272
|
+
e.modifyStat.updated = statValue.IsUpdated();
|
|
273
|
+
e.modifyStat.errorMessage = WideToUtf8(statValue.GetErrorMessage());
|
|
274
|
+
PushEvent(std::move(e));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Addon state (single instance; the SDK itself is process-global) ────────────────────
|
|
278
|
+
|
|
279
|
+
enum class InitStage { Idle, RestartCheck, BaseInit, Done };
|
|
280
|
+
|
|
281
|
+
struct InitSession {
|
|
282
|
+
InitStage stage = InitStage::Idle;
|
|
283
|
+
std::wstring environment;
|
|
284
|
+
std::wstring gameId;
|
|
285
|
+
std::wstring applicationKey;
|
|
286
|
+
std::optional<Napi::Promise::Deferred> deferred;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
struct PendingAchievement {
|
|
290
|
+
std::string achievementId;
|
|
291
|
+
Napi::Promise::Deferred deferred;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
struct PendingStat {
|
|
295
|
+
std::string statId;
|
|
296
|
+
int32_t value = 0; // ModifyStat only
|
|
297
|
+
Napi::Promise::Deferred deferred;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
InitSession g_init;
|
|
301
|
+
std::optional<Napi::Promise::Deferred> g_pendingOwnershipList;
|
|
302
|
+
std::deque<PendingAchievement> g_pendingAchievements;
|
|
303
|
+
std::deque<Napi::Promise::Deferred> g_pendingAllAchievements;
|
|
304
|
+
// FIFO: the SDK answers calls in order. StovePCModifyStatValue carries no stat id at all, and a
|
|
305
|
+
// failed StovePCStat may come back empty, so matching by id is not an option for these two.
|
|
306
|
+
std::deque<PendingStat> g_pendingStats;
|
|
307
|
+
std::deque<PendingStat> g_pendingModifyStats;
|
|
308
|
+
bool g_baseInitialized = false;
|
|
309
|
+
bool g_ownershipInitialized = false;
|
|
310
|
+
bool g_gameSupportInitialized = false;
|
|
311
|
+
|
|
312
|
+
bool Pumping() {
|
|
313
|
+
return g_baseInitialized || (g_init.stage != InitStage::Idle && g_init.stage != InitStage::Done);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── JS value builders ──────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
Napi::Object ResultToObject(Napi::Env env, const ResultSnapshot& r, bool isCallback) {
|
|
319
|
+
Napi::Object o = Napi::Object::New(env);
|
|
320
|
+
o.Set("ok", Napi::Boolean::New(env, r.ok));
|
|
321
|
+
o.Set("sdk", Napi::String::New(env, r.sdkName));
|
|
322
|
+
o.Set("method", Napi::Number::New(env, static_cast<double>(r.methodCode)));
|
|
323
|
+
o.Set("code", Napi::Number::New(env, static_cast<double>(r.resultCode)));
|
|
324
|
+
if (isCallback) {
|
|
325
|
+
o.Set("message", Napi::String::New(env, r.errorMessage));
|
|
326
|
+
o.Set("externalError", Napi::Number::New(env, static_cast<double>(r.externalError)));
|
|
327
|
+
}
|
|
328
|
+
return o;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Error shape: Error & { step, sdk, method, code, externalError }.
|
|
332
|
+
Napi::Error MakeSdkError(Napi::Env env, const char* step, const ResultSnapshot& r) {
|
|
333
|
+
std::string message = std::string("[stovesdk] ") + step + " failed";
|
|
334
|
+
if (!r.sdkName.empty()) {
|
|
335
|
+
message += " (" + r.sdkName + " method " + std::to_string(r.methodCode) + " code " + std::to_string(r.resultCode) + ")";
|
|
336
|
+
}
|
|
337
|
+
if (!r.errorMessage.empty()) message += ": " + r.errorMessage;
|
|
338
|
+
Napi::Error err = Napi::Error::New(env, message);
|
|
339
|
+
err.Set("step", Napi::String::New(env, step));
|
|
340
|
+
err.Set("sdk", Napi::String::New(env, r.sdkName));
|
|
341
|
+
err.Set("method", Napi::Number::New(env, static_cast<double>(r.methodCode)));
|
|
342
|
+
err.Set("code", Napi::Number::New(env, static_cast<double>(r.resultCode)));
|
|
343
|
+
err.Set("externalError", Napi::Number::New(env, static_cast<double>(r.externalError)));
|
|
344
|
+
return err;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Addon-level error (no SDK call was made): Error & { step, sdk: '', method: 0, code < 0 }.
|
|
348
|
+
Napi::Error MakeStateError(Napi::Env env, const char* step, int32_t code, const char* what) {
|
|
349
|
+
Napi::Error err = Napi::Error::New(env, std::string("[stovesdk] ") + step + ": " + what);
|
|
350
|
+
err.Set("step", Napi::String::New(env, step));
|
|
351
|
+
err.Set("sdk", Napi::String::New(env, ""));
|
|
352
|
+
err.Set("method", Napi::Number::New(env, 0));
|
|
353
|
+
err.Set("code", Napi::Number::New(env, static_cast<double>(code)));
|
|
354
|
+
err.Set("externalError", Napi::Number::New(env, 0));
|
|
355
|
+
return err;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
Napi::Object OwnershipToObject(Napi::Env env, const OwnershipSnapshot& o) {
|
|
359
|
+
Napi::Object obj = Napi::Object::New(env);
|
|
360
|
+
obj.Set("gameId", Napi::String::New(env, o.gameId));
|
|
361
|
+
obj.Set("gameCode", Napi::Number::New(env, static_cast<double>(o.gameCode)));
|
|
362
|
+
obj.Set("ownershipCode", Napi::Number::New(env, static_cast<double>(o.ownershipCode)));
|
|
363
|
+
obj.Set("purchaseDate", Napi::String::New(env, std::to_string(o.purchaseDate)));
|
|
364
|
+
obj.Set("memberNumber", Napi::String::New(env, std::to_string(o.memberNumber)));
|
|
365
|
+
return obj;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
Napi::Object AchievementToObject(Napi::Env env, const AchievementSnapshot& a) {
|
|
369
|
+
Napi::Object obj = Napi::Object::New(env);
|
|
370
|
+
obj.Set("achievementId", Napi::String::New(env, a.achievementId));
|
|
371
|
+
obj.Set("name", Napi::String::New(env, a.name));
|
|
372
|
+
obj.Set("description", Napi::String::New(env, a.description));
|
|
373
|
+
obj.Set("status", Napi::String::New(env, a.status));
|
|
374
|
+
obj.Set("value", Napi::Number::New(env, static_cast<double>(a.value)));
|
|
375
|
+
obj.Set("defaultImageUrl", Napi::String::New(env, a.defaultImageUrl));
|
|
376
|
+
obj.Set("achievedImageUrl", Napi::String::New(env, a.achievedImageUrl));
|
|
377
|
+
if (a.hasCondition) {
|
|
378
|
+
Napi::Object cond = Napi::Object::New(env);
|
|
379
|
+
cond.Set("valueOperation", Napi::String::New(env, a.conditionValueOperation));
|
|
380
|
+
cond.Set("type", Napi::String::New(env, a.conditionType));
|
|
381
|
+
cond.Set("goalValue", Napi::Number::New(env, static_cast<double>(a.conditionGoalValue)));
|
|
382
|
+
obj.Set("condition", cond);
|
|
383
|
+
} else {
|
|
384
|
+
obj.Set("condition", env.Null());
|
|
385
|
+
}
|
|
386
|
+
return obj;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Event handlers (JS thread only) ────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
void FailInit(Napi::Env env, const char* step, const ResultSnapshot& r) {
|
|
392
|
+
g_init.stage = InitStage::Done;
|
|
393
|
+
if (g_init.deferred) {
|
|
394
|
+
g_init.deferred->Reject(MakeSdkError(env, step, r).Value());
|
|
395
|
+
g_init.deferred.reset();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
void HandleRestartCheck(Napi::Env env, const Event& e) {
|
|
400
|
+
if (g_init.stage != InitStage::RestartCheck) return; // stale event (e.g. after uninitialize)
|
|
401
|
+
if (!e.result.ok) {
|
|
402
|
+
FailInit(env, "restartAppIfNecessary", e.result);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (e.restartRequired) {
|
|
406
|
+
// The SDK could not attach to a running STOVE client and has asked the client to relaunch
|
|
407
|
+
// the application. The vendor guide says the application should exit now; the caller decides.
|
|
408
|
+
g_init.stage = InitStage::Done;
|
|
409
|
+
if (g_init.deferred) {
|
|
410
|
+
Napi::Object out = Napi::Object::New(env);
|
|
411
|
+
out.Set("status", Napi::String::New(env, "restart"));
|
|
412
|
+
g_init.deferred->Resolve(out);
|
|
413
|
+
g_init.deferred.reset();
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
Base::StovePCInitializeParam param;
|
|
419
|
+
param.SetEnvironment(g_init.environment.c_str());
|
|
420
|
+
param.SetGameID(g_init.gameId.c_str());
|
|
421
|
+
param.SetApplicationKey(g_init.applicationKey.c_str());
|
|
422
|
+
g_init.stage = InitStage::BaseInit;
|
|
423
|
+
Base::Base_Initialize(¶m, OnBaseInitialize);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
void HandleBaseInit(Napi::Env env, const Event& e) {
|
|
427
|
+
if (g_init.stage != InitStage::BaseInit) return;
|
|
428
|
+
if (!e.result.ok) {
|
|
429
|
+
FailInit(env, "initialize", e.result);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
g_baseInitialized = true;
|
|
433
|
+
g_init.stage = InitStage::Done;
|
|
434
|
+
if (g_init.deferred) {
|
|
435
|
+
Napi::Object out = Napi::Object::New(env);
|
|
436
|
+
out.Set("status", Napi::String::New(env, "ok"));
|
|
437
|
+
out.Set("result", ResultToObject(env, e.result, true));
|
|
438
|
+
g_init.deferred->Resolve(out);
|
|
439
|
+
g_init.deferred.reset();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
void HandleOwnershipList(Napi::Env env, const Event& e) {
|
|
444
|
+
if (!g_pendingOwnershipList) return; // nobody waiting (e.g. after uninitialize)
|
|
445
|
+
Napi::Promise::Deferred deferred = *g_pendingOwnershipList;
|
|
446
|
+
g_pendingOwnershipList.reset();
|
|
447
|
+
|
|
448
|
+
if (!e.result.ok) {
|
|
449
|
+
deferred.Reject(MakeSdkError(env, "ownershipList", e.result).Value());
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
Napi::Array list = Napi::Array::New(env, e.ownerships.size());
|
|
453
|
+
uint32_t i = 0;
|
|
454
|
+
for (const OwnershipSnapshot& o : e.ownerships) list.Set(i++, OwnershipToObject(env, o));
|
|
455
|
+
deferred.Resolve(list);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
void HandleAchievement(Napi::Env env, const Event& e) {
|
|
459
|
+
if (g_pendingAchievements.empty()) return;
|
|
460
|
+
|
|
461
|
+
// Match by id when the SDK echoed one back; otherwise settle the oldest request (the SDK
|
|
462
|
+
// processes calls in order, and an error result may carry an empty achievement).
|
|
463
|
+
auto it = g_pendingAchievements.begin();
|
|
464
|
+
if (!e.achievement.achievementId.empty()) {
|
|
465
|
+
for (auto cur = g_pendingAchievements.begin(); cur != g_pendingAchievements.end(); ++cur) {
|
|
466
|
+
if (cur->achievementId == e.achievement.achievementId) {
|
|
467
|
+
it = cur;
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
PendingAchievement pending = std::move(*it);
|
|
473
|
+
g_pendingAchievements.erase(it);
|
|
474
|
+
|
|
475
|
+
if (!e.result.ok) {
|
|
476
|
+
pending.deferred.Reject(MakeSdkError(env, "achievement", e.result).Value());
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
Napi::Object out = AchievementToObject(env, e.achievement);
|
|
480
|
+
if (e.achievement.achievementId.empty()) out.Set("achievementId", Napi::String::New(env, pending.achievementId));
|
|
481
|
+
out.Set("result", ResultToObject(env, e.result, true));
|
|
482
|
+
pending.deferred.Resolve(out);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
void HandleAllAchievements(Napi::Env env, const Event& e) {
|
|
486
|
+
if (g_pendingAllAchievements.empty()) return;
|
|
487
|
+
Napi::Promise::Deferred deferred = std::move(g_pendingAllAchievements.front());
|
|
488
|
+
g_pendingAllAchievements.pop_front();
|
|
489
|
+
|
|
490
|
+
if (!e.result.ok) {
|
|
491
|
+
deferred.Reject(MakeSdkError(env, "allAchievements", e.result).Value());
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
Napi::Array list = Napi::Array::New(env, e.achievements.size());
|
|
495
|
+
uint32_t i = 0;
|
|
496
|
+
for (const AchievementSnapshot& a : e.achievements) list.Set(i++, AchievementToObject(env, a));
|
|
497
|
+
deferred.Resolve(list);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
void HandleStat(Napi::Env env, const Event& e) {
|
|
501
|
+
if (g_pendingStats.empty()) return;
|
|
502
|
+
PendingStat pending = std::move(g_pendingStats.front());
|
|
503
|
+
g_pendingStats.pop_front();
|
|
504
|
+
|
|
505
|
+
if (!e.result.ok) {
|
|
506
|
+
pending.deferred.Reject(MakeSdkError(env, "stat", e.result).Value());
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
Napi::Object out = Napi::Object::New(env);
|
|
510
|
+
out.Set("statId", Napi::String::New(env, e.stat.statId.empty() ? pending.statId : e.stat.statId));
|
|
511
|
+
out.Set("gameId", Napi::String::New(env, e.stat.gameId));
|
|
512
|
+
out.Set("memberNumber", Napi::String::New(env, std::to_string(e.stat.memberNumber)));
|
|
513
|
+
out.Set("updatedAt", Napi::String::New(env, std::to_string(e.stat.updatedAt)));
|
|
514
|
+
out.Set("currentValue", Napi::Number::New(env, static_cast<double>(e.stat.currentValue)));
|
|
515
|
+
out.Set("result", ResultToObject(env, e.result, true));
|
|
516
|
+
pending.deferred.Resolve(out);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
void HandleModifyStat(Napi::Env env, const Event& e) {
|
|
520
|
+
if (g_pendingModifyStats.empty()) return;
|
|
521
|
+
PendingStat pending = std::move(g_pendingModifyStats.front());
|
|
522
|
+
g_pendingModifyStats.pop_front();
|
|
523
|
+
|
|
524
|
+
if (!e.result.ok) {
|
|
525
|
+
pending.deferred.Reject(MakeSdkError(env, "modifyStat", e.result).Value());
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
Napi::Object out = Napi::Object::New(env);
|
|
529
|
+
out.Set("statId", Napi::String::New(env, pending.statId));
|
|
530
|
+
out.Set("value", Napi::Number::New(env, static_cast<double>(pending.value)));
|
|
531
|
+
out.Set("currentValue", Napi::Number::New(env, static_cast<double>(e.modifyStat.currentValue)));
|
|
532
|
+
out.Set("updated", Napi::Boolean::New(env, e.modifyStat.updated));
|
|
533
|
+
out.Set("errorMessage", Napi::String::New(env, e.modifyStat.errorMessage));
|
|
534
|
+
out.Set("result", ResultToObject(env, e.result, true));
|
|
535
|
+
pending.deferred.Resolve(out);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
void DrainEvents(Napi::Env env) {
|
|
539
|
+
for (;;) {
|
|
540
|
+
Event e;
|
|
541
|
+
{
|
|
542
|
+
std::lock_guard<std::mutex> lock(g_queueMutex);
|
|
543
|
+
if (g_queue.empty()) return;
|
|
544
|
+
e = std::move(g_queue.front());
|
|
545
|
+
g_queue.pop_front();
|
|
546
|
+
}
|
|
547
|
+
switch (e.kind) {
|
|
548
|
+
case EventKind::RestartCheck: HandleRestartCheck(env, e); break;
|
|
549
|
+
case EventKind::BaseInit: HandleBaseInit(env, e); break;
|
|
550
|
+
case EventKind::OwnershipList: HandleOwnershipList(env, e); break;
|
|
551
|
+
case EventKind::Achievement: HandleAchievement(env, e); break;
|
|
552
|
+
case EventKind::AllAchievements: HandleAllAchievements(env, e); break;
|
|
553
|
+
case EventKind::Stat: HandleStat(env, e); break;
|
|
554
|
+
case EventKind::ModifyStat: HandleModifyStat(env, e); break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── Lazy sub-module initialization ─────────────────────────────────────────────────────
|
|
560
|
+
|
|
561
|
+
// Returns false after rejecting `deferred` when Base is not up or the module refuses to init.
|
|
562
|
+
bool EnsureBase(Napi::Env env, const char* step, Napi::Promise::Deferred& deferred) {
|
|
563
|
+
if (!g_baseInitialized) {
|
|
564
|
+
deferred.Reject(MakeStateError(env, step, kErrNotInitialized, "SDK is not initialized (call initialize() first)").Value());
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
bool EnsureOwnership(Napi::Env env, const char* step, Napi::Promise::Deferred& deferred) {
|
|
571
|
+
if (!EnsureBase(env, step, deferred)) return false;
|
|
572
|
+
if (!g_ownershipInitialized) {
|
|
573
|
+
const ResultSnapshot r = ResultSnapshot::From(Ownership::Ownership_Initialize());
|
|
574
|
+
if (!r.ok) {
|
|
575
|
+
deferred.Reject(MakeSdkError(env, "ownershipInitialize", r).Value());
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
g_ownershipInitialized = true;
|
|
579
|
+
}
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
bool EnsureGameSupport(Napi::Env env, const char* step, Napi::Promise::Deferred& deferred) {
|
|
584
|
+
if (!EnsureBase(env, step, deferred)) return false;
|
|
585
|
+
if (!g_gameSupportInitialized) {
|
|
586
|
+
const ResultSnapshot r = ResultSnapshot::From(GameSupport::GameSupport_Initialize());
|
|
587
|
+
if (!r.ok) {
|
|
588
|
+
deferred.Reject(MakeSdkError(env, "gameSupportInitialize", r).Value());
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
g_gameSupportInitialized = true;
|
|
592
|
+
}
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Answer for a synchronous read the addon refused to make (SDK not up yet). Same field set as
|
|
597
|
+
// ResultToObject plus `reason`, so callers read `ok` alone and never special-case the shape.
|
|
598
|
+
Napi::Object NotAskedObject(Napi::Env env, const char* reason) {
|
|
599
|
+
Napi::Object o = Napi::Object::New(env);
|
|
600
|
+
o.Set("ok", Napi::Boolean::New(env, false));
|
|
601
|
+
o.Set("sdk", Napi::String::New(env, ""));
|
|
602
|
+
o.Set("method", Napi::Number::New(env, 0));
|
|
603
|
+
o.Set("code", Napi::Number::New(env, static_cast<double>(kErrNotInitialized)));
|
|
604
|
+
o.Set("reason", Napi::String::New(env, reason));
|
|
605
|
+
return o;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
bool RequireString(Napi::Env env, const Napi::CallbackInfo& info, size_t index, const char* usage) {
|
|
609
|
+
if (info.Length() <= index || !info[index].IsString()) {
|
|
610
|
+
Napi::TypeError::New(env, usage).ThrowAsJavaScriptException();
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── Exported functions ─────────────────────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
// getAddonInfo(): { sdkVersion, napiVersion, arch, platform, pumpIntervalMs }
|
|
619
|
+
Napi::Value GetAddonInfo(const Napi::CallbackInfo& info) {
|
|
620
|
+
Napi::Env env = info.Env();
|
|
621
|
+
Napi::Object o = Napi::Object::New(env);
|
|
622
|
+
o.Set("sdkVersion", Napi::String::New(env, STOVE_PCSDK_SDK_VERSION));
|
|
623
|
+
o.Set("napiVersion", Napi::Number::New(env, NAPI_VERSION));
|
|
624
|
+
o.Set("arch", Napi::String::New(env, "x64"));
|
|
625
|
+
o.Set("platform", Napi::String::New(env, "win32"));
|
|
626
|
+
o.Set("pumpIntervalMs", Napi::Number::New(env, 16)); // vendor guide recommendation
|
|
627
|
+
return o;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// getVersion(): { ok, sdk, method, code, version } — Base_GetVersion, informational, synchronous.
|
|
631
|
+
Napi::Value GetVersion(const Napi::CallbackInfo& info) {
|
|
632
|
+
Napi::Env env = info.Env();
|
|
633
|
+
wchar_t buffer[256] = {0};
|
|
634
|
+
const ResultSnapshot r = ResultSnapshot::From(Base::Base_GetVersion(buffer, 256));
|
|
635
|
+
Napi::Object o = ResultToObject(env, r, false);
|
|
636
|
+
o.Set("version", Napi::String::New(env, WideToUtf8(buffer)));
|
|
637
|
+
return o;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// initialize({ environment, gameId, applicationKey, restartWaitMs? }):
|
|
641
|
+
// Promise<{ status: 'ok', result } | { status: 'restart' }>
|
|
642
|
+
// Runs Base_RestartAppIfNecessaryAsync, then Base_Initialize. Resolves 'restart' (without
|
|
643
|
+
// initializing) when the SDK asked the STOVE client to relaunch the application.
|
|
644
|
+
Napi::Value Initialize(const Napi::CallbackInfo& info) {
|
|
645
|
+
Napi::Env env = info.Env();
|
|
646
|
+
if (info.Length() < 1 || !info[0].IsObject()) {
|
|
647
|
+
Napi::TypeError::New(env, "initialize(options) expects an options object").ThrowAsJavaScriptException();
|
|
648
|
+
return env.Undefined();
|
|
649
|
+
}
|
|
650
|
+
Napi::Object opts = info[0].As<Napi::Object>();
|
|
651
|
+
const Napi::Value environment = opts.Get("environment");
|
|
652
|
+
const Napi::Value gameId = opts.Get("gameId");
|
|
653
|
+
const Napi::Value applicationKey = opts.Get("applicationKey");
|
|
654
|
+
if (!environment.IsString() || !gameId.IsString() || !applicationKey.IsString()) {
|
|
655
|
+
Napi::TypeError::New(env, "initialize: environment, gameId and applicationKey must be strings").ThrowAsJavaScriptException();
|
|
656
|
+
return env.Undefined();
|
|
657
|
+
}
|
|
658
|
+
uint32_t restartWaitMs = 60000; // SDK default (vendor docs)
|
|
659
|
+
const Napi::Value wait = opts.Get("restartWaitMs");
|
|
660
|
+
if (wait.IsNumber()) restartWaitMs = wait.As<Napi::Number>().Uint32Value();
|
|
661
|
+
|
|
662
|
+
Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
|
|
663
|
+
if (g_init.stage != InitStage::Idle && g_init.stage != InitStage::Done) {
|
|
664
|
+
deferred.Reject(MakeStateError(env, "initialize", kErrInProgress, "an initialize() call is already in progress").Value());
|
|
665
|
+
return deferred.Promise();
|
|
666
|
+
}
|
|
667
|
+
if (g_baseInitialized) {
|
|
668
|
+
deferred.Reject(MakeStateError(env, "initialize", kErrAlreadyInitialized, "SDK is already initialized; call uninitialize() first").Value());
|
|
669
|
+
return deferred.Promise();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
g_init.environment = JsToWide(environment);
|
|
673
|
+
g_init.gameId = JsToWide(gameId);
|
|
674
|
+
g_init.applicationKey = JsToWide(applicationKey);
|
|
675
|
+
g_init.deferred = deferred;
|
|
676
|
+
g_init.stage = InitStage::RestartCheck;
|
|
677
|
+
|
|
678
|
+
Base::StovePCInitializeParam param;
|
|
679
|
+
param.SetEnvironment(g_init.environment.c_str());
|
|
680
|
+
param.SetGameID(g_init.gameId.c_str());
|
|
681
|
+
param.SetApplicationKey(g_init.applicationKey.c_str());
|
|
682
|
+
Base::Base_RestartAppIfNecessaryAsync(¶m, restartWaitMs, OnRestartAppIfNecessary);
|
|
683
|
+
|
|
684
|
+
return deferred.Promise();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// runCallbacks(): pump Base_RunCallback and settle whatever became ready. Cheap when idle.
|
|
688
|
+
Napi::Value RunCallbacks(const Napi::CallbackInfo& info) {
|
|
689
|
+
Napi::Env env = info.Env();
|
|
690
|
+
if (Pumping()) Base::Base_RunCallback();
|
|
691
|
+
DrainEvents(env);
|
|
692
|
+
return env.Undefined();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// isInitialized(): boolean — Base_Initialize has succeeded and uninitialize() has not run.
|
|
696
|
+
Napi::Value IsInitialized(const Napi::CallbackInfo& info) {
|
|
697
|
+
return Napi::Boolean::New(info.Env(), g_baseInitialized);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// getOwnershipList(): Promise<Ownership[]> — Ownership_OwnershipList (Ownership_Initialize lazily).
|
|
701
|
+
Napi::Value GetOwnershipList(const Napi::CallbackInfo& info) {
|
|
702
|
+
Napi::Env env = info.Env();
|
|
703
|
+
Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
|
|
704
|
+
if (g_pendingOwnershipList) {
|
|
705
|
+
deferred.Reject(MakeStateError(env, "ownershipList", kErrInProgress, "an ownership query is already in progress").Value());
|
|
706
|
+
return deferred.Promise();
|
|
707
|
+
}
|
|
708
|
+
if (!EnsureOwnership(env, "ownershipList", deferred)) return deferred.Promise();
|
|
709
|
+
g_pendingOwnershipList = deferred;
|
|
710
|
+
Ownership::Ownership_OwnershipList(OnOwnershipList);
|
|
711
|
+
return deferred.Promise();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// getUser(): { ok, sdk, method, code, memberNumber, gameUserId, nickname } — Base_GetUser.
|
|
715
|
+
// Synchronous. Never throws: before initialize() it answers { ok: false, code: -1, reason }.
|
|
716
|
+
Napi::Value GetUser(const Napi::CallbackInfo& info) {
|
|
717
|
+
Napi::Env env = info.Env();
|
|
718
|
+
if (!g_baseInitialized) return NotAskedObject(env, "SDK is not initialized");
|
|
719
|
+
Base::StovePCUser user;
|
|
720
|
+
const ResultSnapshot r = ResultSnapshot::From(Base::Base_GetUser(&user));
|
|
721
|
+
Napi::Object o = ResultToObject(env, r, false);
|
|
722
|
+
o.Set("memberNumber", Napi::String::New(env, r.ok ? std::to_string(user.GetMemberNumber()) : std::string()));
|
|
723
|
+
o.Set("gameUserId", Napi::String::New(env, r.ok ? std::to_string(user.GetGameUserId()) : std::string()));
|
|
724
|
+
o.Set("nickname", Napi::String::New(env, r.ok ? WideToUtf8(user.GetNickname()) : std::string()));
|
|
725
|
+
return o;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// getGds(): { ok, sdk, method, code, nation, isDefault, language } — Base_GetGds.
|
|
729
|
+
// `isDefault` true means the country could not be determined and `nation` is STOVE's default.
|
|
730
|
+
Napi::Value GetGds(const Napi::CallbackInfo& info) {
|
|
731
|
+
Napi::Env env = info.Env();
|
|
732
|
+
if (!g_baseInitialized) return NotAskedObject(env, "SDK is not initialized");
|
|
733
|
+
Base::StovePCGds gds;
|
|
734
|
+
const ResultSnapshot r = ResultSnapshot::From(Base::Base_GetGds(&gds));
|
|
735
|
+
Napi::Object o = ResultToObject(env, r, false);
|
|
736
|
+
o.Set("nation", Napi::String::New(env, r.ok ? WideToUtf8(gds.GetNation()) : std::string()));
|
|
737
|
+
o.Set("isDefault", Napi::Boolean::New(env, r.ok ? gds.IsDefault() : false));
|
|
738
|
+
o.Set("language", Napi::String::New(env, r.ok ? WideToUtf8(gds.GetLanguage()) : std::string()));
|
|
739
|
+
return o;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// getAchievement(id): Promise<Achievement> — GameSupport_Achievement (a query; completes nothing).
|
|
743
|
+
Napi::Value GetAchievement(const Napi::CallbackInfo& info) {
|
|
744
|
+
Napi::Env env = info.Env();
|
|
745
|
+
if (!RequireString(env, info, 0, "getAchievement(achievementId) expects a string")) return env.Undefined();
|
|
746
|
+
Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
|
|
747
|
+
if (!EnsureGameSupport(env, "achievement", deferred)) return deferred.Promise();
|
|
748
|
+
|
|
749
|
+
const std::wstring id = JsToWide(info[0]);
|
|
750
|
+
g_pendingAchievements.push_back(PendingAchievement{info[0].As<Napi::String>().Utf8Value(), deferred});
|
|
751
|
+
GameSupport::GameSupport_Achievement(id.c_str(), OnAchievement);
|
|
752
|
+
return deferred.Promise();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// getAllAchievements(): Promise<Achievement[]> — GameSupport_AllAchievement.
|
|
756
|
+
Napi::Value GetAllAchievements(const Napi::CallbackInfo& info) {
|
|
757
|
+
Napi::Env env = info.Env();
|
|
758
|
+
Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
|
|
759
|
+
if (!EnsureGameSupport(env, "allAchievements", deferred)) return deferred.Promise();
|
|
760
|
+
g_pendingAllAchievements.push_back(deferred);
|
|
761
|
+
GameSupport::GameSupport_AllAchievement(OnAllAchievements);
|
|
762
|
+
return deferred.Promise();
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// getStat(statId): Promise<Stat> — GameSupport_Stat.
|
|
766
|
+
Napi::Value GetStat(const Napi::CallbackInfo& info) {
|
|
767
|
+
Napi::Env env = info.Env();
|
|
768
|
+
if (!RequireString(env, info, 0, "getStat(statId) expects a string")) return env.Undefined();
|
|
769
|
+
Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
|
|
770
|
+
if (!EnsureGameSupport(env, "stat", deferred)) return deferred.Promise();
|
|
771
|
+
|
|
772
|
+
const std::wstring id = JsToWide(info[0]);
|
|
773
|
+
g_pendingStats.push_back(PendingStat{info[0].As<Napi::String>().Utf8Value(), 0, deferred});
|
|
774
|
+
GameSupport::GameSupport_Stat(id.c_str(), OnStat);
|
|
775
|
+
return deferred.Promise();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// setStat(statId, value): Promise<ModifyStatResult> — GameSupport_ModifyStat.
|
|
779
|
+
// STOVE has no "unlock achievement" call: achievements are defined in STOVE Studio as a stat plus
|
|
780
|
+
// a goal, so completing one means modifying its stat. `value` must be an int32 (the SDK type).
|
|
781
|
+
Napi::Value SetStat(const Napi::CallbackInfo& info) {
|
|
782
|
+
Napi::Env env = info.Env();
|
|
783
|
+
if (info.Length() < 2 || !info[0].IsString() || !info[1].IsNumber()) {
|
|
784
|
+
Napi::TypeError::New(env, "setStat(statId, value) expects a string and a number").ThrowAsJavaScriptException();
|
|
785
|
+
return env.Undefined();
|
|
786
|
+
}
|
|
787
|
+
const double raw = info[1].As<Napi::Number>().DoubleValue();
|
|
788
|
+
if (raw != raw || raw < static_cast<double>(INT32_MIN) || raw > static_cast<double>(INT32_MAX) || raw != static_cast<double>(static_cast<int32_t>(raw))) {
|
|
789
|
+
Napi::RangeError::New(env, "setStat(statId, value): value must be an int32").ThrowAsJavaScriptException();
|
|
790
|
+
return env.Undefined();
|
|
791
|
+
}
|
|
792
|
+
const int32_t value = static_cast<int32_t>(raw);
|
|
793
|
+
|
|
794
|
+
Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env);
|
|
795
|
+
if (!EnsureGameSupport(env, "modifyStat", deferred)) return deferred.Promise();
|
|
796
|
+
|
|
797
|
+
const std::wstring id = JsToWide(info[0]);
|
|
798
|
+
g_pendingModifyStats.push_back(PendingStat{info[0].As<Napi::String>().Utf8Value(), value, deferred});
|
|
799
|
+
GameSupport::GameSupport_ModifyStat(id.c_str(), value, OnModifyStat);
|
|
800
|
+
return deferred.Promise();
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// uninitialize(): { gameSupport, ownership, base } — each the Result of its UnInitialize, or null
|
|
804
|
+
// if that module was never initialized. Base goes last, as the vendor guide requires. Pending
|
|
805
|
+
// Promises are rejected with { step: 'uninitialize', code: ErrorCode.ABORTED }. Safe to call twice.
|
|
806
|
+
Napi::Value Uninitialize(const Napi::CallbackInfo& info) {
|
|
807
|
+
Napi::Env env = info.Env();
|
|
808
|
+
Napi::Object out = Napi::Object::New(env);
|
|
809
|
+
|
|
810
|
+
if (g_gameSupportInitialized) {
|
|
811
|
+
out.Set("gameSupport", ResultToObject(env, ResultSnapshot::From(GameSupport::GameSupport_UnInitialize()), false));
|
|
812
|
+
g_gameSupportInitialized = false;
|
|
813
|
+
} else {
|
|
814
|
+
out.Set("gameSupport", env.Null());
|
|
815
|
+
}
|
|
816
|
+
if (g_ownershipInitialized) {
|
|
817
|
+
out.Set("ownership", ResultToObject(env, ResultSnapshot::From(Ownership::Ownership_UnInitialize()), false));
|
|
818
|
+
g_ownershipInitialized = false;
|
|
819
|
+
} else {
|
|
820
|
+
out.Set("ownership", env.Null());
|
|
821
|
+
}
|
|
822
|
+
if (g_baseInitialized) {
|
|
823
|
+
out.Set("base", ResultToObject(env, ResultSnapshot::From(Base::Base_UnInitialize()), false));
|
|
824
|
+
g_baseInitialized = false;
|
|
825
|
+
} else {
|
|
826
|
+
out.Set("base", env.Null());
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
{
|
|
830
|
+
std::lock_guard<std::mutex> lock(g_queueMutex);
|
|
831
|
+
g_queue.clear();
|
|
832
|
+
}
|
|
833
|
+
const auto abort = [&](Napi::Promise::Deferred& d, const char* what) {
|
|
834
|
+
d.Reject(MakeStateError(env, "uninitialize", kErrAborted, what).Value());
|
|
835
|
+
};
|
|
836
|
+
if (g_init.deferred) {
|
|
837
|
+
abort(*g_init.deferred, "initialize() aborted by uninitialize()");
|
|
838
|
+
g_init.deferred.reset();
|
|
839
|
+
}
|
|
840
|
+
g_init.stage = InitStage::Idle;
|
|
841
|
+
if (g_pendingOwnershipList) {
|
|
842
|
+
abort(*g_pendingOwnershipList, "ownership query aborted by uninitialize()");
|
|
843
|
+
g_pendingOwnershipList.reset();
|
|
844
|
+
}
|
|
845
|
+
for (PendingAchievement& p : g_pendingAchievements) abort(p.deferred, "achievement request aborted by uninitialize()");
|
|
846
|
+
g_pendingAchievements.clear();
|
|
847
|
+
for (Napi::Promise::Deferred& d : g_pendingAllAchievements) abort(d, "achievement request aborted by uninitialize()");
|
|
848
|
+
g_pendingAllAchievements.clear();
|
|
849
|
+
for (PendingStat& p : g_pendingStats) abort(p.deferred, "stat request aborted by uninitialize()");
|
|
850
|
+
g_pendingStats.clear();
|
|
851
|
+
for (PendingStat& p : g_pendingModifyStats) abort(p.deferred, "stat request aborted by uninitialize()");
|
|
852
|
+
g_pendingModifyStats.clear();
|
|
853
|
+
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Enum values taken from the SDK headers at compile time, so JS never hard-codes them.
|
|
858
|
+
Napi::Object BuildConstants(Napi::Env env) {
|
|
859
|
+
Napi::Object constants = Napi::Object::New(env);
|
|
860
|
+
|
|
861
|
+
Napi::Object ownershipCode = Napi::Object::New(env);
|
|
862
|
+
ownershipCode.Set("NONE", Napi::Number::New(env, static_cast<double>(Ownership::OwnershipCode::NONE)));
|
|
863
|
+
ownershipCode.Set("ACQUIRE", Napi::Number::New(env, static_cast<double>(Ownership::OwnershipCode::ACQUIRE)));
|
|
864
|
+
ownershipCode.Set("LOSE", Napi::Number::New(env, static_cast<double>(Ownership::OwnershipCode::LOSE)));
|
|
865
|
+
constants.Set("OwnershipCode", ownershipCode);
|
|
866
|
+
|
|
867
|
+
Napi::Object gameCode = Napi::Object::New(env);
|
|
868
|
+
gameCode.Set("NONE", Napi::Number::New(env, static_cast<double>(Ownership::OwnershipGameCode::NONE)));
|
|
869
|
+
gameCode.Set("BASIC", Napi::Number::New(env, static_cast<double>(Ownership::OwnershipGameCode::BASIC)));
|
|
870
|
+
gameCode.Set("DEMO", Napi::Number::New(env, static_cast<double>(Ownership::OwnershipGameCode::DEMO)));
|
|
871
|
+
gameCode.Set("DLC", Napi::Number::New(env, static_cast<double>(Ownership::OwnershipGameCode::DLC)));
|
|
872
|
+
constants.Set("OwnershipGameCode", gameCode);
|
|
873
|
+
|
|
874
|
+
Napi::Object errorCode = Napi::Object::New(env);
|
|
875
|
+
errorCode.Set("NOT_INITIALIZED", Napi::Number::New(env, kErrNotInitialized));
|
|
876
|
+
errorCode.Set("ALREADY_INITIALIZED", Napi::Number::New(env, kErrAlreadyInitialized));
|
|
877
|
+
errorCode.Set("IN_PROGRESS", Napi::Number::New(env, kErrInProgress));
|
|
878
|
+
errorCode.Set("ABORTED", Napi::Number::New(env, kErrAborted));
|
|
879
|
+
constants.Set("ErrorCode", errorCode);
|
|
880
|
+
|
|
881
|
+
return constants;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
885
|
+
exports.Set("getAddonInfo", Napi::Function::New(env, GetAddonInfo));
|
|
886
|
+
exports.Set("getVersion", Napi::Function::New(env, GetVersion));
|
|
887
|
+
exports.Set("initialize", Napi::Function::New(env, Initialize));
|
|
888
|
+
exports.Set("runCallbacks", Napi::Function::New(env, RunCallbacks));
|
|
889
|
+
exports.Set("isInitialized", Napi::Function::New(env, IsInitialized));
|
|
890
|
+
exports.Set("uninitialize", Napi::Function::New(env, Uninitialize));
|
|
891
|
+
exports.Set("getOwnershipList", Napi::Function::New(env, GetOwnershipList));
|
|
892
|
+
exports.Set("getUser", Napi::Function::New(env, GetUser));
|
|
893
|
+
exports.Set("getGds", Napi::Function::New(env, GetGds));
|
|
894
|
+
exports.Set("getAchievement", Napi::Function::New(env, GetAchievement));
|
|
895
|
+
exports.Set("getAllAchievements", Napi::Function::New(env, GetAllAchievements));
|
|
896
|
+
exports.Set("getStat", Napi::Function::New(env, GetStat));
|
|
897
|
+
exports.Set("setStat", Napi::Function::New(env, SetStat));
|
|
898
|
+
exports.Set("constants", BuildConstants(env));
|
|
899
|
+
return exports;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
} // namespace
|
|
903
|
+
|
|
904
|
+
NODE_API_MODULE(stovesdk, Init)
|