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 DEFAULT_ACHIEVEMENT_TYPE,
12 DEFAULT_INITIAL_STATE,
13 DEFAULT_LOCALE,
14 DEFAULT_POINT_VALUE,
15 DEFAULT_STEPS_TO_UNLOCK,
16 getAchievement,
17 ignoreMissing,
18 sameBundle,
19 sameNumber,
20 sameText,
21 toDisplayName,
22 withTranslation,
23 translationValue,
24} from "./internal.ts";
25
26export type AchievementConfigurationProps = {
27 /**
28 * Play Games application id from the Google Play Console. Immutable —
29 * changing it replaces the achievement.
30 */
31 applicationId: string;
32 /**
33 * Server-assigned achievement id. Immutable — changing it replaces the
34 * achievement.
35 */
36 achievementId?: string;
37 /**
38 * Achievement type. Immutable after create.
39 * @default "STANDARD"
40 */
41 achievementType?:
42 | gamesConfiguration.AchievementConfigurationAchievementTypeEnum
43 | (string & {});
44 /**
45 * Initial visibility of the achievement.
46 * @default "REVEALED"
47 */
48 initialState?:
49 | gamesConfiguration.AchievementConfigurationInitialStateEnum
50 | (string & {});
51 /**
52 * Steps required to unlock. Required for `INCREMENTAL` achievements.
53 * @default 10
54 */
55 stepsToUnlock?: number;
56 /**
57 * Default-locale display name. If omitted, a unique name is generated.
58 */
59 name?: string;
60 /**
61 * Default-locale description.
62 * @default the name
63 */
64 description?: string;
65 /**
66 * Locale used for `name` and `description` translations.
67 * @default "en-US"
68 */
69 locale?: string;
70 /**
71 * Point value awarded when the achievement unlocks.
72 * @default 5
73 */
74 pointValue?: number;
75 /**
76 * Full draft detail. `name`, `description`, `locale`, and `pointValue`
77 * overlay the default-locale fields when set.
78 */
79 draft?: gamesConfiguration.AchievementConfigurationDetail;
80};
81
82export type AchievementConfiguration = Resource<
83 "GCP.GamesConfiguration.AchievementConfiguration",
84 AchievementConfigurationProps,
85 {
86 /** Server-assigned achievement id. */
87 achievementId: string;
88 /** Play Games application id. */
89 applicationId: string;
90 /** Project id used when the achievement was reconciled. */
91 project: string;
92 /** Achievement type. */
93 achievementType: string | undefined;
94 /** Initial visibility. */
95 initialState: string | undefined;
96 /** Steps to unlock, for incremental achievements. */
97 stepsToUnlock: number | undefined;
98 /** Default-locale name. */
99 name: string | undefined;
100 /** Default-locale description. */
101 description: string | undefined;
102 /** Default locale used for name and description. */
103 locale: string | undefined;
104 /** Point value. */
105 pointValue: number | undefined;
106 /** Opaque update token. */
107 token: string | undefined;
108 /** Draft icon URL. Writes to this field are ignored. */
109 iconUrl: string | undefined;
110 /** Draft sort rank. Writes to this field are ignored. */
111 sortRank: number | undefined;
112 },
113 never,
114 Providers
115>;
116
117/**
118 * A Play Games Services achievement configuration.
119 *
120 * Achievement configurations have no labels field and a server-assigned
121 * id, so only a recorded achievement can be read back. `applicationId` and `achievementId` are identity — changing either
122 * replaces the configuration. `achievementType` is immutable after
123 * create. Name, description, initial state, steps, and point value
124 * update in place.
125 *
126 * ### Creating an Achievement
127 * **Example:** Standard achievement
128 * ```typescript
129 * const achievement = yield* GCP.GamesConfiguration.AchievementConfiguration(
130 * "FirstWin",
131 * {
132 * applicationId: "123456789012",
133 * name: "First Win",
134 * description: "Win your first match",
135 * },
136 * );
137 * ```
138 *
139 * **Example:** Incremental achievement
140 * ```typescript
141 * const achievement = yield* GCP.GamesConfiguration.AchievementConfiguration(
142 * "Centurion",
143 * {
144 * applicationId: "123456789012",
145 * achievementType: "INCREMENTAL",
146 * stepsToUnlock: 100,
147 * name: "Centurion",
148 * description: "Win 100 matches",
149 * pointValue: 20,
150 * },
151 * );
152 * ```
153 *
154 * ### Updating an Achievement
155 * **Example:** Change the description
156 * ```typescript
157 * // Same logical id as before; only the changed props differ.
158 * const achievement = yield* GCP.GamesConfiguration.AchievementConfiguration(
159 * "FirstWin",
160 * {
161 * applicationId: "123456789012",
162 * name: "First Win",
163 * description: "Win a match",
164 * },
165 * );
166 * ```
167 *
168 * @resource
169 * @category GamesConfiguration
170 */
171export const AchievementConfiguration = Resource<AchievementConfiguration>(
172 "GCP.GamesConfiguration.AchievementConfiguration",
173);
174
175export class AchievementConfigurationNotResolved extends Data.TaggedError(
176 "GCP.GamesConfiguration.AchievementConfigurationNotResolved",
177)<{
178 applicationId: string;
179 achievementId: string;
180}> {}
181
182const localeOf = (
183 news: AchievementConfigurationProps,
184 current?: gamesConfiguration.AchievementConfiguration,
185) =>
186 news.locale ??
187 current?.draft?.name?.translations?.[0]?.locale ??
188 current?.draft?.description?.translations?.[0]?.locale ??
189 DEFAULT_LOCALE;
190
191const toAttrs = (
192 achievement: gamesConfiguration.AchievementConfiguration,
193 applicationId: string,
194 project: string,
195 locale = DEFAULT_LOCALE,
196) => ({
197 achievementId: achievement.id ?? "",
198 applicationId,
199 project,
200 achievementType: achievement.achievementType,
201 initialState: achievement.initialState,
202 stepsToUnlock: achievement.stepsToUnlock,
203 name: translationValue(achievement.draft?.name, locale),
204 description: translationValue(achievement.draft?.description, locale),
205 locale,
206 pointValue: achievement.draft?.pointValue,
207 token: achievement.token,
208 iconUrl: achievement.draft?.iconUrl,
209 sortRank: achievement.draft?.sortRank,
210});
211
212const desiredBody = (input: {
213 locale: string;
214 name: string;
215 description: string | undefined;
216 news: AchievementConfigurationProps;
217 current?: gamesConfiguration.AchievementConfiguration;
218}): gamesConfiguration.AchievementConfiguration => {
219 const achievementType =
220 input.news.achievementType ??
221 input.current?.achievementType ??
222 DEFAULT_ACHIEVEMENT_TYPE;
223 const draft = input.news.draft ?? {};
224 const name = withTranslation(draft.name, input.locale, input.name);
225 const description =
226 input.description !== undefined
227 ? withTranslation(draft.description, input.locale, input.description)
228 : (draft.description ??
229 withTranslation(undefined, input.locale, input.name));
230 return {
231 achievementType,
232 initialState:
233 input.news.initialState ??
234 input.current?.initialState ??
235 DEFAULT_INITIAL_STATE,
236 stepsToUnlock:
237 achievementType === "INCREMENTAL"
238 ? (input.news.stepsToUnlock ??
239 input.current?.stepsToUnlock ??
240 DEFAULT_STEPS_TO_UNLOCK)
241 : undefined,
242 token: input.current?.token,
243 id: input.current?.id,
244 draft: {
245 ...draft,
246 name,
247 description,
248 pointValue:
249 input.news.pointValue ??
250 draft.pointValue ??
251 input.current?.draft?.pointValue ??
252 DEFAULT_POINT_VALUE,
253 },
254 };
255};
256
257const needsSync = (
258 current: gamesConfiguration.AchievementConfiguration,
259 desired: gamesConfiguration.AchievementConfiguration,
260) =>
261 !sameText(current.achievementType, desired.achievementType) ||
262 !sameText(current.initialState, desired.initialState) ||
263 !sameNumber(current.stepsToUnlock, desired.stepsToUnlock) ||
264 !sameBundle(current.draft?.name, desired.draft?.name) ||
265 !sameBundle(current.draft?.description, desired.draft?.description) ||
266 !sameNumber(current.draft?.pointValue, desired.draft?.pointValue);
267
268export const AchievementConfigurationProvider = () =>
269 Provider.succeed(AchievementConfiguration, {
270 stables: ["achievementId", "applicationId", "project"],
271
272 diff: Effect.fn(function* ({ news, olds, output }) {
273 if (!isResolved(news)) return undefined;
274 const previousApp = olds?.applicationId ?? output?.applicationId;
275 if (previousApp !== undefined && news.applicationId !== previousApp) {
276 return { action: "replace" as const, deleteFirst: false };
277 }
278 const previousId = olds?.achievementId ?? output?.achievementId;
279 if (
280 previousId !== undefined &&
281 news.achievementId !== undefined &&
282 news.achievementId !== previousId
283 ) {
284 return { action: "replace" as const, deleteFirst: false };
285 }
286 const previousType = olds?.achievementType ?? output?.achievementType;
287 if (
288 previousType !== undefined &&
289 news.achievementType !== undefined &&
290 news.achievementType !== previousType
291 ) {
292 return { action: "replace" as const, deleteFirst: false };
293 }
294 return undefined;
295 }),
296
297 read: Effect.fn(function* ({ olds, output }) {
298 const env = yield* GcpEnvironment.current;
299 // Server-assigned id: only a recorded achievement can be observed.
300 const achievementId = olds?.achievementId ?? output?.achievementId ?? "";
301 const existing = yield* getAchievement(achievementId);
302 if (existing === undefined) return undefined;
303 const attrs = toAttrs(
304 existing,
305 olds?.applicationId ?? output?.applicationId ?? "",
306 env.project,
307 olds?.locale ?? output?.locale,
308 );
309 return output !== undefined ? attrs : Unowned(attrs);
310 }),
311
312 reconcile: Effect.fn(function* ({ id, news, output }) {
313 const env = yield* GcpEnvironment.current;
314 const applicationId = news.applicationId;
315 const name = yield* toDisplayName(
316 id,
317 news.name ?? translationValue(news.draft?.name, news.locale),
318 output?.name,
319 );
320
321 let current = yield* getAchievement(
322 news.achievementId ?? output?.achievementId ?? "",
323 );
324
325 const locale = localeOf(news, current);
326 const desired = desiredBody({
327 locale,
328 name,
329 description: news.description,
330 news,
331 current,
332 });
333
334 if (current === undefined) {
335 current = yield* gamesConfiguration.insertAchievementConfigurations({
336 applicationId,
337 body: desired,
338 });
339 }
340
341 if (current === undefined) {
342 return yield* new AchievementConfigurationNotResolved({
343 applicationId,
344 achievementId: news.achievementId ?? output?.achievementId ?? "",
345 });
346 }
347
348 const synced = desiredBody({
349 locale,
350 name,
351 description: news.description,
352 news,
353 current,
354 });
355 if (needsSync(current, synced)) {
356 current = yield* gamesConfiguration.updateAchievementConfigurations({
357 achievementId: current.id ?? news.achievementId ?? "",
358 body: synced,
359 });
360 }
361
362 return toAttrs(current, applicationId, env.project, locale);
363 }),
364
365 delete: Effect.fn(function* ({ output }) {
366 if (output.achievementId.length === 0) return;
367 yield* ignoreMissing(
368 gamesConfiguration.deleteAchievementConfigurations({
369 achievementId: output.achievementId,
370 }),
371 );
372 }),
373 });