@mpgd/cli 0.52.0 → 0.54.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.
- package/dist/capacitor-shell-starter.d.ts +22 -0
- package/dist/capacitor-shell-starter.js +395 -18
- package/dist/deploy-planning.js +2 -2
- package/dist/index.js +122 -34
- package/dist/kit-upgrade.js +2 -2
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +433 -2
- package/dist/notifications-starter.d.ts +75 -0
- package/dist/notifications-starter.js +522 -0
- package/dist/offline-playtest.js +1 -1
- package/dist/tooling-migration.js +1 -1
- package/package.json +16 -16
- package/templates/notifications-overlay/common/docs/NOTIFICATIONS.md +104 -0
- package/templates/notifications-overlay/common/notifications/deployment.json +8 -0
- package/templates/notifications-overlay/common/notifications/topics.json +222 -0
- package/templates/notifications-overlay/common/src/platform/capacitorNotifications.ts +59 -0
- package/templates/notifications-overlay/common/src/platform/notificationBackend.ts +157 -0
- package/templates/notifications-overlay/common/src/platform/notificationOpens.ts +60 -0
- package/templates/notifications-overlay/common/src/platform/notificationSettings.ts +761 -0
- package/templates/notifications-overlay/common/src/platform/notifications.ts +163 -0
- package/templates/notifications-overlay/common/tools/validate-notifications.ts +75 -0
- package/templates/notifications-overlay/lite/src/lite/notificationSettingsPanel.ts +200 -0
- package/templates/notifications-overlay/phaser/src/scenes/NotificationSettingsScene.ts +301 -0
- package/templates/phaser-game/apps/target-ait/src/main.ts +15 -0
- package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +5 -0
- package/templates/phaser-game/apps/target-ait/vite.config.ts +22 -1
- package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +14 -0
- package/templates/phaser-game/src/platform/gameServices.ts +3 -0
- package/templates/phaser-game/src/platform/serverSession.ts +66 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import Phaser from 'phaser';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
starterNotificationLabelsFor,
|
|
5
|
+
type StarterNotificationLabels,
|
|
6
|
+
type StarterNotificationSettings,
|
|
7
|
+
type StarterNotificationSettingsView,
|
|
8
|
+
} from '../platform/notificationSettings';
|
|
9
|
+
import {
|
|
10
|
+
createStarterNotificationSettingsModel,
|
|
11
|
+
subscribeStarterNotificationOpens,
|
|
12
|
+
} from '../platform/notifications';
|
|
13
|
+
import { starterContextKey, type StarterContext } from '../runtime/gameContext';
|
|
14
|
+
|
|
15
|
+
export const notificationSettingsSceneKey = 'NotificationSettingsScene';
|
|
16
|
+
|
|
17
|
+
const textStyle = { color: '#d6dee8', fontFamily: 'Arial, sans-serif', fontSize: '18px' } as const;
|
|
18
|
+
const buttonStyle = { ...textStyle, color: '#5eead4' } as const;
|
|
19
|
+
/** List rows and the outcome strip share this column (x=60..900) on the 960x540 game. */
|
|
20
|
+
const listTextWidth = 840;
|
|
21
|
+
/** Below the title. The outcome strip grows downward from here. */
|
|
22
|
+
const outcomeTop = 68;
|
|
23
|
+
/** The list ends above the Back button. */
|
|
24
|
+
const listBottom = 480;
|
|
25
|
+
const minListTop = 110;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Routes notification opens: one whose route is the settings screen (the
|
|
29
|
+
* advertising opt-out action, a consent notice) shows the screen, after boot
|
|
30
|
+
* when the open started the game. Route your other topics' opens in `other`
|
|
31
|
+
* (see `subscribeStarterNotificationOpens`): this is the first subscriber,
|
|
32
|
+
* the one that receives a cold-start open. An open during play leaves the
|
|
33
|
+
* run; the play scene saves its checkpoint when it shuts down.
|
|
34
|
+
*/
|
|
35
|
+
export function routeNotificationSettingsOpens(game: Phaser.Game, context: StarterContext): void {
|
|
36
|
+
const activeKeys = (): string[] => game.scene.getScenes(true).map((scene) => scene.scene.key);
|
|
37
|
+
const show = (): void => {
|
|
38
|
+
for (const key of activeKeys()) {
|
|
39
|
+
if (key !== notificationSettingsSceneKey) {
|
|
40
|
+
game.scene.stop(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
game.scene.start(notificationSettingsSceneKey);
|
|
44
|
+
};
|
|
45
|
+
let waiting = false;
|
|
46
|
+
const afterBoot = (): void => {
|
|
47
|
+
const active = activeKeys();
|
|
48
|
+
if (active.length === 0 || active.includes('BootScene')) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
game.events.off('poststep', afterBoot);
|
|
52
|
+
waiting = false;
|
|
53
|
+
show();
|
|
54
|
+
};
|
|
55
|
+
const unsubscribe = subscribeStarterNotificationOpens(context.platform, {
|
|
56
|
+
settings: () => {
|
|
57
|
+
const active = activeKeys();
|
|
58
|
+
if (active.length > 0 && !active.includes('BootScene')) {
|
|
59
|
+
show();
|
|
60
|
+
} else if (!waiting) {
|
|
61
|
+
// Booting: show the screen once the first scene after boot runs.
|
|
62
|
+
waiting = true;
|
|
63
|
+
game.events.on('poststep', afterBoot);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
game.events.once('destroy', () => {
|
|
68
|
+
unsubscribe();
|
|
69
|
+
game.events.off('poststep', afterBoot);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The game-rendered notification settings: the OS or host permission,
|
|
75
|
+
* reminder topics, and the advertising consent the Korean rules require
|
|
76
|
+
* before any advertising push (`notificationSettings.ts` holds the
|
|
77
|
+
* logic). Night consent covers advertising that arrives between 21:00 and
|
|
78
|
+
* 08:00 KST. Every change is a tap; no switch starts on. The list has a camera
|
|
79
|
+
* of its own, which clips it under WebGL and Canvas alike and keeps hidden
|
|
80
|
+
* rows from taking taps; it scrolls by dragging.
|
|
81
|
+
*/
|
|
82
|
+
export class NotificationSettingsScene extends Phaser.Scene {
|
|
83
|
+
private settings!: StarterNotificationSettings;
|
|
84
|
+
private labels!: StarterNotificationLabels;
|
|
85
|
+
private content!: Phaser.GameObjects.Container;
|
|
86
|
+
private listCamera!: Phaser.Cameras.Scene2D.Camera;
|
|
87
|
+
/** The last tap's outcome, in a strip that does not scroll. */
|
|
88
|
+
private outcome!: Phaser.GameObjects.Text;
|
|
89
|
+
private busy = false;
|
|
90
|
+
/** Each visit's token: a pending action from an earlier visit changes nothing. */
|
|
91
|
+
private visit = 0;
|
|
92
|
+
|
|
93
|
+
constructor() {
|
|
94
|
+
super(notificationSettingsSceneKey);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
create(): void {
|
|
98
|
+
const rawContext = this.registry.get(starterContextKey);
|
|
99
|
+
if (rawContext === undefined || rawContext === null) {
|
|
100
|
+
throw new Error('Starter context is missing from Phaser registry.');
|
|
101
|
+
}
|
|
102
|
+
const context = rawContext as StarterContext;
|
|
103
|
+
this.visit += 1;
|
|
104
|
+
this.busy = false;
|
|
105
|
+
this.labels = starterNotificationLabelsFor(context.locale);
|
|
106
|
+
this.settings = createStarterNotificationSettingsModel(context.platform, {
|
|
107
|
+
locale: context.locale,
|
|
108
|
+
});
|
|
109
|
+
const title = this.add
|
|
110
|
+
.text(480, 40, this.labels.title, { ...textStyle, color: '#ffffff', fontSize: '30px' })
|
|
111
|
+
.setOrigin(0.5);
|
|
112
|
+
const back = this.button(480, 510, this.labels.back, () => {
|
|
113
|
+
this.scene.start('LobbyScene');
|
|
114
|
+
}).setOrigin(0.5);
|
|
115
|
+
this.outcome = this.add
|
|
116
|
+
.text(480, outcomeTop, '', {
|
|
117
|
+
...textStyle,
|
|
118
|
+
align: 'center',
|
|
119
|
+
color: '#f59e0b',
|
|
120
|
+
fontSize: '16px',
|
|
121
|
+
wordWrap: { width: listTextWidth },
|
|
122
|
+
})
|
|
123
|
+
.setOrigin(0.5, 0);
|
|
124
|
+
this.content = this.add.container(0, 0);
|
|
125
|
+
this.listCamera = this.cameras.add(0, minListTop, 960, listBottom - minListTop);
|
|
126
|
+
this.placeListBelowOutcome();
|
|
127
|
+
this.listCamera.ignore([title, back, this.outcome]);
|
|
128
|
+
this.cameras.main.ignore(this.content);
|
|
129
|
+
this.enableScroll();
|
|
130
|
+
const visit = this.visit;
|
|
131
|
+
void this.apply(visit, () => this.settings.load());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private enableScroll(): void {
|
|
135
|
+
let lastY: number | undefined;
|
|
136
|
+
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
|
|
137
|
+
lastY = pointer.y;
|
|
138
|
+
});
|
|
139
|
+
this.input.on('pointerup', () => {
|
|
140
|
+
lastY = undefined;
|
|
141
|
+
});
|
|
142
|
+
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
|
|
143
|
+
if (lastY === undefined || !pointer.isDown) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.scrollTo(this.listCamera.scrollY - (pointer.y - lastY));
|
|
147
|
+
lastY = pointer.y;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private placeListBelowOutcome(): void {
|
|
152
|
+
const top = Math.max(minListTop, Math.ceil(this.outcome.y + this.outcome.height) + 8);
|
|
153
|
+
this.listCamera.setPosition(0, top);
|
|
154
|
+
this.listCamera.setSize(960, Math.max(0, listBottom - top));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private scrollTo(scrollY: number): void {
|
|
158
|
+
const height = this.content.getBounds().height;
|
|
159
|
+
this.listCamera.scrollY = Phaser.Math.Clamp(scrollY, 0, Math.max(0, height - this.listCamera.height));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async apply(visit: number, action: () => Promise<StarterNotificationSettingsView>): Promise<void> {
|
|
163
|
+
if (this.busy) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.busy = true;
|
|
167
|
+
try {
|
|
168
|
+
const view = await action();
|
|
169
|
+
if (visit !== this.visit || !this.sys.isActive()) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.render(view);
|
|
173
|
+
if (view.notices.length > 0) {
|
|
174
|
+
// Recorded in the background: taps need not wait for it.
|
|
175
|
+
void this.settings.recordNoticesShown();
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// The model reports its own failures as messages; anything else is logged.
|
|
179
|
+
console.error('[notifications] settings screen failed.', error);
|
|
180
|
+
} finally {
|
|
181
|
+
if (visit === this.visit) {
|
|
182
|
+
this.busy = false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private render(view: StarterNotificationSettingsView): void {
|
|
188
|
+
const text = this.labels;
|
|
189
|
+
this.content.removeAll(true);
|
|
190
|
+
const visit = this.visit;
|
|
191
|
+
let y = 0;
|
|
192
|
+
const line = (value: string, style: Phaser.Types.GameObjects.Text.TextStyle = textStyle) => {
|
|
193
|
+
const object = this.add.text(60, y, value, { ...style, wordWrap: { width: listTextWidth } });
|
|
194
|
+
this.content.add(object);
|
|
195
|
+
y += object.height + 10;
|
|
196
|
+
};
|
|
197
|
+
const action = (value: string, run: () => Promise<StarterNotificationSettingsView>) => {
|
|
198
|
+
const object = this.button(60, y, `[ ${value} ]`, () => {
|
|
199
|
+
void this.apply(visit, run);
|
|
200
|
+
});
|
|
201
|
+
this.content.add(object);
|
|
202
|
+
y += object.height + 8;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// The outcome of the last tap stays in view above the list, wherever it is scrolled.
|
|
206
|
+
this.outcome.setText(view.message ?? '');
|
|
207
|
+
this.placeListBelowOutcome();
|
|
208
|
+
if (!view.available) {
|
|
209
|
+
if (view.message === undefined) {
|
|
210
|
+
line(text.unavailable);
|
|
211
|
+
}
|
|
212
|
+
this.scrollTo(0);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!view.permission.perTopic) {
|
|
216
|
+
line(view.permission.state === 'granted' ? text.permissionOn : text.permissionOff);
|
|
217
|
+
if (view.permission.canRequest) {
|
|
218
|
+
// Explain before the one-shot OS prompt; the prompt runs only from the tap below.
|
|
219
|
+
line(text.explanation, { ...textStyle, fontSize: '15px' });
|
|
220
|
+
action(text.turnOn, () => this.settings.turnOn());
|
|
221
|
+
}
|
|
222
|
+
if (view.permission.canLogin) {
|
|
223
|
+
action(text.logIn, () => this.settings.logIn());
|
|
224
|
+
}
|
|
225
|
+
if (view.permission.canOpenSettings) {
|
|
226
|
+
action(text.openSettings, () => this.settings.openSettings());
|
|
227
|
+
}
|
|
228
|
+
if (view.permission.canRevoke) {
|
|
229
|
+
action(text.turnOff, () => this.settings.turnOff());
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!view.recording && (view.reminders.length > 0 || view.advertising !== undefined)) {
|
|
233
|
+
// No switch without the player's records: it could not show or change the truth.
|
|
234
|
+
line(view.choices === 'unreadable' ? text.choicesUnreadable : text.notConnected, {
|
|
235
|
+
...textStyle,
|
|
236
|
+
fontSize: '15px',
|
|
237
|
+
});
|
|
238
|
+
} else if (view.reminders.length > 0) {
|
|
239
|
+
line(text.reminders, { ...textStyle, color: '#ffffff' });
|
|
240
|
+
for (const reminder of view.reminders) {
|
|
241
|
+
action(`${reminder.label}: ${reminder.enabled ? text.on : text.off}`, () =>
|
|
242
|
+
this.settings.setReminder(reminder.topic, !reminder.enabled),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (view.agreements.length > 0) {
|
|
247
|
+
// Per-topic hosts (Apps in Toss): service messages the player agrees to
|
|
248
|
+
// one by one; the host's settings withdraw them.
|
|
249
|
+
line(text.agreements, { ...textStyle, color: '#ffffff' });
|
|
250
|
+
for (const agreement of view.agreements) {
|
|
251
|
+
const state = agreement.state === 'granted' ? text.on : text.off;
|
|
252
|
+
if (agreement.canRequest) {
|
|
253
|
+
action(`${agreement.label}: ${state} - ${text.agree}`, () =>
|
|
254
|
+
this.settings.agree(agreement.topic),
|
|
255
|
+
);
|
|
256
|
+
} else {
|
|
257
|
+
line(`${agreement.label}: ${state}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (view.recording && view.advertising !== undefined) {
|
|
262
|
+
const { granted, night, nightSelectable } = view.advertising;
|
|
263
|
+
action(`${text.advertising}: ${granted ? text.on : text.off}`, () =>
|
|
264
|
+
this.settings.setAdvertising({ advertising: !granted }),
|
|
265
|
+
);
|
|
266
|
+
line(text.advertisingHelp, { ...textStyle, fontSize: '15px' });
|
|
267
|
+
if (nightSelectable) {
|
|
268
|
+
action(`${text.night}: ${night ? text.on : text.off}`, () =>
|
|
269
|
+
this.settings.setAdvertising({ night: !night }),
|
|
270
|
+
);
|
|
271
|
+
} else {
|
|
272
|
+
// Night consent adds to advertising consent: not selectable without it.
|
|
273
|
+
line(`${text.night}: ${text.off}\n${text.nightNeedsAdvertising}`, {
|
|
274
|
+
...textStyle,
|
|
275
|
+
color: '#7b8794',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (view.notices.length > 0) {
|
|
280
|
+
line(text.notices, { ...textStyle, color: '#ffffff' });
|
|
281
|
+
for (const notice of view.notices) {
|
|
282
|
+
line(`${notice.title}\n${notice.body}`, { ...textStyle, fontSize: '15px' });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Keep the scroll position across a re-render, within the new height.
|
|
286
|
+
this.scrollTo(this.listCamera.scrollY);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private button(x: number, y: number, value: string, onTap: () => void): Phaser.GameObjects.Text {
|
|
290
|
+
return this.add
|
|
291
|
+
.text(x, y, value, buttonStyle)
|
|
292
|
+
.setPadding(0, 8, 24, 8)
|
|
293
|
+
.setInteractive({ useHandCursor: true })
|
|
294
|
+
// A drag that scrolls the list and ends on a button is not a tap.
|
|
295
|
+
.on('pointerup', (pointer: Phaser.Input.Pointer) => {
|
|
296
|
+
if (pointer.getDistance() < 12) {
|
|
297
|
+
onTap();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -12,6 +12,7 @@ installAitHostBridge({
|
|
|
12
12
|
adGroupIds: runtimeConfig.adGroupIds,
|
|
13
13
|
adPlacementTypes: runtimeConfig.adPlacementTypes,
|
|
14
14
|
...iapBridgeOptions(),
|
|
15
|
+
...notificationBridgeOptions(),
|
|
15
16
|
...identityBridgeOptions(),
|
|
16
17
|
});
|
|
17
18
|
|
|
@@ -38,6 +39,20 @@ function iapBridgeOptions(): Pick<
|
|
|
38
39
|
return runtimeConfig.iapProducts.length === 0 ? {} : { iapProducts: runtimeConfig.iapProducts };
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Notifications v2 topics come from the notification topic catalog's
|
|
44
|
+
* `targets.ait` mappings at build time. Without one the host bridge offers no
|
|
45
|
+
* notifications event channel, so the game exposes no `notificationsV2`; with
|
|
46
|
+
* them the bridge asks for each topic's agreement through
|
|
47
|
+
* `Notification.requestAgreement` and routes notification landings. Agreements
|
|
48
|
+
* are recorded by the game backend, never trusted from the wrapper.
|
|
49
|
+
*/
|
|
50
|
+
function notificationBridgeOptions(): Pick<InstallAitHostBridgeOptions, 'notificationTopics'> {
|
|
51
|
+
return Object.keys(runtimeConfig.notificationTopics).length === 0
|
|
52
|
+
? {}
|
|
53
|
+
: { notificationTopics: runtimeConfig.notificationTopics };
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
function identityBridgeOptions(): Pick<InstallAitHostBridgeOptions, 'dependencies'> {
|
|
42
57
|
return import.meta.env.VITE_MPGD_AIT_MOCK_IDENTITY === '1'
|
|
43
58
|
? {
|
|
@@ -8,6 +8,11 @@ declare module 'virtual:mpgd-ait-runtime-config' {
|
|
|
8
8
|
>>;
|
|
9
9
|
/** Catalog products sold on Apps in Toss (`platformProductIds.ait`), read at build time. */
|
|
10
10
|
readonly iapProducts: readonly import('@mpgd/adapter-ait').AitIapProductConfig[];
|
|
11
|
+
/** Notifications v2 topics mapped for Apps in Toss (`targets.ait`), read at build time. */
|
|
12
|
+
readonly notificationTopics: Readonly<Record<
|
|
13
|
+
string,
|
|
14
|
+
import('@mpgd/adapter-ait/notification-config').AitNotificationTopicMapping
|
|
15
|
+
>>;
|
|
11
16
|
}>;
|
|
12
17
|
|
|
13
18
|
export default runtimeConfig;
|
|
@@ -1,14 +1,33 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
2
4
|
|
|
3
5
|
import type { AitIapProductConfig } from '@mpgd/adapter-ait';
|
|
4
6
|
import { extractAitAdBridgeConfig, type AitAdBridgeConfig } from '@mpgd/adapter-ait/ad-config';
|
|
5
7
|
import { extractAitIapProductConfig } from '@mpgd/adapter-ait/iap-config';
|
|
8
|
+
import {
|
|
9
|
+
readAitNotificationTopicsFile,
|
|
10
|
+
type AitNotificationTopicMapping,
|
|
11
|
+
} from '@mpgd/adapter-ait/notification-config';
|
|
6
12
|
import ttsc from '@ttsc/unplugin/vite';
|
|
7
13
|
import { defineConfig, type Plugin } from 'vite';
|
|
8
14
|
|
|
9
15
|
const aitAppName = process.env.MPGD_AIT_APP_NAME?.trim() || '__GAME_NAME__';
|
|
10
16
|
const aitAdConfig = readAitAdConfig(process.env.MPGD_AD_PLACEMENTS_FILE);
|
|
11
17
|
const aitIapProducts = readAitIapProducts(process.env.MPGD_PRODUCT_CATALOG_FILE);
|
|
18
|
+
// Notifications v2 topics come from the notification topic catalog
|
|
19
|
+
// (`targets.ait`), so the wrapper and the backend share one source:
|
|
20
|
+
// MPGD_NOTIFICATION_TOPICS_FILE (a relative path names a file of the game),
|
|
21
|
+
// else notifications/topics.json when it exists. Without a catalog, or with
|
|
22
|
+
// no topic mapped for Apps in Toss, the host bridge exposes no notifications.
|
|
23
|
+
const aitNotificationTopics = readAitNotificationTopicsFile(
|
|
24
|
+
{
|
|
25
|
+
configured: process.env.MPGD_NOTIFICATION_TOPICS_FILE,
|
|
26
|
+
rootDir: fileURLToPath(new URL('../..', import.meta.url)),
|
|
27
|
+
defaultFile: 'notifications/topics.json',
|
|
28
|
+
},
|
|
29
|
+
{ existsSync, readFileSync, resolve },
|
|
30
|
+
);
|
|
12
31
|
|
|
13
32
|
export default defineConfig(({ command, isPreview }) => {
|
|
14
33
|
const enableLocalAitMock = command === 'serve'
|
|
@@ -33,6 +52,7 @@ export default defineConfig(({ command, isPreview }) => {
|
|
|
33
52
|
adGroupIds: aitAdConfig.adGroupIds,
|
|
34
53
|
adPlacementTypes: aitAdConfig.adPlacementTypes,
|
|
35
54
|
iapProducts: aitIapProducts,
|
|
55
|
+
notificationTopics: aitNotificationTopics,
|
|
36
56
|
}),
|
|
37
57
|
...(command === 'build'
|
|
38
58
|
? [ttsc({ project: 'tsconfig.json', plugins: false })]
|
|
@@ -51,6 +71,7 @@ function createAitRuntimeConfigPlugin(input: Readonly<{
|
|
|
51
71
|
readonly adGroupIds: Readonly<Record<string, string>>;
|
|
52
72
|
readonly adPlacementTypes: AitAdBridgeConfig['adPlacementTypes'];
|
|
53
73
|
readonly iapProducts: readonly AitIapProductConfig[];
|
|
74
|
+
readonly notificationTopics: Readonly<Record<string, AitNotificationTopicMapping>>;
|
|
54
75
|
}>): Plugin {
|
|
55
76
|
const publicId = 'virtual:mpgd-ait-runtime-config';
|
|
56
77
|
const resolvedId = `\0${publicId}`;
|
|
@@ -165,3 +165,17 @@ game-owned production credentials, deployment, signing, or store evidence.
|
|
|
165
165
|
Use `@mpgd/adapter-browser/oneplay` through `PlatformGateway`. Build and smoke the `oneplay` target with the configured kit path. The adapter initializes the H5 SDK before gameplay; `completeLoading()` awaits host acknowledgement. Exit checkpoints must be synchronous through `storage.saveSync`. Use game-owned issued placement IDs and server verification before enabling ads or purchases. Check final HTTPS hosting headers for both ONE store domain families. See the [ONE play guide](https://github.com/imjlk/mpgd-kit/blob/main/docs/ONEPLAY.md) and [official SDK guide](https://onestore-dev.gitbook.io/dev/tools/web-sdk).
|
|
166
166
|
|
|
167
167
|
Wire `rewardRequests` and `commerceServer` in `src/platform/buildGateways/oneplay.ts`. Server-only `@mpgd/game-services/oneplay-reward`, `oneplay-purchase-client`, `oneplay-purchase` and `oneplay-pns` provide signed SSV, authenticated checkout intents, API V7 verification, post-ledger consume/acknowledge and signed notification recovery. The game owns durable stores, routes, an encrypted client journal and atomic cancellation fencing. A completed PNS requires completed finalization. Missing ports keep capabilities unavailable; subscriptions are unsupported. See `commerce.checkout.verified-intent` in the game manifest and the ONE play acceptance notes before enabling policies.
|
|
168
|
+
|
|
169
|
+
## ONEplay official SDK skill
|
|
170
|
+
|
|
171
|
+
From this game's repository root, install or explicitly refresh the official instructions:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
npx skills add ONE-store/onestore_web_sdk_skills --agent codex --yes
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Review the resulting `.agents/skills/onestore-web-sdk` and `skills-lock.json` diff before committing.
|
|
178
|
+
Use the skill's doctor and pre-release checklist alongside Kit's `docs/ONEPLAY.md`.
|
|
179
|
+
Keep SDK imports in composition, game-services credentials on the server, and rewards behind
|
|
180
|
+
purchase verification / signed SSV. The official standalone HTML template is reference material,
|
|
181
|
+
not a replacement for the generated gateway. Do not refresh skills inside production builds.
|
|
@@ -15,6 +15,7 @@ import type { PurchasePresentationEvent } from '@mpgd/game-runtime/ads';
|
|
|
15
15
|
|
|
16
16
|
import { resolveStarterCommerceProducts } from './commerceProducts';
|
|
17
17
|
import { createStorageMonetizationOperationStore } from './monetizationJournal';
|
|
18
|
+
import { resolveStarterServerSession } from './serverSession';
|
|
18
19
|
|
|
19
20
|
const productCatalog = productCatalogJson as ProductCatalog;
|
|
20
21
|
|
|
@@ -47,6 +48,7 @@ export function createStarterGameServices(input: {
|
|
|
47
48
|
readonly analytics?: AnalyticsSink;
|
|
48
49
|
readonly analyticsSessionId?: string;
|
|
49
50
|
}): StarterGameServices {
|
|
51
|
+
const session = resolveStarterServerSession({ once: true });
|
|
50
52
|
return createGameServicesRuntime({
|
|
51
53
|
gateway: input.gateway,
|
|
52
54
|
playerId: input.playerId,
|
|
@@ -64,5 +66,6 @@ export function createStarterGameServices(input: {
|
|
|
64
66
|
? {}
|
|
65
67
|
: { baseUrl: import.meta.env.VITE_MPGD_GAME_SERVICES_URL }),
|
|
66
68
|
transport: resolveGameServicesTransport(import.meta.env.VITE_MPGD_GAME_SERVICES_TRANSPORT),
|
|
69
|
+
...(session === undefined ? {} : { getHeaders: () => session.getHeaders() }),
|
|
67
70
|
});
|
|
68
71
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { GameServicesHeaderResolver } from '@mpgd/game-services';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The game's server-backed session: who is signed in to the game backend,
|
|
5
|
+
* and the `Authorization` header for that session. The starter has none, so
|
|
6
|
+
* `resolveStarterServerSession` returns `undefined`. Set yours with
|
|
7
|
+
* `setStarterServerSession`, over your own session holder and identity
|
|
8
|
+
* service. The commerce client then sends its header. In a game created with
|
|
9
|
+
* `--providers notifications`, the notifications runtime uses the same
|
|
10
|
+
* session and header for the game-services Worker's notification routes.
|
|
11
|
+
*
|
|
12
|
+
* `sessionId` must be the stable session-family id: the `sessionId` your
|
|
13
|
+
* identity service answers to the Worker's `authenticateNotificationSession`
|
|
14
|
+
* for this session's header, the same across token refreshes. Do not pass a
|
|
15
|
+
* per-token or rotating id: a `GuestSessionCoordinator` view's `sessionId`
|
|
16
|
+
* may rotate on refresh, so it is not that id.
|
|
17
|
+
*/
|
|
18
|
+
export interface StarterServerSession {
|
|
19
|
+
/**
|
|
20
|
+
* The signed-in player and session, or `undefined` while signed out.
|
|
21
|
+
* `sessionId` is the stable session-family id (see above).
|
|
22
|
+
*/
|
|
23
|
+
current(): { readonly playerId: string; readonly sessionId: string } | undefined;
|
|
24
|
+
/** Calls `listener` when a session becomes current (sign-in, restore, account switch); returns the unsubscribe. */
|
|
25
|
+
onAuthenticated(
|
|
26
|
+
listener: (session: { readonly playerId: string; readonly sessionId: string }) => void,
|
|
27
|
+
): () => void;
|
|
28
|
+
/** The current session's request headers, such as `{ authorization: 'Bearer …' }`. */
|
|
29
|
+
readonly getHeaders: GameServicesHeaderResolver;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let serverSession: StarterServerSession | undefined;
|
|
33
|
+
/** Whether the commerce client read the session already: it reads it once. */
|
|
34
|
+
let readOnce = false;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The game's server-backed session; `undefined` until the game sets one with
|
|
38
|
+
* `setStarterServerSession`. `once` marks a reader that keeps what it reads
|
|
39
|
+
* (the commerce client), so a later change warns.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveStarterServerSession(
|
|
42
|
+
options: { readonly once?: boolean } = {},
|
|
43
|
+
): StarterServerSession | undefined {
|
|
44
|
+
if (options.once === true) {
|
|
45
|
+
readOnce = true;
|
|
46
|
+
}
|
|
47
|
+
return serverSession;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sets the game's server-backed session. Call it at the top of `main.ts`,
|
|
52
|
+
* before `bootstrap()`: `createStarterGameServices` reads it once, and the
|
|
53
|
+
* notification settings and opens read it until a backend exists. Its
|
|
54
|
+
* `current()` may answer `undefined` until the player signs in. A session
|
|
55
|
+
* set after the commerce client was created does not reach that client,
|
|
56
|
+
* which this warns about.
|
|
57
|
+
*/
|
|
58
|
+
export function setStarterServerSession(session: StarterServerSession | undefined): void {
|
|
59
|
+
if (readOnce && session !== undefined && session !== serverSession) {
|
|
60
|
+
console.warn(
|
|
61
|
+
'[game-services] setStarterServerSession ran after the commerce client was created; '
|
|
62
|
+
+ "it does not send this session's header. Call it before bootstrap().",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
serverSession = session;
|
|
66
|
+
}
|