PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / games / store.php

store.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.7, at includes/games/store.php

549 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Games store.
4 *
5 * CRUD for the two games tables. Scores are client-asserted (arcade
6 * trust model): the server clamps and sanitizes what it can — the
7 * game must be server-registered, the score is a non-negative int,
8 * the meta blob is a bounded flat scalar map — and exposes the
9 * `desktop_mode_game_score_pre_save` filter for plugins that want
10 * stricter validation.
11 *
12 * The challenge state machine is enforced HERE, not in REST:
13 * `pending → accepted | declined`, `accepted → completed`. Every
14 * mutation bumps `updated_at_ms`, the Heartbeat high-water mark.
15 *
16 * @package WPDesktopMode
17 * @since 0.9.6
18 */
19
20 defined( 'ABSPATH' ) || exit;
21
22 /**
23 * Bound and sanitize a score meta blob: flat map, slug keys, scalar
24 * values only. Strings are text-sanitized and truncated; the map is
25 * capped at 20 keys so a hostile client can't fatten the table.
26 *
27 * @since 0.9.6
28 *
29 * @param mixed $meta Raw caller input.
30 * @return array Sanitized flat map.
31 */
32 function desktop_mode_games_sanitize_score_meta( $meta ) {
33 if ( ! is_array( $meta ) ) {
34 return array();
35 }
36 $out = array();
37 foreach ( $meta as $key => $value ) {
38 if ( count( $out ) >= 20 ) {
39 break;
40 }
41 $key = sanitize_key( (string) $key );
42 if ( '' === $key ) {
43 continue;
44 }
45 if ( is_int( $value ) || is_float( $value ) ) {
46 $out[ $key ] = $value + 0;
47 } elseif ( is_bool( $value ) ) {
48 $out[ $key ] = $value;
49 } elseif ( is_string( $value ) ) {
50 $out[ $key ] = mb_substr( sanitize_text_field( $value ), 0, 200 );
51 }
52 // Nested arrays/objects are dropped — flat scalars only.
53 }
54 return $out;
55 }
56
57 /**
58 * Persist a finished game run.
59 *
60 * @since 0.9.6
61 *
62 * @param string $game Registered game id.
63 * @param int $user_id Player.
64 * @param int $score Primary sort value. Clamped to >= 0.
65 * @param array $meta Flexible per-game fields (see the game's
66 * `score_columns`).
67 * @return int|WP_Error Row id on success.
68 */
69 function desktop_mode_games_save_score( $game, $user_id, $score, $meta = array() ) {
70 global $wpdb;
71
72 $game = sanitize_key( (string) $game );
73 $user_id = (int) $user_id;
74 $score = max( 0, (int) $score );
75 $meta = desktop_mode_games_sanitize_score_meta( $meta );
76
77 if ( ! desktop_mode_games_is_registered( $game ) ) {
78 return new WP_Error(
79 'desktop_mode_unknown_game',
80 __( 'Unknown game.', 'desktop-mode' ),
81 array( 'status' => 404 )
82 );
83 }
84 if ( $user_id <= 0 ) {
85 return new WP_Error(
86 'desktop_mode_invalid_user',
87 __( 'A valid user is required to save a score.', 'desktop-mode' ),
88 array( 'status' => 400 )
89 );
90 }
91
92 /**
93 * Short-circuit / veto filter for score saves. Return a
94 * `WP_Error` to reject the save (surfaced to the client), or
95 * `null` to proceed. The extension point for anti-cheat
96 * plugins (rate limits, plausibility checks).
97 *
98 * @since 0.9.6
99 *
100 * @param null|WP_Error $pre Null to proceed.
101 * @param string $game Game id.
102 * @param int $user_id Player.
103 * @param int $score Clamped score.
104 * @param array $meta Sanitized meta map.
105 */
106 $pre = apply_filters( 'desktop_mode_game_score_pre_save', null, $game, $user_id, $score, $meta );
107 if ( is_wp_error( $pre ) ) {
108 return $pre;
109 }
110
111 $tables = desktop_mode_games_table_names();
112 $ok = $wpdb->insert(
113 $tables['scores'],
114 array(
115 'game' => $game,
116 'user_id' => $user_id,
117 'score' => $score,
118 'meta' => wp_json_encode( $meta ),
119 'created_at_ms' => desktop_mode_games_now_ms(),
120 ),
121 array( '%s', '%d', '%d', '%s', '%d' )
122 );
123 if ( false === $ok ) {
124 return new WP_Error(
125 'desktop_mode_score_save_failed',
126 __( 'Could not save the score.', 'desktop-mode' ),
127 array( 'status' => 500 )
128 );
129 }
130 $id = (int) $wpdb->insert_id;
131
132 /**
133 * Fires after a game score is saved.
134 *
135 * @since 0.9.6
136 *
137 * @param int $id Score row id.
138 * @param string $game Game id.
139 * @param int $user_id Player.
140 * @param int $score Saved score.
141 * @param array $meta Saved meta map.
142 */
143 do_action( 'desktop_mode_game_score_saved', $id, $game, $user_id, $score, $meta );
144
145 return $id;
146 }
147
148 /**
149 * Leaderboard query.
150 *
151 * @since 0.9.6
152 *
153 * @param string $game Registered game id.
154 * @param array $args {
155 * @type int $page 1-based page. Default 1.
156 * @type int $per_page Rows per page, 1–100. Default 25.
157 * @type string $orderby 'score' | 'created'. Default 'score'.
158 * @type string $order 'asc' | 'desc'. Default 'desc'.
159 * @type int $user_id Restrict to one player. Default 0 (all).
160 * }
161 * @return array{ rows: array[], total: int }
162 */
163 function desktop_mode_games_get_scores( $game, $args = array() ) {
164 global $wpdb;
165
166 $game = sanitize_key( (string) $game );
167 $page = max( 1, (int) ( $args['page'] ?? 1 ) );
168 $per_page = min( 100, max( 1, (int) ( $args['per_page'] ?? 25 ) ) );
169 $orderby = ( 'created' === ( $args['orderby'] ?? '' ) ) ? 'created_at_ms' : 'score';
170 $order = ( 'asc' === strtolower( (string) ( $args['order'] ?? 'desc' ) ) ) ? 'ASC' : 'DESC';
171 $user_id = (int) ( $args['user_id'] ?? 0 );
172
173 $tables = desktop_mode_games_table_names();
174 $where = 'game = %s';
175 $params = array( $game );
176 if ( $user_id > 0 ) {
177 $where .= ' AND user_id = %d';
178 $params[] = $user_id;
179 }
180
181 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
182 $total = (int) $wpdb->get_var(
183 $wpdb->prepare( "SELECT COUNT(*) FROM {$tables['scores']} WHERE {$where}", $params )
184 );
185
186 $params[] = $per_page;
187 $params[] = ( $page - 1 ) * $per_page;
188 // `$orderby` / `$order` are clamped to fixed identifiers above —
189 // safe to interpolate.
190 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
191 $rows = $wpdb->get_results(
192 $wpdb->prepare(
193 "SELECT * FROM {$tables['scores']}
194 WHERE {$where}
195 ORDER BY {$orderby} {$order}, id ASC
196 LIMIT %d OFFSET %d",
197 $params
198 ),
199 ARRAY_A
200 );
201
202 return array(
203 'rows' => array_map( 'desktop_mode_games_shape_score', (array) $rows ),
204 'total' => $total,
205 );
206 }
207
208 /**
209 * Shape a scores row for the wire: camelCase keys + player display
210 * name and avatar.
211 *
212 * @since 0.9.6
213 *
214 * @param array $row Raw table row.
215 * @return array
216 */
217 function desktop_mode_games_shape_score( $row ) {
218 $user_id = (int) $row['user_id'];
219 $user = get_userdata( $user_id );
220 $meta = json_decode( (string) ( $row['meta'] ?? '' ), true );
221 return array(
222 'id' => (int) $row['id'],
223 'game' => (string) $row['game'],
224 'userId' => $user_id,
225 'userName' => $user ? $user->display_name : __( 'Former user', 'desktop-mode' ),
226 'userAvatar' => $user ? get_avatar_url( $user_id, array( 'size' => 48 ) ) : '',
227 'score' => (int) $row['score'],
228 'meta' => is_array( $meta ) ? $meta : array(),
229 'createdAtMs' => (int) $row['created_at_ms'],
230 );
231 }
232
233 /**
234 * Create a score-to-beat challenge.
235 *
236 * @since 0.9.6
237 *
238 * @param string $game Registered game id.
239 * @param int $challenger_id Sender.
240 * @param int $recipient_id Receiver.
241 * @param int $score_to_beat The challenger's score.
242 * @param array $score_meta The challenger's score meta map.
243 * @return int|WP_Error Challenge id on success.
244 */
245 function desktop_mode_games_create_challenge( $game, $challenger_id, $recipient_id, $score_to_beat, $score_meta = array() ) {
246 global $wpdb;
247
248 $game = sanitize_key( (string) $game );
249 $challenger_id = (int) $challenger_id;
250 $recipient_id = (int) $recipient_id;
251
252 if ( ! desktop_mode_games_is_registered( $game ) ) {
253 return new WP_Error(
254 'desktop_mode_unknown_game',
255 __( 'Unknown game.', 'desktop-mode' ),
256 array( 'status' => 404 )
257 );
258 }
259 if ( $recipient_id <= 0 || ! get_userdata( $recipient_id ) ) {
260 return new WP_Error(
261 'desktop_mode_invalid_recipient',
262 __( 'The challenged user does not exist.', 'desktop-mode' ),
263 array( 'status' => 400 )
264 );
265 }
266 if ( $recipient_id === $challenger_id ) {
267 return new WP_Error(
268 'desktop_mode_self_challenge',
269 __( 'You cannot challenge yourself.', 'desktop-mode' ),
270 array( 'status' => 400 )
271 );
272 }
273
274 $now = desktop_mode_games_now_ms();
275 $tables = desktop_mode_games_table_names();
276 $ok = $wpdb->insert(
277 $tables['challenges'],
278 array(
279 'game' => $game,
280 'challenger_id' => $challenger_id,
281 'recipient_id' => $recipient_id,
282 'score_to_beat' => max( 0, (int) $score_to_beat ),
283 'score_meta' => wp_json_encode( desktop_mode_games_sanitize_score_meta( $score_meta ) ),
284 'state' => 'pending',
285 'created_at_ms' => $now,
286 'updated_at_ms' => $now,
287 ),
288 array( '%s', '%d', '%d', '%d', '%s', '%s', '%d', '%d' )
289 );
290 if ( false === $ok ) {
291 return new WP_Error(
292 'desktop_mode_challenge_create_failed',
293 __( 'Could not create the challenge.', 'desktop-mode' ),
294 array( 'status' => 500 )
295 );
296 }
297 $id = (int) $wpdb->insert_id;
298
299 /**
300 * Fires after a game challenge is created.
301 *
302 * @since 0.9.6
303 *
304 * @param int $id Challenge id.
305 * @param array $row The challenge row.
306 */
307 do_action( 'desktop_mode_game_challenge_created', $id, desktop_mode_games_get_challenge( $id ) );
308
309 return $id;
310 }
311
312 /**
313 * Fetch one challenge row.
314 *
315 * @since 0.9.6
316 *
317 * @param int $id Challenge id.
318 * @return array|null Raw table row.
319 */
320 function desktop_mode_games_get_challenge( $id ) {
321 global $wpdb;
322 $tables = desktop_mode_games_table_names();
323 $row = $wpdb->get_row(
324 $wpdb->prepare( "SELECT * FROM {$tables['challenges']} WHERE id = %d", (int) $id ),
325 ARRAY_A
326 );
327 return is_array( $row ) ? $row : null;
328 }
329
330 /**
331 * Transition a challenge to `accepted` or `declined`. Only valid
332 * from `pending`.
333 *
334 * @since 0.9.6
335 *
336 * @param int $id Challenge id.
337 * @param string $state 'accepted' | 'declined'.
338 * @return true|WP_Error
339 */
340 function desktop_mode_games_set_challenge_state( $id, $state ) {
341 global $wpdb;
342
343 if ( ! in_array( $state, array( 'accepted', 'declined' ), true ) ) {
344 return new WP_Error(
345 'desktop_mode_invalid_challenge_state',
346 __( 'Invalid challenge state.', 'desktop-mode' ),
347 array( 'status' => 400 )
348 );
349 }
350 $row = desktop_mode_games_get_challenge( $id );
351 if ( ! $row ) {
352 return new WP_Error(
353 'desktop_mode_challenge_not_found',
354 __( 'Challenge not found.', 'desktop-mode' ),
355 array( 'status' => 404 )
356 );
357 }
358 if ( 'pending' !== $row['state'] ) {
359 return new WP_Error(
360 'desktop_mode_challenge_state_conflict',
361 __( 'This challenge has already been decided.', 'desktop-mode' ),
362 array( 'status' => 409 )
363 );
364 }
365
366 // Monotonic bump: a transition landing in the same millisecond as
367 // the previous write must still move `updated_at_ms` forward, or
368 // version-gated Heartbeat clients would never see the change.
369 $now = max( desktop_mode_games_now_ms(), (int) $row['updated_at_ms'] + 1 );
370 $tables = desktop_mode_games_table_names();
371 $wpdb->update(
372 $tables['challenges'],
373 array(
374 'state' => $state,
375 'decided_at_ms' => $now,
376 'updated_at_ms' => $now,
377 ),
378 array( 'id' => (int) $id ),
379 array( '%s', '%d', '%d' ),
380 array( '%d' )
381 );
382
383 if ( 'accepted' === $state ) {
384 /**
385 * Fires after a challenge is accepted by its recipient.
386 *
387 * @since 0.9.6
388 *
389 * @param int $id Challenge id.
390 * @param array $row The (pre-transition) challenge row.
391 */
392 do_action( 'desktop_mode_game_challenge_accepted', (int) $id, $row );
393 } else {
394 /**
395 * Fires after a challenge is declined by its recipient.
396 *
397 * @since 0.9.6
398 *
399 * @param int $id Challenge id.
400 * @param array $row The (pre-transition) challenge row.
401 */
402 do_action( 'desktop_mode_game_challenge_declined', (int) $id, $row );
403 }
404
405 return true;
406 }
407
408 /**
409 * Record the recipient's run against an accepted challenge. Also
410 * persists the run as a normal leaderboard score row.
411 *
412 * @since 0.9.6
413 *
414 * @param int $id Challenge id.
415 * @param int $score The recipient's score.
416 * @param array $meta The recipient's score meta map.
417 * @return array|WP_Error The updated challenge row.
418 */
419 function desktop_mode_games_complete_challenge( $id, $score, $meta = array() ) {
420 global $wpdb;
421
422 $row = desktop_mode_games_get_challenge( $id );
423 if ( ! $row ) {
424 return new WP_Error(
425 'desktop_mode_challenge_not_found',
426 __( 'Challenge not found.', 'desktop-mode' ),
427 array( 'status' => 404 )
428 );
429 }
430 if ( 'accepted' !== $row['state'] ) {
431 return new WP_Error(
432 'desktop_mode_challenge_state_conflict',
433 __( 'Only an accepted challenge can be completed.', 'desktop-mode' ),
434 array( 'status' => 409 )
435 );
436 }
437
438 $score = max( 0, (int) $score );
439 $meta = desktop_mode_games_sanitize_score_meta( $meta );
440 $result = $score > (int) $row['score_to_beat'] ? 'beaten' : 'not_beaten';
441
442 // The run also lands on the leaderboard — a challenge game is a
443 // real game. A veto from the pre-save filter aborts the whole
444 // completion so the two writes can't diverge.
445 $score_id = desktop_mode_games_save_score( $row['game'], (int) $row['recipient_id'], $score, $meta );
446 if ( is_wp_error( $score_id ) ) {
447 return $score_id;
448 }
449
450 // Same monotonic-bump rule as `set_challenge_state()` — see there.
451 $now = max( desktop_mode_games_now_ms(), (int) $row['updated_at_ms'] + 1 );
452 $tables = desktop_mode_games_table_names();
453 $wpdb->update(
454 $tables['challenges'],
455 array(
456 'state' => 'completed',
457 'result' => $result,
458 'result_score' => $score,
459 'result_meta' => wp_json_encode( $meta ),
460 'completed_at_ms' => $now,
461 'updated_at_ms' => $now,
462 ),
463 array( 'id' => (int) $id ),
464 array( '%s', '%s', '%d', '%s', '%d', '%d' ),
465 array( '%d' )
466 );
467
468 $updated = desktop_mode_games_get_challenge( $id );
469
470 /**
471 * Fires after a challenge run is completed.
472 *
473 * @since 0.9.6
474 *
475 * @param int $id Challenge id.
476 * @param string $result 'beaten' | 'not_beaten'.
477 * @param array $row The updated challenge row.
478 */
479 do_action( 'desktop_mode_game_challenge_completed', (int) $id, $result, $updated );
480
481 return $updated;
482 }
483
484 /**
485 * Challenges involving a user (as challenger or recipient) whose
486 * `updated_at_ms` exceeds the given high-water mark. The Heartbeat
487 * delta query.
488 *
489 * @since 0.9.6
490 *
491 * @param int $user_id Viewer.
492 * @param int $since_ms Last-seen `updated_at_ms`. 0 = everything.
493 * @param int $cap Row cap.
494 * @return array[] Raw rows, oldest change first.
495 */
496 function desktop_mode_games_get_challenges_for_user( $user_id, $since_ms = 0, $cap = 50 ) {
497 global $wpdb;
498 $tables = desktop_mode_games_table_names();
499 $rows = $wpdb->get_results(
500 $wpdb->prepare(
501 "SELECT * FROM {$tables['challenges']}
502 WHERE ( challenger_id = %d OR recipient_id = %d )
503 AND updated_at_ms > %d
504 ORDER BY updated_at_ms ASC
505 LIMIT %d",
506 (int) $user_id,
507 (int) $user_id,
508 (int) $since_ms,
509 max( 1, (int) $cap )
510 ),
511 ARRAY_A
512 );
513 return (array) $rows;
514 }
515
516 /**
517 * Shape a challenge row for the wire: camelCase keys plus display
518 * name + avatar for both parties.
519 *
520 * @since 0.9.6
521 *
522 * @param array $row Raw table row.
523 * @return array
524 */
525 function desktop_mode_games_shape_challenge( $row ) {
526 $challenger = get_userdata( (int) $row['challenger_id'] );
527 $recipient = get_userdata( (int) $row['recipient_id'] );
528 $score_meta = json_decode( (string) ( $row['score_meta'] ?? '' ), true );
529 $result_meta = json_decode( (string) ( $row['result_meta'] ?? '' ), true );
530 return array(
531 'id' => (int) $row['id'],
532 'game' => (string) $row['game'],
533 'challengerId' => (int) $row['challenger_id'],
534 'challengerName' => $challenger ? $challenger->display_name : __( 'Former user', 'desktop-mode' ),
535 'challengerAvatar' => $challenger ? get_avatar_url( $challenger->ID, array( 'size' => 48 ) ) : '',
536 'recipientId' => (int) $row['recipient_id'],
537 'recipientName' => $recipient ? $recipient->display_name : __( 'Former user', 'desktop-mode' ),
538 'recipientAvatar' => $recipient ? get_avatar_url( $recipient->ID, array( 'size' => 48 ) ) : '',
539 'scoreToBeat' => (int) $row['score_to_beat'],
540 'scoreMeta' => is_array( $score_meta ) ? $score_meta : array(),
541 'state' => (string) $row['state'],
542 'result' => null !== $row['result'] ? (string) $row['result'] : null,
543 'resultScore' => null !== $row['result_score'] ? (int) $row['result_score'] : null,
544 'resultMeta' => is_array( $result_meta ) ? $result_meta : array(),
545 'createdAtMs' => (int) $row['created_at_ms'],
546 'updatedAtMs' => (int) $row['updated_at_ms'],
547 );
548 }
549