Merge pull request 'feature/avoid-repeating-places-in-game' (#38) from feature/avoid-repeating-places-in-game into develop
All checks were successful
default-pipeline default-pipeline #189

Reviewed-on: https://gitea.e5tv.hu/esoko/mapguesser/pulls/38
Reviewed-by: Pőcze Bence <bence@pocze.ch>
This commit is contained in:
Balázs Vigh 2021-05-09 10:58:53 +02:00
commit 216d30329f
10 changed files with 397 additions and 35 deletions

View File

@ -0,0 +1,12 @@
CREATE TABLE `user_played_place` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`place_id` int(10) unsigned NOT NULL,
`last_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`occurrences` int(10) NOT NULL DEFAULT 1,
PRIMARY KEY(`id`),
KEY `user_id` (`user_id`),
KEY `place_id` (`place_id`),
CONSTRAINT `user_played_place_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`),
CONSTRAINT `user_played_place_place_id` FOREIGN KEY (`place_id`) REFERENCES `places` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

View File

@ -8,6 +8,7 @@ use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\Repository\MultiRoomRepository;
use MapGuesser\Repository\UserConfirmationRepository;
use MapGuesser\Repository\UserPasswordResetterRepository;
use MapGuesser\Repository\UserPlayedPlaceRepository;
use MapGuesser\Repository\UserRepository;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@ -25,6 +26,8 @@ class MaintainDatabaseCommand extends Command
private MultiRoomRepository $multiRoomRepository;
private UserPlayedPlaceRepository $userPlayedPlaceRepository;
public function __construct()
{
parent::__construct();
@ -34,6 +37,7 @@ class MaintainDatabaseCommand extends Command
$this->userConfirmationRepository = new UserConfirmationRepository();
$this->userPasswordResetterRepository = new UserPasswordResetterRepository();
$this->multiRoomRepository = new MultiRoomRepository();
$this->userPlayedPlaceRepository = new UserPlayedPlaceRepository();
}
public function configure(): void
@ -81,6 +85,10 @@ class MaintainDatabaseCommand extends Command
$this->pdm->deleteFromDb($userPasswordResetter);
}
foreach ($this->userPlayedPlaceRepository->getAllByUser($user) as $userPlayedPlace) {
$this->pdm->deleteFromDb($userPlayedPlace);
}
$this->pdm->deleteFromDb($user);
}

View File

@ -7,8 +7,10 @@ use MapGuesser\Response\JsonContent;
use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\Multi\MultiConnector;
use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\PersistentData\Model\UserPlayedPlace;
use MapGuesser\Repository\MultiRoomRepository;
use MapGuesser\Repository\PlaceRepository;
use MapGuesser\Repository\UserPlayedPlaceRepository;
class GameFlowController
{
@ -25,6 +27,8 @@ class GameFlowController
private PlaceRepository $placeRepository;
private UserPlayedPlaceRepository $userPlayedPlaceRepository;
public function __construct(IRequest $request)
{
$this->request = $request;
@ -32,6 +36,7 @@ class GameFlowController
$this->multiConnector = new MultiConnector();
$this->multiRoomRepository = new MultiRoomRepository();
$this->placeRepository = new PlaceRepository();
$this->userPlayedPlaceRepository = new UserPlayedPlaceRepository();
}
public function initialData(): IContent
@ -145,9 +150,32 @@ class GameFlowController
$session->set('state', $state);
$this->saveVisit($last);
return new JsonContent($response);
}
// save the selected place for the round in UserPlayedPlace
private function saveVisit($last): void
{
$session = $this->request->session();
$userId = $session->get('userId');
if(isset($userId)) {
$placeId = $last['placeId'];
$userPlayedPlace = $this->userPlayedPlaceRepository->getByUserIdAndPlaceId($userId, $placeId);
if(!$userPlayedPlace) {
$userPlayedPlace = new UserPlayedPlace();
$userPlayedPlace->setUserId($userId);
$userPlayedPlace->setPlaceId($placeId);
} else {
$userPlayedPlace->incrementOccurrences();
}
$userPlayedPlace->setLastTimeDate(new DateTime());
$this->pdm->saveToDb($userPlayedPlace);
}
}
public function multiGuess(): IContent
{
$roomId = $this->request->query('roomId');
@ -224,7 +252,10 @@ class GameFlowController
private function startNewGame(array &$state, int $mapId): void
{
$places = $this->placeRepository->getRandomNForMapWithValidPano($mapId, static::NUMBER_OF_ROUNDS);
$session = $this->request->session();
$userId = $session->get('userId');
$places = $this->placeRepository->getRandomNPlaces($mapId, static::NUMBER_OF_ROUNDS, $userId);
$state['rounds'] = [];
$state['currentRound'] = 0;

View File

@ -14,6 +14,7 @@ use MapGuesser\PersistentData\Model\UserPasswordResetter;
use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\Repository\UserConfirmationRepository;
use MapGuesser\Repository\UserPasswordResetterRepository;
use MapGuesser\Repository\UserPlayedPlaceRepository;
use MapGuesser\Repository\UserRepository;
use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent;
@ -32,6 +33,8 @@ class LoginController
private UserPasswordResetterRepository $userPasswordResetterRepository;
private UserPlayedPlaceRepository $userPlayedPlaceRepository;
public function __construct(IRequest $request)
{
$this->request = $request;
@ -39,6 +42,7 @@ class LoginController
$this->userRepository = new UserRepository();
$this->userConfirmationRepository = new UserConfirmationRepository();
$this->userPasswordResetterRepository = new UserPasswordResetterRepository();
$this->userPlayedPlaceRepository = new UserPlayedPlaceRepository();
}
public function getLoginForm()
@ -430,6 +434,10 @@ class LoginController
$user = $this->userRepository->getById($confirmation->getUserId());
foreach ($this->userPlayedPlaceRepository->getAllByUser($user) as $userPlayedPlace) {
$this->pdm->deleteFromDb($userPlayedPlace);
}
$this->pdm->deleteFromDb($user);
\Container::$dbConnection->commit();

View File

@ -10,6 +10,7 @@ use MapGuesser\PersistentData\Model\Place;
use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\Repository\MapRepository;
use MapGuesser\Repository\PlaceRepository;
use MapGuesser\Repository\UserPlayedPlaceRepository;
use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent;
use MapGuesser\Util\Geo\Bounds;
@ -27,12 +28,15 @@ class MapAdminController implements ISecured
private PlaceRepository $placeRepository;
private UserPlayedPlaceRepository $userPlayedPlaceRepository;
public function __construct(IRequest $request)
{
$this->request = $request;
$this->pdm = new PersistentDataManager();
$this->mapRepository = new MapRepository();
$this->placeRepository = new PlaceRepository();
$this->userPlayedPlaceRepository = new UserPlayedPlaceRepository();
}
public function authorize(): bool
@ -138,7 +142,7 @@ class MapAdminController implements ISecured
$place = $this->placeRepository->getById((int) $placeRaw['id']);
$this->pdm->deleteFromDb($place);
$this->deletePlace($place);
}
}
@ -178,10 +182,19 @@ class MapAdminController implements ISecured
return new JsonContent(['success' => true]);
}
private function deletePlace(Place $place): void
{
foreach ($this->userPlayedPlaceRepository->getAllByPlace($place) as $userPlayedPlace) {
$this->pdm->deleteFromDb($userPlayedPlace);
}
$this->pdm->deleteFromDb($place);
}
private function deletePlaces(Map $map): void
{
foreach ($this->placeRepository->getAllForMap($map) as $place) {
$this->pdm->deleteFromDb($place);
$this->deletePlace($place);
}
}

View File

@ -11,6 +11,7 @@ use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\PersistentData\Model\User;
use MapGuesser\Repository\UserConfirmationRepository;
use MapGuesser\Repository\UserPasswordResetterRepository;
use MapGuesser\Repository\UserPlayedPlaceRepository;
use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent;
use MapGuesser\Response\Redirect;
@ -26,12 +27,15 @@ class UserController implements ISecured
private UserPasswordResetterRepository $userPasswordResetterRepository;
private UserPlayedPlaceRepository $userPlayedPlaceRepository;
public function __construct(IRequest $request)
{
$this->request = $request;
$this->pdm = new PersistentDataManager();
$this->userConfirmationRepository = new UserConfirmationRepository();
$this->userPasswordResetterRepository = new UserPasswordResetterRepository();
$this->userPlayedPlaceRepository = new UserPlayedPlaceRepository();
}
public function authorize(): bool
@ -201,6 +205,10 @@ class UserController implements ISecured
$this->pdm->deleteFromDb($userPasswordResetter);
}
foreach ($this->userPlayedPlaceRepository->getAllByUser($user) as $userPlayedPlace) {
$this->pdm->deleteFromDb($userPlayedPlace);
}
$this->pdm->deleteFromDb($user);
\Container::$dbConnection->commit();

View File

@ -12,6 +12,8 @@ class Select
const CONDITION_HAVING = 1;
const DERIVED_TABLE_KEY = 'DERIVED';
private IConnection $connection;
private string $table;
@ -55,6 +57,11 @@ class Select
return $this;
}
public function setDerivedTableAlias(string $tableAlias): Select
{
return $this->setTableAliases([Select::DERIVED_TABLE_KEY => $tableAlias]);
}
public function from(string $table): Select
{
$this->table = $table;
@ -194,6 +201,11 @@ class Select
}
}
private function isDerivedTable(): bool
{
return array_key_exists(Select::DERIVED_TABLE_KEY, $this->tableAliases);
}
private function addJoin(string $type, $table, $column1, string $relation, $column2): void
{
$this->joins[] = [$type, $table, $column1, $relation, $column2];
@ -211,10 +223,14 @@ class Select
private function generateQuery(): array
{
$queryString = 'SELECT ' . $this->generateColumns() . ' FROM ' . $this->generateTable($this->table, true);
list($tableQuery, $tableParams) = $this->generateTable($this->table, true);
$queryString = 'SELECT ' . $this->generateColumns() . ' FROM ' . $tableQuery;
if (count($this->joins) > 0) {
$queryString .= ' ' . $this->generateJoins();
list($joinQuery, $joinParams) = $this->generateJoins();
$queryString .= ' ' . $joinQuery;
} else {
$joinParams = [];
}
if (count($this->conditions[self::CONDITION_WHERE]) > 0) {
@ -245,20 +261,32 @@ class Select
$queryString .= ' LIMIT ' . $this->limit[1] . ', ' . $this->limit[0];
}
return [$queryString, array_merge($whereParams, $havingParams)];
if($this->isDerivedTable()) {
$queryString = '(' . $queryString . ') AS ' . $this->tableAliases[Select::DERIVED_TABLE_KEY];
}
return [$queryString, array_merge($tableParams, $joinParams, $whereParams, $havingParams)];
}
private function generateTable($table, bool $defineAlias = false): string
private function generateTable($table, bool $defineAlias = false): array
{
$params = [];
if ($table instanceof RawExpression) {
return (string) $table;
return [(string) $table, $params];
}
if($table instanceof Select)
{
return $table->generateQuery();
}
if (isset($this->tableAliases[$table])) {
return ($defineAlias ? Utils::backtick($this->tableAliases[$table]) . ' ' . Utils::backtick($table) : Utils::backtick($table));
$queryString = ($defineAlias ? Utils::backtick($this->tableAliases[$table]) . ' ' . Utils::backtick($table) : Utils::backtick($table));
return [$queryString, $params];
}
return Utils::backtick($table);
return [Utils::backtick($table), $params];
}
private function generateColumn($column): string
@ -271,7 +299,8 @@ class Select
$out = '';
if ($column[0]) {
$out .= $this->generateTable($column[0]) . '.';
list($tableName, $params) = $this->generateTable($column[0]);
$out .= $tableName . '.';
}
$out .= Utils::backtick($column[1]);
@ -297,15 +326,19 @@ class Select
return implode(',', $columns);
}
private function generateJoins(): string
private function generateJoins(): array
{
$joins = $this->joins;
array_walk($joins, function (&$value, $key) {
$value = $value[0] . ' JOIN ' . $this->generateTable($value[1], true) . ' ON ' . $this->generateColumn($value[2]) . ' ' . $value[3] . ' ' . $this->generateColumn($value[4]);
});
$joinQueries = [];
$params = [];
return implode(' ', $joins);
foreach($this->joins as $join) {
list($joinQueryFragment, $paramsFragment) = $this->generateTable($join[1], true);
$joinQueries[] = $join[0] . ' JOIN ' . $joinQueryFragment . ' ON ' . $this->generateColumn($join[2]) . ' ' . $join[3] . ' ' . $this->generateColumn($join[4]);
$params = array_merge($params, $paramsFragment);
}
return [implode(' ', $joinQueries), $params];
}
private function generateConditions(int $type): array

View File

@ -0,0 +1,99 @@
<?php namespace MapGuesser\PersistentData\Model;
use DateTime;
class UserPlayedPlace extends Model
{
protected static string $table = 'user_played_place';
protected static array $fields = ['user_id', 'place_id', 'last_time', 'occurrences'];
protected static array $relations = ['user' => User::class, 'place' => Place::class];
private ?User $user = null;
private ?int $userId = null;
private ?Place $place = null;
private ?int $placeId = null;
private DateTime $lastTime;
private int $occurrences = 1;
public function setUser(User $user): void
{
$this->user = $user;
}
public function setUserId(int $userId): void
{
$this->userId = $userId;
}
public function setPlace(Place $place): void
{
$this->place = $place;
}
public function setPlaceId(int $placeId): void
{
$this->placeId = $placeId;
}
public function setLastTimeDate(DateTime $lastTime): void
{
$this->lastTime = $lastTime;
}
public function setLastTime(string $lastTime): void
{
$this->lastTime = new DateTime($lastTime);
}
public function setOccurrences(int $occurrences): void
{
$this->occurrences = $occurrences;
}
public function incrementOccurrences(): void
{
$this->occurrences++;
}
public function getUser(): ?User
{
return $this->user;
}
public function getUserId(): ?int
{
return $this->userId;
}
public function getPlace(): ?Place
{
return $this->place;
}
public function getPlaceId(): ?int
{
return $this->placeId;
}
public function getLastTimeDate(): DateTime
{
return $this->lastTime;
}
public function getLastTime(): string
{
return $this->lastTime->format('Y-m-d H:i:s');
}
public function getOccurrences(): int
{
return $this->occurrences;
}
}

View File

@ -28,13 +28,35 @@ class PlaceRepository
yield from $this->pdm->selectMultipleFromDb($select, Place::class);
}
//TODO: use Map and User instead of id
public function getRandomNPlaces(int $mapId, int $n, ?int $userId): array
{
if (!isset($userId)) { // anonymous single player
return $this->getRandomNForMapWithValidPano($mapId, $n);
} else { // authorized user or multiplayer game with selection based on what the host played before
$unvisitedPlaces = $this->getRandomUnvisitedNForMapWithValidPano($mapId, $n, $userId);
if (count($unvisitedPlaces) == $n) {
return $unvisitedPlaces;
}
$oldPlaces = $this->getRandomOldNForMapWithValidPano($mapId, $n - count($unvisitedPlaces), $userId);
return array_merge($unvisitedPlaces, $oldPlaces);
}
}
//TODO: use Map instead of id
public function getRandomNForMapWithValidPano(int $mapId, int $n, array $exclude = []): array
private function getRandomNForMapWithValidPano(int $mapId, int $n): array
{
$places = [];
$select = new Select(\Container::$dbConnection, 'places');
$select->where('map_id', '=', $mapId);
$numberOfPlaces = $select->count();
$exclude = [];
for ($i = 1; $i <= $n; ++$i) {
$place = $this->getRandomForMapWithValidPano($mapId, $exclude);
$place = $this->getRandomForMapWithValidPano($numberOfPlaces, $select, $exclude);
$places[] = $place;
$exclude[] = $place->getId();
@ -43,11 +65,15 @@ class PlaceRepository
return $places;
}
//TODO: use Map instead of id
public function getRandomForMapWithValidPano(int $mapId, array $exclude = []): Place
private function getRandomForMapWithValidPano(int $numberOfPlaces, Select $select, array &$exclude, ?callable $pickRandomInt = null): ?Place
{
do {
$place = $this->selectRandomFromDbForMap($mapId, $exclude);
$numberOfPlacesLeft = $numberOfPlaces - count($exclude);
$place = $this->selectRandomFromDbForMap($numberOfPlacesLeft, $select, $exclude, $pickRandomInt);
if ($place === null) {
// there is no more never visited place left
return null;
}
$panoId = $place->getFreshPanoId();
if ($panoId === null) {
@ -58,25 +84,97 @@ class PlaceRepository
return $place;
}
private function selectRandomFromDbForMap(int $mapId, array $exclude): Place
private function selectRandomFromDbForMap(int $numberOfPlacesLeft, Select $select, array $exclude, ?callable $pickRandomInt): ?Place
{
//TODO: omit table name here
$select = new Select(\Container::$dbConnection, 'places');
$select->where('id', 'NOT IN', $exclude);
$select->where('map_id', '=', $mapId);
if ($numberOfPlacesLeft <= 0)
return null;
$numberOfPlaces = $select->count();
//TODO: prevent this
if ($numberOfPlaces === 0) {
throw new \Exception('There is no selectable place (count was 0).');
if (!isset($pickRandomInt)) {
$randomOffset = random_int(0, $numberOfPlacesLeft - 1);
} else {
$randomOffset = $pickRandomInt($numberOfPlacesLeft);
}
$randomOffset = random_int(0, $numberOfPlaces - 1);
$select->orderBy('id');
$select->where('id', 'NOT IN', $exclude);
$select->limit(1, $randomOffset);
return $this->pdm->selectFromDb($select, Place::class);
}
// Never visited places
private function getRandomUnvisitedNForMapWithValidPano(int $mapId, int $n, int $userId): array
{
$places = [];
$exclude = [];
// list of places visited by user
$selectPlacesByCurrentUser = new Select(\Container::$dbConnection, 'user_played_place');
$selectPlacesByCurrentUser->columns(['place_id', 'last_time']);
$selectPlacesByCurrentUser->where('user_id', '=', $userId);
$selectPlacesByCurrentUser->setDerivedTableAlias('places_by_current_user');
// count the places never visited
$selectUnvisited = new Select(\Container::$dbConnection, 'places');
$selectUnvisited->leftJoin($selectPlacesByCurrentUser, ['places', 'id'], '=', ['places_by_current_user', 'place_id']);
$selectUnvisited->where('map_id', '=', $mapId);
$selectUnvisited->where('last_time', '=', null);
$numberOfUnvisitedPlaces = $selectUnvisited->count();
// look for as many new places as possible but maximum $n
do {
$place = $this->getRandomForMapWithValidPano($numberOfUnvisitedPlaces, $selectUnvisited, $exclude);
if (isset($place)) {
$places[] = $place;
$exclude[] = $place->getId();
}
} while (count($places) < $n && isset($place));
return $places;
}
// Places visited in the longest time
private function getRandomOldNForMapWithValidPano(int $mapId, int $n, int $userId): array
{
$places = [];
$exclude = [];
// list of places visited by user
$selectPlacesByCurrentUser = new Select(\Container::$dbConnection, 'user_played_place');
$selectPlacesByCurrentUser->columns(['place_id', 'last_time']);
$selectPlacesByCurrentUser->where('user_id', '=', $userId);
$selectPlacesByCurrentUser->setDerivedTableAlias('places_by_current_user');
// count places that were visited at least once
$selectOldPlaces = new Select(\Container::$dbConnection, 'places');
$selectOldPlaces->innerJoin($selectPlacesByCurrentUser, ['places', 'id'], '=', ['places_by_current_user', 'place_id']);
$selectOldPlaces->where('map_id', '=', $mapId);
$numberOfOldPlaces = $selectOldPlaces->count();
// set order by datetime, oldest first
$selectOldPlaces->orderBy('last_time');
// selection algorithm with preference (weighting) for older places using Box-Muller transform
$pickGaussianRandomInt = function($numberOfPlaces) {
$stdev = 0.2;
$avg = 0.0;
$x = mt_rand() / mt_getrandmax();
$y = mt_rand() / mt_getrandmax();
$randomNum = abs(sqrt(-2 * log($x)) * cos(2 * pi() * $y) * $stdev + $avg);
return (int) min($randomNum * $numberOfPlaces, $numberOfPlaces - 1);
};
// look for n - numberOfUnvisitedPlaces places
while (count($places) < $n)
{
$place = $this->getRandomForMapWithValidPano($numberOfOldPlaces, $selectOldPlaces, $exclude, $pickGaussianRandomInt);
if (isset($place))
{
$places[] = $place;
$exclude[] = $place->getId();
}
}
return $places;
}
}

View File

@ -0,0 +1,52 @@
<?php namespace MapGuesser\Repository;
use DateTime;
use Generator;
use MapGuesser\Database\Query\Select;
use MapGuesser\PersistentData\Model\User;
use MapGuesser\PersistentData\Model\Place;
use MapGuesser\PersistentData\Model\UserPlayedPlace;
use MapGuesser\PersistentData\PersistentDataManager;
class UserPlayedPlaceRepository
{
private PersistentDataManager $pdm;
public function __construct()
{
$this->pdm = new PersistentDataManager();
}
public function getByUser(User $user): Generator
{
$select = new Select(\Container::$dbConnection);
$select->where('user_id', '=', $user->getId());
yield from $this->pdm->selectMultipleFromDb($select, UserPlayedPlace::class);
}
public function getAllByPlace(Place $place): Generator
{
$select = new Select(\Container::$dbConnection);
$select->where('place_id', '=', $place->getId());
yield from $this->pdm->selectMultipleFromDb($select, UserPlayedPlace::class);
}
public function getAllByUser(User $user) : Generator
{
$select = new Select(\Container::$dbConnection);
$select->where('user_id', '=', $user->getId());
yield from $this->pdm->selectMultipleFromDb($select, UserPlayedPlace::class, true);
}
public function getByUserIdAndPlaceId(int $userId, int $placeId) : ?UserPlayedPlace
{
$select = new Select(\Container::$dbConnection);
$select->where('user_id', '=', $userId);
$select->where('place_id', '=', $placeId);
return $this->pdm->selectFromDb($select, UserPlayedPlace::class);
}
}