1import * as gamesConfiguration from "@distilled.cloud/gcp/gamesConfiguration_v1configuration";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 defaultScoreFormat,
12 DEFAULT_LOCALE,
13 DEFAULT_SCORE_ORDER,
14 getLeaderboard,
15 ignoreMissing,
16 jsonEqual,
17 MAX_LEADERBOARD_NAME_LENGTH,
18 sameBundle,
19 sameText,
20 toDisplayName,
21 translationValue,
22 withTranslation,
23} from "./internal.ts";
24
25export type LeaderboardConfigurationProps = {
26 /**
27 * Play Games application id from the Google Play Console. Immutable —
28 * changing it replaces the leaderboard.
29 */
30 applicationId: string;
31 /**
32 * Server-assigned leaderboard id. Immutable — changing it replaces the
33 * leaderboard.
34 */
35 leaderboardId?: string;
36 /**
37 * Sort order for posted scores.
38 * @default "LARGER_IS_BETTER"
39 */
40 scoreOrder?:
41 | gamesConfiguration.LeaderboardConfigurationScoreOrderEnum
42 | (string & {});
43 /**
44 * Minimum score that can be posted (int64 string).
45 */
46 scoreMin?: string;
47 /**
48 * Maximum score that can be posted (int64 string).
49 */
50 scoreMax?: string;
51 /**
52 * Default-locale display name. If omitted, a unique name is generated.
53 */
54 name?: string;
55 /**
56 * Locale used for `name` translations.
57 * @default "en-US"
58 */
59 locale?: string;
60 /**
61 * Score formatting. Defaults to a whole-number `NUMERIC` format.
62 */
63 scoreFormat?: gamesConfiguration.GamesNumberFormatConfiguration;
64 /**
65 * Full draft detail. `name`, `locale`, and `scoreFormat` overlay the
66 * default-locale fields when set.
67 */
68 draft?: gamesConfiguration.LeaderboardConfigurationDetail;
69};
70
71export type LeaderboardConfiguration = Resource<
72 "GCP.GamesConfiguration.LeaderboardConfiguration",
73 LeaderboardConfigurationProps,
74 {
75 /** Server-assigned leaderboard id. */
76 leaderboardId: string;
77 /** Play Games application id. */
78 applicationId: string;
79 /** Project id used when the leaderboard was reconciled. */
80 project: string;
81 /** Sort order for posted scores. */
82 scoreOrder: string | undefined;
83 /** Minimum postable score. */
84 scoreMin: string | undefined;
85 /** Maximum postable score. */
86 scoreMax: string | undefined;
87 /** Default-locale name with the Alchemy ownership prefix stripped. */
88 name: string | undefined;
89 /** Default locale used for the name. */
90 locale: string | undefined;
91 /** Score formatting. */
92 scoreFormat: gamesConfiguration.GamesNumberFormatConfiguration | undefined;
93 /** Opaque update token. */
94 token: string | undefined;
95 /** Draft icon URL. Writes to this field are ignored. */
96 iconUrl: string | undefined;
97 /** Draft sort rank. Writes to this field are ignored. */
98 sortRank: number | undefined;
99 },
100 never,
101 Providers
102>;
103
104/**
105 * A Play Games Services leaderboard configuration.
106 *
107 * Leaderboard configurations have no labels field and a server-assigned
108 * id, so only a recorded leaderboard can be read back. `applicationId` and `leaderboardId` are identity — changing either
109 * replaces the configuration. Score order, bounds, name, and format
110 * update in place.
111 *
112 * ### Creating a Leaderboard
113 * **Example:** High-score board
114 * ```typescript
115 * const board = yield* GCP.GamesConfiguration.LeaderboardConfiguration(
116 * "HighScore",
117 * {
118 * applicationId: "123456789012",
119 * name: "High Score",
120 * scoreOrder: "LARGER_IS_BETTER",
121 * },
122 * );
123 * ```
124 *
125 * **Example:** Fastest-time board
126 * ```typescript
127 * const board = yield* GCP.GamesConfiguration.LeaderboardConfiguration(
128 * "BestTime",
129 * {
130 * applicationId: "123456789012",
131 * name: "Best Time",
132 * scoreOrder: "SMALLER_IS_BETTER",
133 * scoreFormat: { numberFormatType: "TIME_DURATION" },
134 * },
135 * );
136 * ```
137 *
138 * ### Updating a Leaderboard
139 * **Example:** Change the name
140 * ```typescript
141 * // Same logical id as before; only the changed props differ.
142 * const board = yield* GCP.GamesConfiguration.LeaderboardConfiguration(
143 * "HighScore",
144 * {
145 * applicationId: "123456789012",
146 * name: "All-time High Score",
147 * },
148 * );
149 * ```
150 *
151 * @resource
152 * @category GamesConfiguration
153 */
154export const LeaderboardConfiguration = Resource<LeaderboardConfiguration>(
155 "GCP.GamesConfiguration.LeaderboardConfiguration",
156);
157
158export class LeaderboardConfigurationNotResolved extends Data.TaggedError(
159 "GCP.GamesConfiguration.LeaderboardConfigurationNotResolved",
160)<{
161 applicationId: string;
162 leaderboardId: string;
163}> {}
164
165const localeOf = (
166 news: LeaderboardConfigurationProps,
167 current?: gamesConfiguration.LeaderboardConfiguration,
168) =>
169 news.locale ??
170 current?.draft?.name?.translations?.[0]?.locale ??
171 DEFAULT_LOCALE;
172
173const toAttrs = (
174 leaderboard: gamesConfiguration.LeaderboardConfiguration,
175 applicationId: string,
176 project: string,
177 locale = DEFAULT_LOCALE,
178) => ({
179 leaderboardId: leaderboard.id ?? "",
180 applicationId,
181 project,
182 scoreOrder: leaderboard.scoreOrder,
183 scoreMin: leaderboard.scoreMin,
184 scoreMax: leaderboard.scoreMax,
185 name: translationValue(leaderboard.draft?.name, locale),
186 locale,
187 scoreFormat: leaderboard.draft?.scoreFormat,
188 token: leaderboard.token,
189 iconUrl: leaderboard.draft?.iconUrl,
190 sortRank: leaderboard.draft?.sortRank,
191});
192
193const desiredBody = (input: {
194 locale: string;
195 name: string;
196 news: LeaderboardConfigurationProps;
197 current?: gamesConfiguration.LeaderboardConfiguration;
198}): gamesConfiguration.LeaderboardConfiguration => {
199 const draft = input.news.draft ?? {};
200 const name = withTranslation(draft.name, input.locale, input.name);
201 return {
202 scoreOrder:
203 input.news.scoreOrder ?? input.current?.scoreOrder ?? DEFAULT_SCORE_ORDER,
204 scoreMin: input.news.scoreMin ?? input.current?.scoreMin,
205 scoreMax: input.news.scoreMax ?? input.current?.scoreMax,
206 token: input.current?.token,
207 id: input.current?.id,
208 draft: {
209 ...draft,
210 name,
211 scoreFormat:
212 input.news.scoreFormat ??
213 draft.scoreFormat ??
214 input.current?.draft?.scoreFormat ??
215 defaultScoreFormat(),
216 },
217 };
218};
219
220const needsSync = (
221 current: gamesConfiguration.LeaderboardConfiguration,
222 desired: gamesConfiguration.LeaderboardConfiguration,
223) =>
224 !sameText(current.scoreOrder, desired.scoreOrder) ||
225 !sameText(current.scoreMin, desired.scoreMin) ||
226 !sameText(current.scoreMax, desired.scoreMax) ||
227 !sameBundle(current.draft?.name, desired.draft?.name) ||
228 !jsonEqual(current.draft?.scoreFormat, desired.draft?.scoreFormat);
229
230export const LeaderboardConfigurationProvider = () =>
231 Provider.succeed(LeaderboardConfiguration, {
232 stables: ["leaderboardId", "applicationId", "project"],
233
234 diff: Effect.fn(function* ({ news, olds, output }) {
235 if (!isResolved(news)) return undefined;
236 const previousApp = olds?.applicationId ?? output?.applicationId;
237 if (previousApp !== undefined && news.applicationId !== previousApp) {
238 return { action: "replace" as const, deleteFirst: false };
239 }
240 const previousId = olds?.leaderboardId ?? output?.leaderboardId;
241 if (
242 previousId !== undefined &&
243 news.leaderboardId !== undefined &&
244 news.leaderboardId !== previousId
245 ) {
246 return { action: "replace" as const, deleteFirst: false };
247 }
248 return undefined;
249 }),
250
251 read: Effect.fn(function* ({ olds, output }) {
252 const env = yield* GcpEnvironment.current;
253 // Server-assigned id: only a recorded leaderboard can be observed.
254 const leaderboardId = olds?.leaderboardId ?? output?.leaderboardId ?? "";
255 const existing = yield* getLeaderboard(leaderboardId);
256 if (existing === undefined) return undefined;
257 const attrs = toAttrs(
258 existing,
259 olds?.applicationId ?? output?.applicationId ?? "",
260 env.project,
261 olds?.locale ?? output?.locale,
262 );
263 return output !== undefined ? attrs : Unowned(attrs);
264 }),
265
266 reconcile: Effect.fn(function* ({ id, news, output }) {
267 const env = yield* GcpEnvironment.current;
268 const applicationId = news.applicationId;
269 const name = yield* toDisplayName(
270 id,
271 news.name ?? translationValue(news.draft?.name, news.locale),
272 output?.name,
273 MAX_LEADERBOARD_NAME_LENGTH,
274 );
275
276 let current = yield* getLeaderboard(
277 news.leaderboardId ?? output?.leaderboardId ?? "",
278 );
279
280 const locale = localeOf(news, current);
281 const desired = desiredBody({
282 locale,
283 name,
284 news,
285 current,
286 });
287
288 if (current === undefined) {
289 current = yield* gamesConfiguration.insertLeaderboardConfigurations({
290 applicationId,
291 body: desired,
292 });
293 }
294
295 if (current === undefined) {
296 return yield* new LeaderboardConfigurationNotResolved({
297 applicationId,
298 leaderboardId: news.leaderboardId ?? output?.leaderboardId ?? "",
299 });
300 }
301
302 const synced = desiredBody({
303 locale,
304 name,
305 news,
306 current,
307 });
308 if (needsSync(current, synced)) {
309 current = yield* gamesConfiguration.updateLeaderboardConfigurations({
310 leaderboardId: current.id ?? news.leaderboardId ?? "",
311 body: synced,
312 });
313 }
314
315 return toAttrs(current, applicationId, env.project, locale);
316 }),
317
318 delete: Effect.fn(function* ({ output }) {
319 if (output.leaderboardId.length === 0) return;
320 yield* ignoreMissing(
321 gamesConfiguration.deleteLeaderboardConfigurations({
322 leaderboardId: output.leaderboardId,
323 }),
324 );
325 }),
326 });