@mpgd/game-runtime 0.2.6 → 0.3.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/README.md +111 -0
- package/dist/actions/index.d.ts +5 -0
- package/dist/actions/index.js +37 -3
- package/dist/ads/index.d.ts +63 -0
- package/dist/ads/index.js +625 -0
- package/dist/audio/index.d.ts +14 -0
- package/dist/audio/index.js +35 -0
- package/dist/game/index.d.ts +41 -0
- package/dist/game/index.js +103 -0
- package/dist/phaser/index.d.ts +2 -0
- package/dist/phaser/index.js +4 -1
- package/dist/presentation/index.d.ts +40 -0
- package/dist/presentation/index.js +120 -0
- package/docs/phaser.md +11 -0
- package/package.json +19 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { GameServicesOperationClient, GameServicesRewardedAdResult } from '@mpgd/game-services/operations';
|
|
2
|
+
import type { PlatformGateway, RewardedAdResult } from '@mpgd/platform';
|
|
3
|
+
import type { AdPlacementInput } from '@mpgd/platform/ads';
|
|
4
|
+
import { type GameExecutionController } from '../index.js';
|
|
5
|
+
import { type GameLifecycleState } from '../platform/index.js';
|
|
6
|
+
import { type FullScreenPresentationScope } from '../presentation/index.js';
|
|
7
|
+
import { type AdWaitDeadline, type PurchasePresentationEvent } from '../ads/index.js';
|
|
8
|
+
import { type GameActionCoordinator, type GameActionReconciliationPort } from '../actions/index.js';
|
|
9
|
+
import { type ObserverErrorHandler } from '../observers.js';
|
|
10
|
+
/** Structural service ports keep the core usable without DOM, HTTP, or schema libraries. */
|
|
11
|
+
export interface GameServicePorts {
|
|
12
|
+
readonly client?: GameServicesOperationClient;
|
|
13
|
+
readonly monetizationRecovery?: {
|
|
14
|
+
recoverRewardResult(idempotencyKey: string, platform: RewardedAdResult): Promise<GameServicesRewardedAdResult>;
|
|
15
|
+
reconcile(): Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface GamePlatformRuntime<T extends PlatformGateway = PlatformGateway, S extends GameServicePorts = GameServicePorts> {
|
|
19
|
+
readonly gateway: T;
|
|
20
|
+
readonly execution: GameExecutionController;
|
|
21
|
+
readonly presentation: FullScreenPresentationScope;
|
|
22
|
+
readonly services: S;
|
|
23
|
+
readonly actions?: GameActionCoordinator;
|
|
24
|
+
/** Reconcile existing journal entries only; this method never opens platform UI. */
|
|
25
|
+
reconcile(): Promise<void>;
|
|
26
|
+
/** Game teardown; native observers and application-owned late recovery may continue. */
|
|
27
|
+
dispose(): void;
|
|
28
|
+
}
|
|
29
|
+
/** Construct once above every scene, preserving target gateway extensions and receiver binding. */
|
|
30
|
+
export declare function createGamePlatformRuntime<T extends PlatformGateway, S extends GameServicePorts>(input: {
|
|
31
|
+
readonly gateway: T;
|
|
32
|
+
readonly createServices: (gateway: T) => S;
|
|
33
|
+
readonly initialLifecycleState: GameLifecycleState;
|
|
34
|
+
readonly canShow?: (placement: AdPlacementInput) => boolean;
|
|
35
|
+
readonly deadline?: AdWaitDeadline;
|
|
36
|
+
readonly reconciliation?: GameActionReconciliationPort;
|
|
37
|
+
readonly purchasePresentation?: {
|
|
38
|
+
subscribe(listener: (event: PurchasePresentationEvent) => void): () => void;
|
|
39
|
+
};
|
|
40
|
+
readonly onObserverError?: ObserverErrorHandler;
|
|
41
|
+
}): GamePlatformRuntime<T, S>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createGameExecutionController } from '../index.js';
|
|
2
|
+
import { bindGameLifecycle } from '../platform/index.js';
|
|
3
|
+
import { createFullScreenPresentationScope, } from '../presentation/index.js';
|
|
4
|
+
import { createAdClaimEvidenceRecoveryObserver, createCoordinatedPlatformGateway, } from '../ads/index.js';
|
|
5
|
+
import { createGameActionCoordinator, } from '../actions/index.js';
|
|
6
|
+
import { observe } from '../observers.js';
|
|
7
|
+
/** Construct once above every scene, preserving target gateway extensions and receiver binding. */
|
|
8
|
+
export function createGamePlatformRuntime(input) {
|
|
9
|
+
const execution = createGameExecutionController({
|
|
10
|
+
...(input.onObserverError === undefined ? {} : { onListenerError: input.onObserverError }),
|
|
11
|
+
});
|
|
12
|
+
const presentation = createFullScreenPresentationScope({ execution });
|
|
13
|
+
let services;
|
|
14
|
+
let lateEvidence;
|
|
15
|
+
const source = input.gateway.ads.provider;
|
|
16
|
+
const coordinated = source === undefined ? undefined : createCoordinatedPlatformGateway({
|
|
17
|
+
gateway: input.gateway, provider: source, presentation,
|
|
18
|
+
canShow: (placement) => input.canShow?.(placement) !== false && (placement.format !== 'rewarded' || services?.monetizationRecovery !== undefined),
|
|
19
|
+
...(input.deadline === undefined ? {} : { deadline: input.deadline }),
|
|
20
|
+
...(input.purchasePresentation === undefined ? {} : { purchasePresentation: input.purchasePresentation }),
|
|
21
|
+
...(input.onObserverError === undefined ? {} : { onObserverError: input.onObserverError }),
|
|
22
|
+
onClaimEvidence: (observation) => lateEvidence?.(observation),
|
|
23
|
+
onLateResult: async ({ input: request, result }) => {
|
|
24
|
+
if (request.format !== 'rewarded' || result.eligibility === 'eligible' || result.presentation !== 'not-started'
|
|
25
|
+
|| result.evidence !== undefined || result.claimEvidence !== undefined || services?.monetizationRecovery === undefined) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (result.outcome !== 'failed' && result.outcome !== 'unavailable' && result.outcome !== 'skipped') {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const recovered = await services.monetizationRecovery.recoverRewardResult(request.idempotencyKey, { status: result.outcome, rewardGranted: false });
|
|
32
|
+
if (!disposed) {
|
|
33
|
+
actions?.confirmRecoveredRewardResult(request.idempotencyKey, recovered);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const gateway = (coordinated ?? input.gateway);
|
|
38
|
+
let lifecycle;
|
|
39
|
+
let actions;
|
|
40
|
+
let unsubscribeResume;
|
|
41
|
+
let disposed = false;
|
|
42
|
+
const dispose = () => {
|
|
43
|
+
if (disposed) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
disposed = true;
|
|
47
|
+
execution.destroy();
|
|
48
|
+
actions?.dispose();
|
|
49
|
+
coordinated?.dispose();
|
|
50
|
+
presentation.dispose();
|
|
51
|
+
lifecycle?.dispose();
|
|
52
|
+
if (unsubscribeResume !== undefined) {
|
|
53
|
+
observe(unsubscribeResume, input.onObserverError);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const reconcile = async () => {
|
|
57
|
+
if (disposed) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await services?.monetizationRecovery?.reconcile();
|
|
61
|
+
if (!disposed && input.reconciliation !== undefined) {
|
|
62
|
+
await actions?.reconcile();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
services = input.createServices(gateway);
|
|
67
|
+
const recovery = services.monetizationRecovery;
|
|
68
|
+
if (recovery !== undefined) {
|
|
69
|
+
lateEvidence = createAdClaimEvidenceRecoveryObserver({ recovery, onResult: async (request, result) => {
|
|
70
|
+
if (!disposed && result.status === 'rejected') {
|
|
71
|
+
actions?.confirmRecoveredRewardResult(request.idempotencyKey, result);
|
|
72
|
+
}
|
|
73
|
+
if (!disposed && input.reconciliation !== undefined) {
|
|
74
|
+
await actions?.reconcile();
|
|
75
|
+
}
|
|
76
|
+
} });
|
|
77
|
+
}
|
|
78
|
+
if (services.client !== undefined) {
|
|
79
|
+
actions = createGameActionCoordinator({ execution, client: services.client,
|
|
80
|
+
...(coordinated === undefined ? {} : { presentation }),
|
|
81
|
+
...(input.reconciliation === undefined ? {} : { reconciliation: input.reconciliation }),
|
|
82
|
+
...(input.onObserverError === undefined ? {} : { onObserverError: input.onObserverError }),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
lifecycle = bindGameLifecycle({ controller: execution, source: gateway.lifecycle, initialState: input.initialLifecycleState,
|
|
86
|
+
...(input.onObserverError === undefined ? {} : { onError: input.onObserverError }),
|
|
87
|
+
});
|
|
88
|
+
unsubscribeResume = gateway.lifecycle.onResume(() => { observe(reconcile, input.onObserverError); });
|
|
89
|
+
return Object.freeze({
|
|
90
|
+
gateway,
|
|
91
|
+
execution,
|
|
92
|
+
presentation,
|
|
93
|
+
services,
|
|
94
|
+
...(actions === undefined ? {} : { actions }),
|
|
95
|
+
reconcile,
|
|
96
|
+
dispose,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
dispose();
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/phaser/index.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface BindPhaserGameSceneInput {
|
|
|
16
16
|
readonly resetInput: () => void;
|
|
17
17
|
readonly renderingPolicy: 'visibility';
|
|
18
18
|
readonly audio?: GameplayAudioSink;
|
|
19
|
+
/** A game-owned bindGameAudio projection survives this scene's disposal. */
|
|
20
|
+
readonly audioOwner?: 'scene' | 'game';
|
|
19
21
|
readonly uiScope?: {
|
|
20
22
|
dispose(): void;
|
|
21
23
|
};
|
package/dist/phaser/index.js
CHANGED
|
@@ -9,6 +9,9 @@ export function bindPhaserGameScene(input) {
|
|
|
9
9
|
if (controller.getSnapshot().status === 'destroyed') {
|
|
10
10
|
throw new Error('Cannot bind a destroyed game execution controller.');
|
|
11
11
|
}
|
|
12
|
+
if (input.audioOwner === 'game' && audio !== undefined) {
|
|
13
|
+
throw new TypeError('Game-owned audio cannot also be controlled by a scene sink.');
|
|
14
|
+
}
|
|
12
15
|
const events = scene.sys.events;
|
|
13
16
|
let disposed = false;
|
|
14
17
|
let sceneEnded = false;
|
|
@@ -174,7 +177,7 @@ export function bindPhaserGameScene(input) {
|
|
|
174
177
|
}
|
|
175
178
|
const blocked = snapshot.blocked;
|
|
176
179
|
const simulationUnsupported = blocked.simulation && !blocked['gameplay-input'];
|
|
177
|
-
const audioUnsupported = blocked.audio && audio === undefined;
|
|
180
|
+
const audioUnsupported = blocked.audio && audio === undefined && input.audioOwner !== 'game';
|
|
178
181
|
const notifySimulation = simulationUnsupported && !reportedSimulation;
|
|
179
182
|
const notifyAudio = audioUnsupported && !reportedAudio;
|
|
180
183
|
reportedSimulation = simulationUnsupported;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { GameExecutionController } from '../index.js';
|
|
2
|
+
import { type UiListener } from '../ui/index.js';
|
|
3
|
+
import type { ObserverErrorHandler } from '../observers.js';
|
|
4
|
+
export type FullScreenPresentationKind = 'purchase' | 'rewarded' | 'interstitial';
|
|
5
|
+
export interface FullScreenPresentationOwner {
|
|
6
|
+
readonly kind: FullScreenPresentationKind;
|
|
7
|
+
readonly invocationId: string;
|
|
8
|
+
readonly state: 'requested' | 'open' | 'unknown';
|
|
9
|
+
}
|
|
10
|
+
export interface FullScreenPresentationSnapshot {
|
|
11
|
+
readonly version: number;
|
|
12
|
+
readonly status: 'ready' | 'busy' | 'unknown' | 'disposed';
|
|
13
|
+
readonly owner?: FullScreenPresentationOwner;
|
|
14
|
+
}
|
|
15
|
+
export interface FullScreenPresentationLease {
|
|
16
|
+
/** Native facts only. A caller deadline or a backend grant cannot confirm closure. */
|
|
17
|
+
markStarted(): void;
|
|
18
|
+
markUnknown(): void;
|
|
19
|
+
confirmClosed(): void;
|
|
20
|
+
}
|
|
21
|
+
export interface FullScreenPresentationScope {
|
|
22
|
+
readonly execution: GameExecutionController;
|
|
23
|
+
acquire(input: {
|
|
24
|
+
readonly kind: FullScreenPresentationKind;
|
|
25
|
+
readonly invocationId: string;
|
|
26
|
+
}): FullScreenPresentationLease;
|
|
27
|
+
getSnapshot(): FullScreenPresentationSnapshot;
|
|
28
|
+
subscribe(listener: UiListener<FullScreenPresentationSnapshot>): () => void;
|
|
29
|
+
/** Stop new presentations and detach projections; retain any live native ownership. */
|
|
30
|
+
dispose(): void;
|
|
31
|
+
}
|
|
32
|
+
export declare class PresentationExecutionError extends Error {
|
|
33
|
+
readonly code: 'busy' | 'disposed' | 'key-conflict' | 'history-full' | 'already-completed';
|
|
34
|
+
constructor(code: 'busy' | 'disposed' | 'key-conflict' | 'history-full' | 'already-completed');
|
|
35
|
+
}
|
|
36
|
+
/** Share one scope above all screens, providers, and purchase/ad entrypoints. */
|
|
37
|
+
export declare function createFullScreenPresentationScope(input: {
|
|
38
|
+
readonly execution: GameExecutionController;
|
|
39
|
+
readonly onObserverError?: ObserverErrorHandler;
|
|
40
|
+
}): FullScreenPresentationScope;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createGameUiBridge } from '../ui/index.js';
|
|
2
|
+
export class PresentationExecutionError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(`Presentation cannot start: ${code}`);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = 'PresentationExecutionError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Share one scope above all screens, providers, and purchase/ad entrypoints. */
|
|
11
|
+
export function createFullScreenPresentationScope(input) {
|
|
12
|
+
let disposed = false;
|
|
13
|
+
let version = 0;
|
|
14
|
+
let owner;
|
|
15
|
+
let snapshot = Object.freeze({ version, status: 'ready' });
|
|
16
|
+
const bridge = createGameUiBridge({
|
|
17
|
+
initialSnapshot: snapshot,
|
|
18
|
+
...(input.onObserverError === undefined ? {} : { onListenerError: input.onObserverError }),
|
|
19
|
+
});
|
|
20
|
+
const isDisposed = () => disposed || input.execution.getSnapshot().status === 'destroyed';
|
|
21
|
+
function publish() {
|
|
22
|
+
let status = 'ready';
|
|
23
|
+
if (isDisposed()) {
|
|
24
|
+
status = 'disposed';
|
|
25
|
+
}
|
|
26
|
+
else if (owner?.info.state === 'unknown') {
|
|
27
|
+
status = 'unknown';
|
|
28
|
+
}
|
|
29
|
+
else if (owner !== undefined) {
|
|
30
|
+
status = 'busy';
|
|
31
|
+
}
|
|
32
|
+
snapshot = Object.freeze({
|
|
33
|
+
version: ++version,
|
|
34
|
+
status,
|
|
35
|
+
...(owner === undefined ? {} : { owner: owner.info }),
|
|
36
|
+
});
|
|
37
|
+
if (!disposed) {
|
|
38
|
+
bridge.setSnapshot(snapshot);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
execution: input.execution,
|
|
43
|
+
acquire(request) {
|
|
44
|
+
if (isDisposed()) {
|
|
45
|
+
throw new PresentationExecutionError('disposed');
|
|
46
|
+
}
|
|
47
|
+
if (owner !== undefined) {
|
|
48
|
+
throw new PresentationExecutionError('busy');
|
|
49
|
+
}
|
|
50
|
+
const kind = request.kind;
|
|
51
|
+
const invocationId = request.invocationId;
|
|
52
|
+
if (!['purchase', 'rewarded', 'interstitial'].includes(kind)
|
|
53
|
+
|| typeof invocationId !== 'string' || invocationId.trim() === '' || invocationId.length > 512) {
|
|
54
|
+
throw new TypeError('Invalid presentation identity.');
|
|
55
|
+
}
|
|
56
|
+
if (isDisposed()) {
|
|
57
|
+
throw new PresentationExecutionError('disposed');
|
|
58
|
+
}
|
|
59
|
+
if (owner !== undefined) {
|
|
60
|
+
throw new PresentationExecutionError('busy');
|
|
61
|
+
}
|
|
62
|
+
const owned = {
|
|
63
|
+
info: Object.freeze({ kind, invocationId, state: 'requested' }),
|
|
64
|
+
};
|
|
65
|
+
// Install ownership before execution listeners can reenter during acquisition.
|
|
66
|
+
owner = owned;
|
|
67
|
+
try {
|
|
68
|
+
owned.block = input.execution.acquireBlock({
|
|
69
|
+
reason: `presentation:${kind}`, channels: ['simulation', 'gameplay-input', 'audio'],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (owner === owned) {
|
|
74
|
+
owner = undefined;
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
publish();
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
markStarted() {
|
|
81
|
+
if (owner === owned && owned.info.state !== 'open') {
|
|
82
|
+
owned.info = Object.freeze({ ...owned.info, state: 'open' });
|
|
83
|
+
publish();
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
markUnknown() {
|
|
87
|
+
if (owner === owned && owned.info.state !== 'unknown') {
|
|
88
|
+
owned.info = Object.freeze({ ...owned.info, state: 'unknown' });
|
|
89
|
+
publish();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
confirmClosed() {
|
|
93
|
+
if (owner !== owned) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
owner = undefined;
|
|
97
|
+
publish();
|
|
98
|
+
owned.block?.release();
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
getSnapshot() {
|
|
103
|
+
if (isDisposed() && snapshot.status !== 'disposed') {
|
|
104
|
+
publish();
|
|
105
|
+
}
|
|
106
|
+
return snapshot;
|
|
107
|
+
},
|
|
108
|
+
subscribe: (listener) => bridge.subscribeSnapshot(listener),
|
|
109
|
+
dispose() {
|
|
110
|
+
if (disposed) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Publish disposal before detaching view projections, without proving native closure.
|
|
114
|
+
snapshot = Object.freeze({ version: ++version, status: 'disposed', ...(owner === undefined ? {} : { owner: owner.info }) });
|
|
115
|
+
disposed = true;
|
|
116
|
+
bridge.setSnapshot(snapshot);
|
|
117
|
+
bridge.destroy();
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
package/docs/phaser.md
CHANGED
|
@@ -104,3 +104,14 @@ subsequent explicit `dispose()` retries it while the runtime is active. Successf
|
|
|
104
104
|
cleanup stays idempotent; terminal runtime destruction never retries an unmute.
|
|
105
105
|
Complete this explicit cleanup before reusing the same sink for another binding,
|
|
106
106
|
as required by the single-writer ownership contract. No retry timer is installed.
|
|
107
|
+
|
|
108
|
+
## Game-owned sound manager
|
|
109
|
+
|
|
110
|
+
Use `bindGameAudio` from `@mpgd/game-runtime/audio` above every scene and select
|
|
111
|
+
`audioOwner: 'game'` in scene bindings. That option prevents scene-level audio
|
|
112
|
+
projection and suppresses the missing-scene-audio diagnostic; providing both
|
|
113
|
+
it and a scene audio sink is rejected. Connect the game sound manager's mute
|
|
114
|
+
property to the sink. Scene shutdown only detaches its input/render binding.
|
|
115
|
+
Destroy the shared runtime on the Phaser game's destroy event, then detach the
|
|
116
|
+
audio projection. Unknown native presentation and application-owned claim
|
|
117
|
+
recovery survive scene disposal and must not be canceled by SDK cleanup.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Gameplay execution, scoped UI and action coordination with optional Phaser scene bindings.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -34,6 +34,22 @@
|
|
|
34
34
|
"./phaser": {
|
|
35
35
|
"types": "./dist/phaser/index.d.ts",
|
|
36
36
|
"default": "./dist/phaser/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./presentation": {
|
|
39
|
+
"types": "./dist/presentation/index.d.ts",
|
|
40
|
+
"default": "./dist/presentation/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./ads": {
|
|
43
|
+
"types": "./dist/ads/index.d.ts",
|
|
44
|
+
"default": "./dist/ads/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./audio": {
|
|
47
|
+
"types": "./dist/audio/index.d.ts",
|
|
48
|
+
"default": "./dist/audio/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./game": {
|
|
51
|
+
"types": "./dist/game/index.d.ts",
|
|
52
|
+
"default": "./dist/game/index.js"
|
|
37
53
|
}
|
|
38
54
|
},
|
|
39
55
|
"files": [
|
|
@@ -48,7 +64,8 @@
|
|
|
48
64
|
"@types/node": "^24.0.0"
|
|
49
65
|
},
|
|
50
66
|
"dependencies": {
|
|
51
|
-
"@mpgd/game-services": "0.
|
|
67
|
+
"@mpgd/game-services": "0.18.0",
|
|
68
|
+
"@mpgd/platform": "0.15.0"
|
|
52
69
|
},
|
|
53
70
|
"repository": {
|
|
54
71
|
"type": "git",
|