Merged master to branch
This commit is contained in:
commit
15f88b9e20
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,8 @@
|
||||
VENV
|
||||
WENV
|
||||
env
|
||||
**/__pycache__
|
||||
.vscode
|
||||
*.swp
|
||||
linux_env
|
||||
moveset_data.json
|
@ -3,10 +3,12 @@ from DataModels.Road import Road
|
||||
from DataModels.House import House
|
||||
from DataModels.Dump import Dump
|
||||
from config import GRID_WIDTH, GRID_HEIGHT, DELAY
|
||||
from utilities import movement, check_moves
|
||||
from utilities import movement, check_moves, save_moveset
|
||||
from Traversal.DFS import DFS
|
||||
from Traversal.BestFS import BestFS
|
||||
from Traversal.BFS import BFS
|
||||
import pygame
|
||||
|
||||
class GC(Cell):
|
||||
moves_made = 0
|
||||
def __init__(self, x, y, max_rubbish, yellow=0, green=0, blue=0):
|
||||
@ -35,7 +37,7 @@ class GC(Cell):
|
||||
item.return_trash(self)
|
||||
self.update_image()
|
||||
|
||||
def get_moves_made(self):
|
||||
def get_moves_count(self):
|
||||
return self.moves_made
|
||||
|
||||
def find_houses(self,enviromnent, house_count,dump_count, mode):
|
||||
@ -65,8 +67,43 @@ class GC(Cell):
|
||||
for x in element_list:
|
||||
x.unvisited = True
|
||||
self.moves.reverse()
|
||||
save_moveset(self.moves)
|
||||
|
||||
|
||||
def find_houses_BestFS(self, environment):
|
||||
x = self.x
|
||||
y = self.y
|
||||
result = [[x,y]]
|
||||
|
||||
houses_list = []
|
||||
dump_list = []
|
||||
a = 0
|
||||
for row in environment:
|
||||
b = 0
|
||||
for col in row:
|
||||
if (type(col) is House):
|
||||
houses_list.append([col,[a,b]])
|
||||
if (type(col) is Dump):
|
||||
dump_list.append([col,[a,b]])
|
||||
b += 1
|
||||
a += 1
|
||||
|
||||
x, y = self.x, self.y
|
||||
|
||||
for i in range(len(houses_list)):
|
||||
available_movement = check_moves(environment, x, y)
|
||||
output = BestFS(environment, available_movement, [[x,y]], houses_list)
|
||||
if(output != None):
|
||||
[x,y],result,houses_list = output[0], output[1], output[2]
|
||||
self.moves.extend(result[1:])
|
||||
for i in range(len(dump_list)):
|
||||
available_movement = check_moves(environment, x, y)
|
||||
output = BestFS(environment, available_movement, [[x,y]], dump_list)
|
||||
if(output != None):
|
||||
[x,y],result,dump_list = output[0], output[1], output[2]
|
||||
self.moves.extend(result[1:])
|
||||
self.moves.reverse()
|
||||
save_moveset(self.moves)
|
||||
|
||||
|
||||
def make_actions_from_list(self,environment):
|
||||
|
138
Raports/SI_Raport_1.md
Normal file
138
Raports/SI_Raport_1.md
Normal file
@ -0,0 +1,138 @@
|
||||
# Sztuczna inteligencja 2019 - Raport 1
|
||||
|
||||
**Czas trwania opisywanych prac:** 06.03.2018 - 26.03.2018
|
||||
|
||||
**Członkowie zespołu:** Anna Nowak, Magdalena Wilczyńska, Konrad Pierzyński, Michał Starski
|
||||
|
||||
**Wybrany temat:** Inteligentna śmieciarka
|
||||
|
||||
**Link do repozytorium projektu:** https://git.wmi.amu.edu.pl/s440556/SZI2019SmieciarzWmi
|
||||
|
||||
## Środowisko agenta i reprezentacja wiedzy
|
||||
|
||||
Pracę zaczeliśmy od spisania wszystkiego co będzie nam potrzebne do napisanie działającego środowiska. Notatki
|
||||
|
||||
można obejrzeć pod [tym linkiem](https://git.wmi.amu.edu.pl/s440556/SZI2019SmieciarzWmi/src/master/README.md). Pod uwagę wzieliśmy następujące czynniki:
|
||||
|
||||
* Jak dana technologia radzi sobie z graficznym przedstawieniem informacji (render mapy i rozmieszczenie na niej obiektów, zmiana stanów obiektów w zależności od sytuacji ...)
|
||||
|
||||
* Poziom skomplikowania operacji na strukturach danych
|
||||
|
||||
* Podejście języka do paradygmatu obiektowego
|
||||
|
||||
* Trudność w implementacji mechanizmów sztucznej inteligencji
|
||||
|
||||
* Preferencje programistyczne grupy
|
||||
|
||||
Po naradzie i sugestiach od strony prowadzącego zdecydowaliśmy, że do projektu najlepiej będzie pasował język **python** z uwagi na jego uniwersalność i łatwość przetwarzania danych.
|
||||
|
||||
### Plansza po której porusza się agent
|
||||
|
||||
**Założenia**
|
||||
|
||||
1. Plansza jest generowana losowo przy każdym uruchomieniu skryptu
|
||||
|
||||
2. Każda plansza zawiera *n* domów i 3 wysypiska śmieci
|
||||
|
||||
3. Każdy domek klatkę generuje jeden z 3 rodzajów śmieci z prawdopodobieństwem P (domyślnie 1/25)
|
||||
|
||||
4. Agent chodzi po domkach zbiera śmieci i wyrzuca je na odpowiednie wysypisko
|
||||
|
||||
5. Agent ma pojemność 10 na każdy rodzaj śmiecia
|
||||
|
||||
6. Dom może wygenerować maksymalnie 10 śmieci
|
||||
|
||||
7. Plansza jest dyskretna
|
||||
|
||||
**Detale implementacyjne**
|
||||
|
||||
Środowisko powstało przy użyciu biblioteki **pygame**, która udostępnia nam narzędzia do wygodnego zbudowania schludnie wyglądającej symulacji. Plansza ma postać dwuwymiarowiej płaszczyzny kartezjańskiej podzielonej na komórki (Cells). Na komórkach umieszczane są po kolei obiekty, tak, aby nie doszło do sytuacji, w której agent nie ma możliwości ruchu. Środowisko jest generowane dynamicznie w zależności od parametru **home-count**, który podajemy przy starcie skryptu. Dzięki temu możemy testować agenta w możliwie różnych sytuacjach. Opis elementów środowiska został przedstawiony przy użyciu paradygmatu obiektowego, dzięki temu kod jest czytelniejszy i wydzielony.
|
||||
|
||||
|
||||
|
||||
Na ten moment na planszy pojawiają się instancje klas:
|
||||
|
||||
*Grass* - klasa reprezentująca komórkę po której agent może swobodnie się poruszać, wypełnia większość mapy
|
||||
|
||||
*House* - klasa reprezentująca dom, agent wchodzi z nią w interakcję, zabiera wygenerowane śmieci
|
||||
|
||||
*Landfill* - klasa reprezentująca wysypisko, agent wchodzi z nią w interakcję, oddaje zebrane śmieci
|
||||
|
||||
*Garbage_collector* - klasa reprezentująca agenta - porusza się po mapie i wchodzi w interakcję z innymi obiektami
|
||||
|
||||
*HUD* - klasa reprezentująca HUD aplikacji (jeszcze niezaimplementowany)
|
||||
|
||||
|
||||
|
||||
**Struktura plików projektu**
|
||||
|
||||
--enums => Klasy dziedziczące po klasie *Enum*, ułatwiające parsowanie informacji
|
||||
|
||||
--fonts => Czcionki
|
||||
|
||||
--images => Obrazy i ikony używane w aplikacji
|
||||
|
||||
--raports => Raporty
|
||||
|
||||
--sprites => Klasy reprezentujące obiekty na mapie
|
||||
|
||||
.gitignore
|
||||
|
||||
config.py => Plik przechowujący funkcję zarządzające konfiguracją aplikacji
|
||||
|
||||
game.py => Plik rozruchowy programu
|
||||
|
||||
utils.py => Funkcje pomocnicze
|
||||
|
||||
README.md => Informacje o aplikacji
|
||||
|
||||
requirements.txt => Przechowuje informacje na temat używanych bibliotek
|
||||
|
||||
to_do.txt => Lista przyszłych zadań do zrobienia
|
||||
|
||||
|
||||
|
||||
### Reprezentacja Wiedzy
|
||||
|
||||
Przyjeliśmy na potrzeby projektu, że agent będzie wiedział co się dzieje na całym obszarze środowiska. W tym momencie wszystko co wie agent wyświetlane jest w oknie terminala, w czasie rzeczywistym, podczas trwania programu. Do informacji posiadanych przez agenta należą:
|
||||
|
||||
|
||||
|
||||
* Ile zebrano śmieci od startu programu
|
||||
|
||||
* Stopień zapełnienia śmieciarki
|
||||
|
||||
* Ile śmieci zostało na mapie
|
||||
|
||||
|
||||
|
||||
Podczas dalszego rozwoju powyższe informacje będą przedstawiane na ekranie aplikacji.
|
||||
|
||||
|
||||
|
||||
### Uruchamianie aplikacji
|
||||
|
||||
**Linux**
|
||||
|
||||
Uruchomienie standardowe (z 5 domami)
|
||||
|
||||
```bash
|
||||
make init #stworzenie wirtualnego środowiska
|
||||
make install #zainstalowanie zależności
|
||||
make start #uruchomienie z domyślnym parametrem home-count=5
|
||||
```
|
||||
|
||||
Uruchomienie niestandardowe
|
||||
|
||||
```bash
|
||||
env/bin/python3 ./game.py home-count=amount #Liczba domów nie może być mniejsza niż 3
|
||||
```
|
||||
|
||||
**Windows**
|
||||
|
||||
```powershell
|
||||
py -m virtualenv env # Stworzenie wirtualnego środowiska
|
||||
env\Scripts\pip.exe install -r requirements.txt
|
||||
env\Scripts\python.exe ./game.py --home-count=amount
|
||||
```
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Sztuczna inteligencja 2019 - Raport 2
|
||||
|
||||
**Czas trwania opisywanych prac:** 06.03.2018 - 26.03.2018
|
||||
**Czas trwania opisywanych prac:** 06.03.2019 - 26.03.2019
|
||||
|
||||
**Członkowie zespołu:** Anna Nowak, Magdalena Wilczyńska, Konrad Pierzyński, Michał Starski
|
||||
|
||||
@ -13,29 +13,26 @@
|
||||
#### Implementacja
|
||||
|
||||
Pierwszym podejściem naszej grupy do rozwiązania problemu była implementacja
|
||||
algorytmu przeszukiwania drzewa w głąb - DFS (Wersja iteracyjna).
|
||||
algorytmu przeszukiwania drzewa w głąb - DFS (Wersja rekurencyjna).
|
||||
Aby zaimplementować ten algorytm, niezbędne było przygotowanie dla niego kilku
|
||||
struktur pomocniczych dzięki którym będziemy mogli jasno zdefiniować warunki stopu i uzyskać satysfakcjonujące nas rozwiązanie.
|
||||
|
||||
Do użytych struktur należą:
|
||||
|
||||
- **Lista dwuwymiarowa przedstawiająca mapę w formie siatki po której można łatwo iterować** - Jeden stan takiej listy traktowaliśmy jako wierzchołek grafu
|
||||
- **Stos wykonanych przez algorytm ruchów** - Używany do przechodzenia do kolejnych możliwych stanów jak i zapamiętania rozwiązania problemu.
|
||||
- **Lista możliwych ruchów do wykonania przez agenta przy konkretnym stanie mapy**
|
||||
- **Licznik głębokości na którą zszedł algorytm** - Zapobiega zajściu za głęboko w przypadku braku rozwiązania
|
||||
|
||||
**Przebieg algorytmu**:
|
||||
|
||||
- Dodaj do stosu pierwszy krok
|
||||
- Dopóki stos nie jest pusty:
|
||||
1. Weź stan mapy ze stosu (operacja stack.pop())
|
||||
2. Sprawdź warunek końca (Czy problem został rozwiązany ?)
|
||||
- Jeżeli tak, zakończ algorytm
|
||||
1. Sprawdź czy w pobliżu śmieciarki znajduje się nieopróżnioony domek
|
||||
2. Jeżeli możliwa jest jakaś akcja (zebranie/oddanie smieci) wykonaj ją
|
||||
- Jeżeli akcja została wykonana, zakończ algorytm
|
||||
- Jeżeli nie, sprawdź czy głębokość przekroczyła 30
|
||||
1. Jeżeli tak, zakończ algorytm informacją o braku rozwiązania
|
||||
2. Jeżeli nie, kontynuuj algorytm
|
||||
3. Dla każdego możliwego kierunku wykonaj algorytm od punktu 1
|
||||
|
||||
Rozwiązanie następuje wtedy, gdy wszystkie śmieci zostaną zebrane przez agenta.
|
||||
Rozwiązanie następuje wtedy, gdy domek zostaje opróżniony. Algorytm zostaje wywołany tyle samo razy, ile jest domków. Agent nie zna położenia domków na mapie podczas działania algorytmu.
|
||||
|
||||
#### Obserwacje
|
||||
|
||||
|
64
Raports/SI_Raport_3.md
Normal file
64
Raports/SI_Raport_3.md
Normal file
@ -0,0 +1,64 @@
|
||||
# Sztuczna inteligencja 2019 - Raport 3
|
||||
|
||||
**Czas trwania opisywanych prac:** 03.04.2019 - 14.04.2019
|
||||
|
||||
**Członkowie zespołu:** Anna Nowak, Magdalena Wilczyńska, Konrad Pierzyński, Michał Starski
|
||||
|
||||
**Wybrany temat:** Inteligentna śmieciarka
|
||||
|
||||
|
||||
**Link do repozytorium projektu:** https://git.wmi.amu.edu.pl/s440556/SZI2019SmieciarzWmi
|
||||
|
||||
## Planowanie ruchu - Algorytmy BFS i Best-first search
|
||||
|
||||
#### Implementacja
|
||||
|
||||
##### BFS (Iteracyjnie)
|
||||
Algorytm przeszukiwania drzewa w głąb.
|
||||
|
||||
Algorytm jest niepoinformowany.
|
||||
|
||||
W przypadku BFS użyte struktury pozostają w gruncie rzeczy te same (z tym, że tym razem zamiast stosu do przechowywania stanu używamy kolejki), zmienia się tylko kolejność wykonywanych instrukcji:
|
||||
|
||||
|
||||
**Przebieg algorytmu**:
|
||||
|
||||
- Dodaj do kolejki pierwszy krok
|
||||
- Dopóki kolejka nie jest pusta:
|
||||
1. Zdejmij z kolejki następny nieodwiedzony wierzchołek grafu
|
||||
2. Jeżeli możliwa jest jakaś akcja (zebranie/oddanie smieci) wykonaj ją
|
||||
3. Sprawdź wszystkie sąsiednie wierzchołki wybranego wierzchołka, które jeszcze nie zostały odwiedzone
|
||||
|
||||
##### Best-first search
|
||||
Agent idzie w kierunku celu do którego jest najbliżej w linii prostej w danym momencie.
|
||||
|
||||
Algorytm jest poinformowany.
|
||||
|
||||
**Przebieg algorytmu**
|
||||
|
||||
1. Ustaw pozycję początkową
|
||||
2. Znajdź obiekt w znajdujący się najbliżej w linii prostej
|
||||
3. Jeżeli odległość od obiektu wynosi 1, wykonaj interakcję i usuń obiekt z listy celów, zwróć ścieżkę do obiektu
|
||||
4. Jeżeli przekroczono limit rekursji lub nie można wykonać kroku zakończ
|
||||
5. Na podstawie pozycji nowoobranego celu wybierz preferowane oraz niechciane kierunki poruszania się
|
||||
6. Posortuj dozwolone ruchy zgodnie z preferencjami
|
||||
7. Dla każdego kierunku na liście przeszukuj dostępne ścieżki dopóki jakakolwiek nie zostanie znaleziona
|
||||
|
||||
|
||||
-----------
|
||||
Ponadto, dołożyliśmy śmieciarzowi możliwość oddawania śmieci na wysypisko w ten sposób kompletując założenia planszy.
|
||||
|
||||
#### Obserwacje
|
||||
W porównaniu do poprzednio zaimplementowanego DFS oba algorytmy sprawują się zdecydowanie szybciej. Przez to, że szukanie drogi nie odbywa się w głąb, agent nie traci czasu na przeszukiwanie wierzchołków z góry skazanych na porażkę. Poniżej przedstawiamy tabelę mierzącą liczbę kroków, która była potrzebna do wykonania przez agenta przy użyciu DFS, BFS i Best-first search na 5 przygotowanych do testów mapach:
|
||||
|
||||
| Algorytm / Kroki | Mapa 1 | Mapa 2 | Mapa 3 | Mapa 4 | Mapa 5 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| DFS | 134 | 45 | 67 | 191 | 12 |
|
||||
| BFS | 62 | 23 | 57 | 101 | 12 |
|
||||
| Best-first | 55 | 20 | 58 | 99 | 12 |
|
||||
|
||||
Po wykonaniu testów możemy stwierdzić, że najlepszym z tych 3 algorytmów okazał się Best-first search. Warto jednak zauważyć, że różnica kroków jest mała.
|
||||
|
||||
Co widać bez jakichkolwiek wątpliwości DFS okazał się najgorszy (zgodnie z uzasadnieniem znajdującym się w raporcie nr. 2). Liczba kroków jest prawie dwukrotnie większa od tej w konkurujących algorytmach.
|
||||
|
||||
(Mapa 5 posiadała tylko jedną możliwe przejście posiadające 12 kroków ten wynik można pominąć.)
|
68
Traversal/BestFS.py
Normal file
68
Traversal/BestFS.py
Normal file
@ -0,0 +1,68 @@
|
||||
from utilities import movement,check_moves
|
||||
from DataModels.House import House
|
||||
from DataModels.Container import Container
|
||||
from config import GRID_WIDTH, GRID_HEIGHT
|
||||
from math import sqrt
|
||||
INF = float('Inf')
|
||||
|
||||
|
||||
def CalculateDistance(gc, object_list):
|
||||
min_distance_goal = ['-',INF]
|
||||
for h in object_list:
|
||||
distance = sqrt(pow(h[1][0]-gc[0],2)+pow(h[1][1]-gc[1],2))
|
||||
if(min_distance_goal[1] > distance):
|
||||
min_distance_goal = [h[1], distance]
|
||||
return min_distance_goal
|
||||
|
||||
def BestFS(grid, available_movement, gc_moveset, object_list, depth = 0):
|
||||
|
||||
x, y = gc_moveset[-1][0], gc_moveset[-1][1]
|
||||
|
||||
#calculate distance to the nearest object
|
||||
min_distance_goal = CalculateDistance([x,y], object_list)
|
||||
|
||||
if(min_distance_goal[1] == 1):
|
||||
gc_moveset.append("pick_garbage")
|
||||
cell = grid[min_distance_goal[0][0]][min_distance_goal[0][1]]
|
||||
object_list.remove([cell,min_distance_goal[0]])
|
||||
return([x, y], gc_moveset, object_list)
|
||||
|
||||
#if depth exceeded, return
|
||||
if(depth > 15 or len(available_movement) == 0):
|
||||
return
|
||||
|
||||
#set preffered directions based on the closest object
|
||||
preffered_directions = []
|
||||
discouraged_directions = []
|
||||
|
||||
if(min_distance_goal[0][0] > x):
|
||||
preffered_directions.append("right")
|
||||
if(min_distance_goal[0][0] < x):
|
||||
preffered_directions.append("left")
|
||||
if(min_distance_goal[0][1] > y):
|
||||
preffered_directions.append("down")
|
||||
if(min_distance_goal[0][1] < y):
|
||||
preffered_directions.append("up")
|
||||
|
||||
if(len(preffered_directions) == 1):
|
||||
discouraged_directions.append(movement(grid, x, y)[1][preffered_directions[0]])
|
||||
|
||||
#sort available moves according to preferences
|
||||
sorted = [o for o in preffered_directions if o in available_movement]
|
||||
for o in sorted:
|
||||
available_movement.remove(o)
|
||||
sorted.extend([o for o in available_movement if o not in discouraged_directions])
|
||||
for o in sorted:
|
||||
if(o in available_movement):
|
||||
available_movement.remove(o)
|
||||
sorted.extend(available_movement)
|
||||
available_movement = sorted.copy()
|
||||
|
||||
for direction in available_movement:
|
||||
x_next, y_next = movement(grid,x,y)[0][direction]
|
||||
available_movement_next = check_moves(grid, x_next,y_next,direction)
|
||||
gc_moveset_next = gc_moveset.copy()
|
||||
gc_moveset_next.append([x_next,y_next])
|
||||
result = BestFS(grid, available_movement_next, gc_moveset_next, object_list, depth + 1)
|
||||
if result!= None:
|
||||
return result
|
10
main.py
10
main.py
@ -21,7 +21,7 @@ dump_count=0
|
||||
FPS_CLOCK = pygame.time.Clock()
|
||||
GAME_WINDOW = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT), 0, 32)
|
||||
|
||||
map = open(MAP_NAME)
|
||||
map = open(MAP_NAME, 'r')
|
||||
map.readline()
|
||||
map.readline()
|
||||
|
||||
@ -100,6 +100,8 @@ while True:
|
||||
gc.collect(map_objects)
|
||||
elif event.key == pygame.K_0:
|
||||
gc.find_houses(map_objects,house_count,dump_count, "DFS")
|
||||
elif event.key == pygame.K_9:
|
||||
gc.find_houses_BestFS(map_objects)
|
||||
elif event.key == pygame.K_8:
|
||||
gc.find_houses(map_objects,house_count,dump_count, "BFS")
|
||||
|
||||
@ -108,12 +110,12 @@ while True:
|
||||
pygame_sprites.draw(GAME_WINDOW)
|
||||
|
||||
#draw GC moves
|
||||
bg_rect = pygame.Surface((105,30), pygame.SRCALPHA) # per-pixel alpha
|
||||
bg_rect.fill((0,0,0,160)) # notice the alpha value in the color
|
||||
bg_rect = pygame.Surface((105,30), pygame.SRCALPHA)
|
||||
bg_rect.fill((0,0,0,160))
|
||||
GAME_WINDOW.blit(bg_rect, (0, WINDOW_HEIGHT-30))
|
||||
|
||||
font = pygame.font.SysFont("monospace", 15)
|
||||
gc_moves = font.render("Moves: " + str(gc.get_moves_made()), 1, (255,255,255))
|
||||
gc_moves = font.render("Moves: " + str(gc.get_moves_count()), 1, (255,255,255))
|
||||
GAME_WINDOW.blit(gc_moves, (10, WINDOW_HEIGHT - 25))
|
||||
|
||||
pygame.display.flip()
|
||||
|
30
utilities.py
30
utilities.py
@ -1,5 +1,7 @@
|
||||
from config import GRID_WIDTH, GRID_HEIGHT
|
||||
from DataModels.Road import Road
|
||||
import json, os, platform
|
||||
|
||||
def movement(environment, x ,y):
|
||||
movement = {
|
||||
"right": (x + 1, y) if x + 1 < GRID_WIDTH and type(environment[x + 1][y]) == Road else (x, y),
|
||||
@ -21,3 +23,31 @@ def check_moves(environment, x,y,direction=None):
|
||||
if direction == None:
|
||||
return ([dir for dir in movement(environment, x, y)[0] if movement(environment, x,y)[0][dir] != (x,y)])
|
||||
return ([dir for dir in movement(environment, x, y)[0] if movement(environment, x,y)[0][dir] != (x,y) and dir != movement(environment,x,y)[1][direction]])
|
||||
|
||||
def save_moveset(moveset):
|
||||
if platform.system() == 'Windows':
|
||||
path = '\moveset_data.json'
|
||||
else:
|
||||
path = '/moveset_data.json'
|
||||
output_file = os.path.normpath(os.getcwd()) + path
|
||||
|
||||
results = {}
|
||||
try:
|
||||
f = open(output_file, 'r+')
|
||||
except:
|
||||
open(output_file, 'a').close()
|
||||
finally:
|
||||
f = open(output_file, 'r+')
|
||||
|
||||
try:
|
||||
results = json.load(f)
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
if "moveset" not in results:
|
||||
results = { "moveset": [] }
|
||||
|
||||
results["moveset"].append(moveset)
|
||||
f.seek(0)
|
||||
json.dump(results, f, indent=1)
|
||||
f.close()
|
Loading…
Reference in New Issue
Block a user