You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

449 lines
16 KiB
PHP

<?php
namespace Isekai\UserPoints\Service;
use Isekai\UserPoints\Utils;
use User;
use stdClass;
use MediaWiki\MediaWikiServices;
/**
* @property int $user_id
* @property string $type
* @property int $points
* @property string|array $timed_points_data
* @property int $locked_points
* @property string|array $locked_points_data
* @property int $next_refresh_time
*/
class IsekaiUserPointsData extends stdClass {}
/**
* @property int $points
* @property array $timedPointsData
* @property int $lockedPoints
* @property array $lockedPointsData
* @property int $nextRefreshTime
*/
class IsekaiUserPoints {
public const DEFAULT_TRANSACTION_TIMEOUT = 60 * 10;
public const MAX_TRANSACTION_TIMEOUT = 60 * 60;
/** @var MediaWikiServices */
private $services;
/** @var \Config */
private $config;
/** @var User */
public $user;
/** @var string */
public $type;
/** @var string */
public $pointName = '';
/** @var string */
private $rawName = '';
/** @var IsekaiUserPointsData|null */
private $mPointData = null;
/** @var bool */
private $pointRecordExists = false;
private $pointDataKeysMap = [
'points' => 'points',
'timedPointsData' => 'timed_points_data',
'lockedPoints' => 'locked_points',
'lockedPointsData' => 'locked_points_data',
'nextRefreshTime' => 'next_refresh_time'
];
public function __construct(User $user, string $type, array $pointConfig, MediaWikiServices $mediaWikiServices = null) {
if (!$mediaWikiServices) {
$this->services = MediaWikiServices::getInstance();
} else {
$this->services = $mediaWikiServices;
}
$this->config = $this->services->getMainConfig();
$this->user = $user;
$this->type = $type;
if (isset($pointConfig['namemsg'])) {
$this->pointName = wfMessage($pointConfig['namemsg'])->text();
$this->rawName = $pointConfig['name'] ?? $this->pointName;
} elseif (isset($pointConfig['name'])) {
$this->pointName = $this->rawName = $pointConfig['name'];
}
}
public function setData($pointData) {
$this->mPointData = $pointData;
}
private function loadPointData() {
if (!$this->mPointData) {
$dbr = $this->services->getDBLoadBalancer()->getConnection(DB_REPLICA);
$this->mPointData = $dbr->selectRow(
'isekai_user_points',
'*',
[
'user_id' => $this->user->getId(),
'type' => $this->type
],
__METHOD__
);
if (!$this->mPointData) {
$this->initDefaultData();
} else {
$this->mPointData->timed_points_data = json_decode($this->mPointData->timed_points_data, true) ?? [];
$this->mPointData->locked_points_data = json_decode($this->mPointData->locked_points_data, true) ?? [];
$this->pointRecordExists = true;
$this->removeExpiredPoints();
}
}
}
private function initDefaultData() {
$this->mPointData = new IsekaiUserPointsData();
$this->mPointData->user_id = $this->user->getId();
$this->mPointData->type = $this->type;
$this->mPointData->points = 0;
$this->mPointData->timed_points_data = [];
$this->mPointData->locked_points = 0;
$this->mPointData->locked_points_data = [];
$this->mPointData->next_refresh_time = null;
}
/**
* Remove expired points
*/
private function removeExpiredPoints() {
$currentTime = time();
if ($this->mPointData->next_refresh_time !== null &&
$currentTime > $this->mPointData->next_refresh_time &&
is_array($this->mPointData->timed_points_data)) {
$touched = false;
$originalPoints = $this->mPointData->points;
foreach ($this->mPointData->timed_points_data as $key => $data) {
list($expireTime, $points) = $data;
if ($currentTime > $expireTime) {
$this->mPointData->points -= $points;
unset($this->mPointData->timed_points_data[$key]);
$touched = true;
}
}
if ($touched) {
// Update next refresh time
if (count($this->mPointData->timed_points_data) > 0) {
// Sort by expire time
usort($this->mPointData->timed_points_data, function($a, $b) {
return $a[0] - $b[0];
});
$this->mPointData->next_refresh_time = $this->mPointData->timed_points_data[0][0];
} else {
$this->mPointData->next_refresh_time = null;
}
$this->save();
$deltaPoints = $originalPoints - $this->mPointData->points;
$hookContainer = $this->services->getHookContainer();
$hookContainer->run('IsekaiUserPoints::PointsExpired', [$this, $deltaPoints]);
$this->savePointLog(-$deltaPoints, 'system', 'point-expired');
}
}
}
public function __get($name) {
$this->loadPointData();
if (isset($this->pointDataKeysMap[$name])) {
$key = $this->pointDataKeysMap[$name];
return $this->mPointData->$key;
} else {
throw new \Exception("Invalid property $name");
}
}
/**
* Add user points
* @param int $points Points to add
* @param int $expireTime Expire time in seconds. -1 for never expire
* @param string $service Service name that add points. Message key is `isekai-userpoints-service-$service`.
* @param ?string $action Action name that add points. Message key is `isekai-userpoints-action-$service-$action`.
*/
public function addPoints($points, $expireTime = -1, $service = 'system', $action = null) {
$this->loadPointData();
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$hookContainer->run('IsekaiUserPoints::BeforeAddPoints', [$this, &$points, &$expireTime, &$service, &$action]);
$this->mPointData->points += $points;
// Handle expire time
$expireTime = intval($expireTime);
$resolvedExipreTime = null;
if ($expireTime > 0) {
// Quantify expire time to 1 day
$expireTime = ceil($expireTime / 86400) * 86400;
$resolvedExipreTime = time() + $expireTime;
$recordExists = false;
foreach ($this->mPointData->timed_points_data as $key => $data) {
$oneExpireTime = $data[0];
if ($resolvedExipreTime === $oneExpireTime) {
$this->mPointData->timed_points_data[$key][1] += $points;
$recordExists = true;
break;
}
}
if (!$recordExists) {
$this->mPointData->timed_points_data[] = [$resolvedExipreTime, $points];
}
if ($this->mPointData->next_refresh_time === null) {
$this->mPointData->next_refresh_time = $resolvedExipreTime;
}
}
/** @todo Add point log */
$this->save();
$this->savePointLog($points, $service, $action, $resolvedExipreTime);
$hookContainer->run('IsekaiUserPoints::AfterAddPoints', [$this, $points, $expireTime, $service, $action]);
}
/**
* Detect if the user has enough points to consume
* @param int $points Points to consume
*/
public function hasEnoughPoints($points) {
$this->loadPointData();
return $this->mPointData->points >= $points;
}
/**
* Consume points
* @param int $points Points to consume
* @param bool $force Force consume points even if the user has not enough points
* @param bool $ignoreSave Ignore save points data after consume points
* @param string $service Service name that consume points. Message key is `isekai-userpoints-service-$service`.
* @param ?string $action Action name that consume points. Message key is `isekai-userpoints-action-$service-$action`.
* @return bool True if the user has enough points to consume
*/
public function consumePoints($points, $force = false, $ignoreSave = false, $service = 'system', $action = null) {
$this->loadPointData();
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$hookContainer->run('IsekaiUserPoints::BeforeConsumePoints', [$this, &$points, &$service, &$action]);
if (!$this->hasEnoughPoints($points) && !$force) {
return false;
}
$this->mPointData->points = max(0, $this->mPointData->points - $points);
if ($this->mPointData->next_refresh_time !== null) { // Update timed points data
$consumedPoints = $points;
$touched = false;
foreach ($this->mPointData->timed_points_data as $key => $data) {
list($expireTime, $onePoints) = $data;
if ($onePoints <= $consumedPoints) {
$consumedPoints -= $onePoints;
unset($this->mPointData->timed_points_data[$key]);
$touched = true;
} else { // All points consumed
$this->mPointData->timed_points_data[$key][1] -= $consumedPoints;
$consumedPoints = 0;
$touched = true;
break;
}
}
if ($touched) {
// Update next refresh time
if (count($this->mPointData->timed_points_data) > 0) {
// Sort by expire time
usort($this->mPointData->timed_points_data, function($a, $b) {
return $a[0] - $b[0];
});
$this->mPointData->next_refresh_time = $this->mPointData->timed_points_data[0][0];
} else {
$this->mPointData->next_refresh_time = null;
}
}
}
if (!$ignoreSave) {
$this->save();
}
$this->savePointLog(-$points, $service, $action);
$hookContainer->run('IsekaiUserPoints::AfterConsumePoints', [$this, $points, $service, $action]);
return true;
}
/**
* Start a consume points transaction
* @param int $points Points to consume
* @param int|null $timeout Transaction timeout in seconds
* @param string $service Service name that consume points. Message key is `isekai-userpoints-service-$service`.
* @param ?string $action Action name that consume points. Message key is `isekai-userpoints-action-$service-$action`.
* @return bool True if the transaction started successfully
*/
public function startConsumePointsTransaction($points, $timeout = null, $service = 'system', $action = null) {
$this->loadPointData();
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$hookContainer->run('IsekaiUserPoints::BeforeConsumePoints', [$this, &$points, &$service, &$action]);
if (!$this->hasEnoughPoints($points)) {
return false;
}
$timeout = min($timeout ?? self::DEFAULT_TRANSACTION_TIMEOUT, self::MAX_TRANSACTION_TIMEOUT);
$expireTime = time() + $timeout;
// Generate random string for transaction id
$transactionId = '';
do {
$transactionId = bin2hex(random_bytes(8));
} while (isset($this->mPointData->locked_points_data[$transactionId]));
$result = $this->consumePoints($points, false, true);
if (!$result) return false;
$this->mPointData->locked_points += $points;
$this->mPointData->locked_points_data[$transactionId] = [$expireTime, $points, $service, $action];
$this->save();
return $transactionId;
}
/**
* Commit a consume points transaction
* @param string $transactionId Transaction id
* @return bool True if the transaction is committed successfully
*/
public function commitConsumePointsTransaction($transactionId) {
if (!isset($this->mPointData->locked_points_data[$transactionId])) {
return false;
}
list($expireTime, $points, $service, $action) = $this->mPointData->locked_points_data[$transactionId];
$this->mPointData->locked_points = max(0, $this->mPointData->locked_points - $points);
unset($this->mPointData->locked_points_data[$transactionId]);
$this->save();
$hookContainer = MediaWikiServices::getInstance()->getHookContainer();
$this->savePointLog(-$points, $service, $action);
$hookContainer->run('IsekaiUserPoints::AfterConsumePoints', [$this, $points, $service, $action]);
return true;
}
/**
* Rollback a consume points transaction
* @param string $transactionId Transaction id
* @return bool True if the transaction is rolled back successfully
*/
public function rollbackConsumePointsTransaction($transactionId) {
if (!isset($this->mPointData->locked_points_data[$transactionId])) {
return false;
}
list($expireTime, $points) = $this->mPointData->locked_points_data[$transactionId];
$this->mPointData->locked_points = max(0, $this->mPointData->locked_points - $points);
$this->mPointData->points += $points;
unset($this->mPointData->locked_points_data[$transactionId]);
$this->save();
return true;
}
public function getPointInfo() {
return [
'name' => Utils::getPointName($this->type),
'icon' => Utils::getPointIcon($this->type),
];
}
/**
* Save point data
*/
private function save() {
$dbw = $this->services->getDBLoadBalancer()->getConnection(DB_PRIMARY);
if ($this->pointRecordExists) {
$dbw->update(
'isekai_user_points',
[
'points' => $this->mPointData->points,
'timed_points_data' => json_encode($this->mPointData->timed_points_data),
'locked_points' => $this->mPointData->locked_points,
'locked_points_data' => json_encode($this->mPointData->locked_points_data),
'next_refresh_time' => $this->mPointData->next_refresh_time
],
[
'user_id' => $this->user->getId(),
'type' => $this->type,
],
__METHOD__
);
} else {
$dbw->insert(
'isekai_user_points',
[
'user_id' => $this->mPointData->user_id,
'type' => $this->mPointData->type,
'points' => $this->mPointData->points,
'timed_points_data' => json_encode($this->mPointData->timed_points_data),
'locked_points' => $this->mPointData->locked_points,
'locked_points_data' => json_encode($this->mPointData->locked_points_data),
'next_refresh_time' => $this->mPointData->next_refresh_time,
],
__METHOD__
);
$this->pointRecordExists = true;
}
}
private function savePointLog($points, $service, $action, $expireTime = null) {
$dbw = $this->services->getDBLoadBalancer()->getConnection(DB_PRIMARY);
if ($expireTime) {
$expireDate = date('Y-m-d', $expireTime);
} else {
$expireDate = null;
}
$dbw->insert(
'isekai_user_points_log',
[
'user_id' => $this->user->getId(),
'point_type' => $this->type,
'points' => $points,
'point_expire' => $expireDate,
'service' => $service,
'action' => $action,
'timestamp' => wfTimestampNow()
],
__METHOD__
);
}
}