PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
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 0.8.6 All 33 releases
desktop-mode / includes / games / store.php

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

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