diff --git a/database/migrations/structure/20210503_1040_user_played_place.sql b/database/migrations/structure/20210503_1040_user_played_place.sql new file mode 100644 index 0000000..afe60b3 --- /dev/null +++ b/database/migrations/structure/20210503_1040_user_played_place.sql @@ -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; diff --git a/src/Cli/MaintainDatabaseCommand.php b/src/Cli/MaintainDatabaseCommand.php index b622204..e893c71 100644 --- a/src/Cli/MaintainDatabaseCommand.php +++ b/src/Cli/MaintainDatabaseCommand.php @@ -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); } diff --git a/src/Controller/GameFlowController.php b/src/Controller/GameFlowController.php index cca90c1..eba2a48 100644 --- a/src/Controller/GameFlowController.php +++ b/src/Controller/GameFlowController.php @@ -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; diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index e54d44a..4f85983 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -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(); diff --git a/src/Controller/MapAdminController.php b/src/Controller/MapAdminController.php index add5232..12b260a 100644 --- a/src/Controller/MapAdminController.php +++ b/src/Controller/MapAdminController.php @@ -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); } } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 805b752..3b0df05 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -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(); diff --git a/src/Database/Query/Select.php b/src/Database/Query/Select.php index 08a1049..d62bd84 100644 --- a/src/Database/Query/Select.php +++ b/src/Database/Query/Select.php @@ -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 diff --git a/src/PersistentData/Model/UserPlayedPlace.php b/src/PersistentData/Model/UserPlayedPlace.php new file mode 100644 index 0000000..d612476 --- /dev/null +++ b/src/PersistentData/Model/UserPlayedPlace.php @@ -0,0 +1,99 @@ + 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; + } +} diff --git a/src/Repository/PlaceRepository.php b/src/Repository/PlaceRepository.php index 84c90cf..4c36edc 100644 --- a/src/Repository/PlaceRepository.php +++ b/src/Repository/PlaceRepository.php @@ -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; + } + } diff --git a/src/Repository/UserPlayedPlaceRepository.php b/src/Repository/UserPlayedPlaceRepository.php new file mode 100644 index 0000000..5daaaaa --- /dev/null +++ b/src/Repository/UserPlayedPlaceRepository.php @@ -0,0 +1,52 @@ +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); + } +}