| 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 |
|