polyclash 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polyclash-0.0.1/LICENSE +21 -0
- polyclash-0.0.1/PKG-INFO +110 -0
- polyclash-0.0.1/README.md +93 -0
- polyclash-0.0.1/polyclash/__init__.py +0 -0
- polyclash-0.0.1/polyclash/client.py +31 -0
- polyclash-0.0.1/polyclash/data/__init__.py +0 -0
- polyclash-0.0.1/polyclash/data/data.py +87 -0
- polyclash-0.0.1/polyclash/game/__init__.py +0 -0
- polyclash-0.0.1/polyclash/game/board.py +301 -0
- polyclash-0.0.1/polyclash/game/controller.py +153 -0
- polyclash-0.0.1/polyclash/game/player.py +71 -0
- polyclash-0.0.1/polyclash/game/timer.py +37 -0
- polyclash-0.0.1/polyclash/gui/__init__.py +0 -0
- polyclash-0.0.1/polyclash/gui/constants.py +13 -0
- polyclash-0.0.1/polyclash/gui/dialogs.py +292 -0
- polyclash-0.0.1/polyclash/gui/main.py +179 -0
- polyclash-0.0.1/polyclash/gui/mesh.py +47 -0
- polyclash-0.0.1/polyclash/gui/overly_info.py +63 -0
- polyclash-0.0.1/polyclash/gui/overly_map.py +68 -0
- polyclash-0.0.1/polyclash/gui/view_sphere.py +158 -0
- polyclash-0.0.1/polyclash/server.py +274 -0
- polyclash-0.0.1/polyclash/util/__init__.py +0 -0
- polyclash-0.0.1/polyclash/util/api.py +151 -0
- polyclash-0.0.1/polyclash/util/logging.py +31 -0
- polyclash-0.0.1/polyclash/util/storage.py +395 -0
- polyclash-0.0.1/polyclash/workers/__init__.py +0 -0
- polyclash-0.0.1/polyclash/workers/ai_play.py +55 -0
- polyclash-0.0.1/polyclash/workers/network.py +69 -0
- polyclash-0.0.1/polyclash.egg-info/PKG-INFO +110 -0
- polyclash-0.0.1/polyclash.egg-info/SOURCES.txt +35 -0
- polyclash-0.0.1/polyclash.egg-info/dependency_links.txt +1 -0
- polyclash-0.0.1/polyclash.egg-info/entry_points.txt +3 -0
- polyclash-0.0.1/polyclash.egg-info/requires.txt +11 -0
- polyclash-0.0.1/polyclash.egg-info/top_level.txt +1 -0
- polyclash-0.0.1/setup.cfg +10 -0
- polyclash-0.0.1/setup.py +53 -0
polyclash-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Mingli Yuan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
polyclash-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: polyclash
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A python 3d spherical Go game on a snub dodecahedron board
|
|
5
|
+
Home-page: https://github.com/spherical-go/polyclash
|
|
6
|
+
Author: Mingli Yuan
|
|
7
|
+
Author-email: mingli.yuan@gmail.com
|
|
8
|
+
Project-URL: Documentation, https://github.com/spherical-go/polyclash
|
|
9
|
+
Project-URL: Source, https://github.com/spherical-go/polyclash
|
|
10
|
+
Project-URL: Tracker, https://github.com/spherical-go/polyclash/issues
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
|
|
18
|
+
# PolyClash: A Go-like game on sphere by using a snub dodecahedron board
|
|
19
|
+
|
|
20
|
+
## Introduction
|
|
21
|
+
|
|
22
|
+
Like mathematical truth, Go is an eternal game in the universe. Similarly, the snub dodecahedron is also an eternal geometric shape, which is the Archimedean polyhedron with the most sphericity.
|
|
23
|
+
We combine these two to create a new game: PolyClash.
|
|
24
|
+
|
|
25
|
+
Can we create a set of rules that are as simple as possible while making this game very interesting? So that this game is also an eternal game. This is our goal.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install polyclash
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
The client can be started by running the following command:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
polyclash-client
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The client is a Qt application that allows you to play the game.
|
|
42
|
+
|
|
43
|
+
The local server can be started by running the following command:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
polyclash-server
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
It should be noted that the server is not necessary to play the game.
|
|
50
|
+
The server is only needed if you want to play the game with other players in a local network.
|
|
51
|
+
|
|
52
|
+
## Deployment on a production server
|
|
53
|
+
|
|
54
|
+
You can also set up a production server, which can be accessed by other players on the internet.
|
|
55
|
+
We recommend using a reverse proxy like Nginx to set up the production server, and using uwsgi or similar tools
|
|
56
|
+
to run the server.
|
|
57
|
+
|
|
58
|
+
below is an example of how to run the server using uwsgi:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uwsgi --http :7763 --gevent 100 --http-websockets --master --wsgi polyclash.server:app --logto ~/.polyclash/uwsgi.log
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
and the Nginx configuration:
|
|
65
|
+
|
|
66
|
+
```nginx
|
|
67
|
+
server {
|
|
68
|
+
listen 80;
|
|
69
|
+
server_name polyclash.example.com;
|
|
70
|
+
|
|
71
|
+
location /sphgo {
|
|
72
|
+
proxy_pass http://127.0.0.1:7763;
|
|
73
|
+
proxy_set_header Host $host;
|
|
74
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
75
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
76
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
location /socket.io {
|
|
80
|
+
proxy_redirect off;
|
|
81
|
+
proxy_buffering off;
|
|
82
|
+
|
|
83
|
+
proxy_set_header Host $host;
|
|
84
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
85
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
86
|
+
|
|
87
|
+
proxy_http_version 1.1;
|
|
88
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
89
|
+
proxy_set_header Connection "Upgrade";
|
|
90
|
+
|
|
91
|
+
proxy_pass http://127.0.0.1:7763/socket.io;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
How to release a new version:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python3 setup.py sdist bdist_wheel
|
|
102
|
+
python3 -m twine upload dist/*
|
|
103
|
+
|
|
104
|
+
git tag va.b.c master
|
|
105
|
+
git push origin va.b.c
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# PolyClash: A Go-like game on sphere by using a snub dodecahedron board
|
|
2
|
+
|
|
3
|
+
## Introduction
|
|
4
|
+
|
|
5
|
+
Like mathematical truth, Go is an eternal game in the universe. Similarly, the snub dodecahedron is also an eternal geometric shape, which is the Archimedean polyhedron with the most sphericity.
|
|
6
|
+
We combine these two to create a new game: PolyClash.
|
|
7
|
+
|
|
8
|
+
Can we create a set of rules that are as simple as possible while making this game very interesting? So that this game is also an eternal game. This is our goal.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install polyclash
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
The client can be started by running the following command:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
polyclash-client
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The client is a Qt application that allows you to play the game.
|
|
25
|
+
|
|
26
|
+
The local server can be started by running the following command:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
polyclash-server
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
It should be noted that the server is not necessary to play the game.
|
|
33
|
+
The server is only needed if you want to play the game with other players in a local network.
|
|
34
|
+
|
|
35
|
+
## Deployment on a production server
|
|
36
|
+
|
|
37
|
+
You can also set up a production server, which can be accessed by other players on the internet.
|
|
38
|
+
We recommend using a reverse proxy like Nginx to set up the production server, and using uwsgi or similar tools
|
|
39
|
+
to run the server.
|
|
40
|
+
|
|
41
|
+
below is an example of how to run the server using uwsgi:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uwsgi --http :7763 --gevent 100 --http-websockets --master --wsgi polyclash.server:app --logto ~/.polyclash/uwsgi.log
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
and the Nginx configuration:
|
|
48
|
+
|
|
49
|
+
```nginx
|
|
50
|
+
server {
|
|
51
|
+
listen 80;
|
|
52
|
+
server_name polyclash.example.com;
|
|
53
|
+
|
|
54
|
+
location /sphgo {
|
|
55
|
+
proxy_pass http://127.0.0.1:7763;
|
|
56
|
+
proxy_set_header Host $host;
|
|
57
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
58
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
59
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
location /socket.io {
|
|
63
|
+
proxy_redirect off;
|
|
64
|
+
proxy_buffering off;
|
|
65
|
+
|
|
66
|
+
proxy_set_header Host $host;
|
|
67
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
68
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
69
|
+
|
|
70
|
+
proxy_http_version 1.1;
|
|
71
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
72
|
+
proxy_set_header Connection "Upgrade";
|
|
73
|
+
|
|
74
|
+
proxy_pass http://127.0.0.1:7763/socket.io;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
How to release a new version:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python3 setup.py sdist bdist_wheel
|
|
85
|
+
python3 -m twine upload dist/*
|
|
86
|
+
|
|
87
|
+
git tag va.b.c master
|
|
88
|
+
git push origin va.b.c
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from PyQt5.QtWidgets import QApplication
|
|
4
|
+
|
|
5
|
+
from polyclash.game.board import BLACK, WHITE
|
|
6
|
+
from polyclash.game.controller import SphericalGoController
|
|
7
|
+
from polyclash.game.player import HUMAN, AI
|
|
8
|
+
from polyclash.gui.main import MainWindow
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
app = QApplication(sys.argv)
|
|
13
|
+
|
|
14
|
+
controller = SphericalGoController()
|
|
15
|
+
controller.add_player(BLACK, kind=HUMAN)
|
|
16
|
+
controller.add_player(WHITE, kind=AI)
|
|
17
|
+
|
|
18
|
+
window = MainWindow(controller=controller)
|
|
19
|
+
|
|
20
|
+
screen = app.primaryScreen().geometry()
|
|
21
|
+
width = screen.width() // 5 * 4
|
|
22
|
+
height = screen.height() // 5 * 4
|
|
23
|
+
x = (screen.width() - width) // 2
|
|
24
|
+
y = (screen.height() - height) // 2
|
|
25
|
+
window.move(x, y)
|
|
26
|
+
window.resize(width, height)
|
|
27
|
+
|
|
28
|
+
controller.board.reset()
|
|
29
|
+
window.delayed_resize(width+1, height+1)
|
|
30
|
+
window.show()
|
|
31
|
+
sys.exit(app.exec_())
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pickle as pkl
|
|
3
|
+
import os.path as osp
|
|
4
|
+
|
|
5
|
+
from scipy.spatial import cKDTree
|
|
6
|
+
|
|
7
|
+
data_path = osp.abspath(osp.join(osp.dirname(__file__), "board.npz"))
|
|
8
|
+
pkl_path = osp.abspath(osp.join(osp.dirname(__file__), "board.pkl"))
|
|
9
|
+
idx_path = osp.abspath(osp.join(osp.dirname(__file__), "index.pkl"))
|
|
10
|
+
|
|
11
|
+
npz_data = np.load(data_path)
|
|
12
|
+
pentagons = npz_data['pentagons']
|
|
13
|
+
triangles = npz_data['triangles']
|
|
14
|
+
polysmalls = npz_data['polysmalls']
|
|
15
|
+
polylarges = npz_data['polylarges']
|
|
16
|
+
|
|
17
|
+
cities = npz_data['cities']
|
|
18
|
+
triangle2faces = npz_data['triangle2faces']
|
|
19
|
+
pentagon2faces = npz_data['pentagon2faces']
|
|
20
|
+
|
|
21
|
+
neighbors = pkl.load(open(pkl_path, "rb"))
|
|
22
|
+
|
|
23
|
+
indexer = pkl.load(open(idx_path, "rb"))
|
|
24
|
+
|
|
25
|
+
# encoding -> position
|
|
26
|
+
decoder = {}
|
|
27
|
+
for k, v in indexer.items():
|
|
28
|
+
if len(k) == 1:
|
|
29
|
+
decoder[tuple([k[0]])] = v
|
|
30
|
+
if len(k) == 2:
|
|
31
|
+
decoder[tuple([k[0], k[1]])] = v
|
|
32
|
+
decoder[tuple([k[1], k[0]])] = v
|
|
33
|
+
if len(k) == 3:
|
|
34
|
+
decoder[tuple([k[0], k[1], k[2]])] = v
|
|
35
|
+
decoder[tuple([k[1], k[2], k[0]])] = v
|
|
36
|
+
decoder[tuple([k[2], k[0], k[1]])] = v
|
|
37
|
+
if len(k) == 5:
|
|
38
|
+
decoder[tuple([k[0], k[1], k[2], k[3], k[4]])] = v
|
|
39
|
+
decoder[tuple([k[1], k[2], k[3], k[4], k[0]])] = v
|
|
40
|
+
decoder[tuple([k[2], k[3], k[4], k[0], k[1]])] = v
|
|
41
|
+
decoder[tuple([k[3], k[4], k[0], k[1], k[2]])] = v
|
|
42
|
+
decoder[tuple([k[4], k[0], k[1], k[2], k[3]])] = v
|
|
43
|
+
|
|
44
|
+
# position -> encoding
|
|
45
|
+
encoder = list([tuple() for i in range(302)])
|
|
46
|
+
for k, v in indexer.items():
|
|
47
|
+
encoder[v] = tuple(sorted(k))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Use a kdTree manage all the cities, when a stone is placed, find the nearest city
|
|
51
|
+
class CityManager:
|
|
52
|
+
def __init__(self, cities):
|
|
53
|
+
self.cities = cities
|
|
54
|
+
self.kd_tree = cKDTree(self.cities)
|
|
55
|
+
|
|
56
|
+
def find_nearest_city(self, position):
|
|
57
|
+
return self.kd_tree.query(position)[1]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
city_manager = CityManager(cities)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
axis = np.zeros((8, 3), dtype=np.float_)
|
|
64
|
+
axis[0] = np.mean(cities[0:15], axis=0)
|
|
65
|
+
axis[1] = np.mean(cities[15:30], axis=0)
|
|
66
|
+
axis[2] = np.mean(cities[30:45], axis=0)
|
|
67
|
+
axis[3] = np.mean(cities[45:60], axis=0)
|
|
68
|
+
axis[4] = - axis[0]
|
|
69
|
+
axis[5] = - axis[1]
|
|
70
|
+
axis[6] = - axis[2]
|
|
71
|
+
axis[7] = - axis[3]
|
|
72
|
+
axis = axis / np.linalg.norm(axis, axis=1)[:, np.newaxis]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_areas():
|
|
76
|
+
phi = (1 + np.sqrt(5)) / 2
|
|
77
|
+
coefficients = np.array([1, 2, 0, -phi**2], dtype=np.float64)
|
|
78
|
+
roots = np.roots(coefficients)
|
|
79
|
+
xi = roots[np.isreal(roots)].real[0]
|
|
80
|
+
length = 2 * xi * np.sqrt(1 - xi)
|
|
81
|
+
triangle_area = np.sqrt(3) / 4 * length**2
|
|
82
|
+
pentagon_area = np.sqrt(25 + 10 * np.sqrt(5)) * length**2 / 4
|
|
83
|
+
total_area = 80 * triangle_area + 12 * pentagon_area
|
|
84
|
+
return triangle_area / 3, pentagon_area / 5, total_area
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
polysmall_area, polylarge_area, total_area = get_areas()
|
|
File without changes
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
from random import sample
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from polyclash.data.data import cities, neighbors, polysmalls, polylarges, polylarge_area, polysmall_area, total_area, \
|
|
9
|
+
encoder
|
|
10
|
+
|
|
11
|
+
BLACK = 1
|
|
12
|
+
WHITE = -1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def calculate_area(boarddata, piece, area):
|
|
16
|
+
black_area, white_area, unclaimed_area = 0, 0, 0
|
|
17
|
+
parties = boarddata[piece]
|
|
18
|
+
parties_set = set(parties)
|
|
19
|
+
if BLACK not in parties_set and WHITE not in parties_set:
|
|
20
|
+
unclaimed_area += area
|
|
21
|
+
if BLACK in parties_set and WHITE not in parties_set:
|
|
22
|
+
black_area += area
|
|
23
|
+
if WHITE in parties_set and BLACK not in parties_set:
|
|
24
|
+
white_area += area
|
|
25
|
+
if BLACK in parties_set and WHITE in parties_set:
|
|
26
|
+
black_side, white_side = 0, 0
|
|
27
|
+
for part in parties:
|
|
28
|
+
if part == BLACK:
|
|
29
|
+
black_side += 1
|
|
30
|
+
if part == WHITE:
|
|
31
|
+
white_side += 1
|
|
32
|
+
black_area += area / (black_side + white_side) * black_side
|
|
33
|
+
white_area += area / (black_side + white_side) * white_side
|
|
34
|
+
return black_area, white_area, unclaimed_area
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def calculate_distance(point1, point2):
|
|
38
|
+
city1 = cities[point1]
|
|
39
|
+
city2 = cities[point2]
|
|
40
|
+
return np.linalg.norm(city1 - city2)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def calculate_potential(board, point, counter):
|
|
44
|
+
potential = 0
|
|
45
|
+
for i, stone in enumerate(board):
|
|
46
|
+
if stone != 0:
|
|
47
|
+
distance = calculate_distance(point, i)
|
|
48
|
+
if distance > 0:
|
|
49
|
+
potential += (1 / distance) * np.tanh(0.5 - counter / 302)
|
|
50
|
+
return potential
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Board:
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.board_size = 302
|
|
56
|
+
self.board = np.zeros([self.board_size])
|
|
57
|
+
self.current_player = BLACK
|
|
58
|
+
self.neighbors = neighbors
|
|
59
|
+
self.latest_player = None
|
|
60
|
+
self.latest_removes = [[]]
|
|
61
|
+
self.black_suicides = set()
|
|
62
|
+
self.white_suicides = set()
|
|
63
|
+
|
|
64
|
+
self.turns = OrderedDict()
|
|
65
|
+
|
|
66
|
+
self._observers = []
|
|
67
|
+
self.notification_enabled = True
|
|
68
|
+
|
|
69
|
+
self.simulator = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def counter(self):
|
|
73
|
+
return len(self.turns)
|
|
74
|
+
|
|
75
|
+
def register_observer(self, observer):
|
|
76
|
+
if observer not in self._observers:
|
|
77
|
+
self._observers.append(observer)
|
|
78
|
+
|
|
79
|
+
def unregister_observer(self, observer):
|
|
80
|
+
self._observers.remove(observer)
|
|
81
|
+
|
|
82
|
+
def enable_notification(self):
|
|
83
|
+
self.notification_enabled = True
|
|
84
|
+
|
|
85
|
+
def disable_notification(self):
|
|
86
|
+
self.notification_enabled = False
|
|
87
|
+
|
|
88
|
+
def notify_observers(self, message, **kwargs):
|
|
89
|
+
for observer in self._observers:
|
|
90
|
+
if self.notification_enabled:
|
|
91
|
+
observer.handle_notification(message, **kwargs)
|
|
92
|
+
|
|
93
|
+
def has_liberty(self, point, color=None, visited=None):
|
|
94
|
+
if color is None:
|
|
95
|
+
color = self.board[point]
|
|
96
|
+
|
|
97
|
+
if visited is None:
|
|
98
|
+
visited = set() # 用于记录已经检查过的点
|
|
99
|
+
|
|
100
|
+
if point in visited:
|
|
101
|
+
return False # 如果已经访问过这个点,不再重复检查
|
|
102
|
+
visited.add(point)
|
|
103
|
+
|
|
104
|
+
for neighbor in self.neighbors[point]:
|
|
105
|
+
if self.board[neighbor] == 0: # 如果邻居是空的,则有气
|
|
106
|
+
return True
|
|
107
|
+
elif self.board[neighbor] == color and self.has_liberty(neighbor, color, visited):
|
|
108
|
+
# 如果邻居是同色,并且递归发现有气,那么这个点也有气
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
# 如果所有路径都检查完毕,仍然没有发现有气,返回False
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def remove_stone(self, point):
|
|
115
|
+
color = self.board[point]
|
|
116
|
+
self.board[point] = 0
|
|
117
|
+
self.latest_removes[-1].append(point)
|
|
118
|
+
self.notify_observers("remove_stone", point=point, score=self.score())
|
|
119
|
+
|
|
120
|
+
for neighbor in self.neighbors[point]:
|
|
121
|
+
if self.board[neighbor] == color:
|
|
122
|
+
self.remove_stone(neighbor)
|
|
123
|
+
|
|
124
|
+
def reset(self):
|
|
125
|
+
self.board = np.zeros([self.board_size])
|
|
126
|
+
self.current_player = BLACK
|
|
127
|
+
self.latest_removes = [[]]
|
|
128
|
+
self.black_suicides = set()
|
|
129
|
+
self.white_suicides = set()
|
|
130
|
+
self.turns = OrderedDict()
|
|
131
|
+
self.notify_observers("reset", **{})
|
|
132
|
+
|
|
133
|
+
def switch_player(self):
|
|
134
|
+
self.current_player = -self.current_player
|
|
135
|
+
self.notify_observers("switch_player", side=self.current_player)
|
|
136
|
+
|
|
137
|
+
def play(self, point, player, turn_check=True):
|
|
138
|
+
if self.latest_player and self.latest_player == player:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
if self.board[point] != 0:
|
|
142
|
+
raise ValueError("Invalid move: position already occupied.")
|
|
143
|
+
|
|
144
|
+
if point >= 302:
|
|
145
|
+
raise ValueError("Invalid move: position not on the board.")
|
|
146
|
+
|
|
147
|
+
if player == BLACK and self.counter % 2 == 1:
|
|
148
|
+
raise ValueError("Invalid move: not the player's turn.")
|
|
149
|
+
|
|
150
|
+
if player == WHITE and self.counter % 2 == 0:
|
|
151
|
+
raise ValueError("Invalid move: not the player's turn.")
|
|
152
|
+
|
|
153
|
+
if turn_check and player != self.current_player:
|
|
154
|
+
raise ValueError("Invalid move: not the player's turn.")
|
|
155
|
+
|
|
156
|
+
if self.latest_removes and len(self.latest_removes[-1]) == 1 and point == self.latest_removes[-1][0]:
|
|
157
|
+
raise ValueError("Invalid move: ko rule violation.")
|
|
158
|
+
|
|
159
|
+
# if player == BLACK and point in self.white_suicides:
|
|
160
|
+
# self.white_suicides.remove(point)
|
|
161
|
+
|
|
162
|
+
# if player == WHITE and point in self.black_suicides:
|
|
163
|
+
# self.black_suicides.remove(point)
|
|
164
|
+
|
|
165
|
+
self.board[point] = player
|
|
166
|
+
# print(point, encoder[point], self.neighbors[point])
|
|
167
|
+
|
|
168
|
+
for neighbor in self.neighbors[point]:
|
|
169
|
+
if self.board[neighbor] == -player: # Opponent's stone
|
|
170
|
+
if not self.has_liberty(neighbor, -player):
|
|
171
|
+
self.remove_stone(neighbor)
|
|
172
|
+
|
|
173
|
+
if not self.has_liberty(point):
|
|
174
|
+
# 如果自己的棋也没有气,则为自杀棋,撤回落子
|
|
175
|
+
self.board[point] = 0
|
|
176
|
+
if player == BLACK:
|
|
177
|
+
self.black_suicides.add(point)
|
|
178
|
+
else:
|
|
179
|
+
self.white_suicides.add(point)
|
|
180
|
+
raise ValueError("Invalid move: suicide is not allowed.")
|
|
181
|
+
|
|
182
|
+
self.turns[self.counter] = encoder[point]
|
|
183
|
+
self.notify_observers("add_stone", point=point, player=player, score=self.score())
|
|
184
|
+
self.latest_player = player
|
|
185
|
+
|
|
186
|
+
def get_empties(self, player):
|
|
187
|
+
empty_points = set([ix for ix, point in enumerate(self.board) if point == 0])
|
|
188
|
+
if self.latest_removes and len(self.latest_removes[-1]) == 1 and self.latest_removes[-1][0] in empty_points:
|
|
189
|
+
empty_points.remove(self.latest_removes[-1][0])
|
|
190
|
+
if player == BLACK:
|
|
191
|
+
for point in self.black_suicides:
|
|
192
|
+
empty_points.remove(point)
|
|
193
|
+
if player == WHITE:
|
|
194
|
+
for point in self.white_suicides:
|
|
195
|
+
empty_points.remove(point)
|
|
196
|
+
return list(empty_points)
|
|
197
|
+
|
|
198
|
+
def score(self):
|
|
199
|
+
total_black_area, total_white_area, total_unclaimed_area = 0, 0, 0
|
|
200
|
+
for piece in polysmalls:
|
|
201
|
+
black_area, white_area, unclaimed_area = calculate_area(self.board, piece, polysmall_area)
|
|
202
|
+
total_black_area += black_area
|
|
203
|
+
total_white_area += white_area
|
|
204
|
+
total_unclaimed_area += unclaimed_area
|
|
205
|
+
for piece in polylarges:
|
|
206
|
+
black_area, white_area, unclaimed_area = calculate_area(self.board, piece, polylarge_area)
|
|
207
|
+
total_black_area += black_area
|
|
208
|
+
total_white_area += white_area
|
|
209
|
+
total_unclaimed_area += unclaimed_area
|
|
210
|
+
|
|
211
|
+
return total_black_area / total_area, total_white_area / total_area, total_unclaimed_area / total_area
|
|
212
|
+
|
|
213
|
+
def is_game_over(self):
|
|
214
|
+
return len(self.get_empties(self.current_player)) == 0
|
|
215
|
+
|
|
216
|
+
def result(self):
|
|
217
|
+
return {}
|
|
218
|
+
|
|
219
|
+
def genmove(self, player):
|
|
220
|
+
if self.simulator is None:
|
|
221
|
+
self.simulator = SimulatedBoard()
|
|
222
|
+
self.simulator.redirect(self)
|
|
223
|
+
return self.simulator.genmove(player)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class SimulatedBoard(Board):
|
|
227
|
+
def __init__(self):
|
|
228
|
+
super().__init__()
|
|
229
|
+
|
|
230
|
+
def redirect(self, board):
|
|
231
|
+
self.board = board.board.copy()
|
|
232
|
+
self.current_player = board.current_player
|
|
233
|
+
self.latest_removes = board.latest_removes.copy()
|
|
234
|
+
self.black_suicides = board.black_suicides.copy()
|
|
235
|
+
self.white_suicides = board.white_suicides.copy()
|
|
236
|
+
self.orginal_counter = board.counter
|
|
237
|
+
self.turns = board.turns.copy()
|
|
238
|
+
|
|
239
|
+
def genmove(self, player):
|
|
240
|
+
best_score = -math.inf
|
|
241
|
+
best_potential = math.inf
|
|
242
|
+
best_move = None
|
|
243
|
+
|
|
244
|
+
for point in self.get_empties(player):
|
|
245
|
+
simulated_score, gain = self.simulate_score(0, point, player)
|
|
246
|
+
simulated_score = simulated_score + 2 * gain
|
|
247
|
+
if simulated_score > best_score:
|
|
248
|
+
best_score = simulated_score
|
|
249
|
+
best_potential = calculate_potential(self.board, point, self.counter)
|
|
250
|
+
best_move = point
|
|
251
|
+
elif simulated_score == best_score:
|
|
252
|
+
potential = calculate_potential(self.board, point, self.counter)
|
|
253
|
+
if potential < best_potential:
|
|
254
|
+
best_potential = potential
|
|
255
|
+
best_move = point
|
|
256
|
+
|
|
257
|
+
return best_move
|
|
258
|
+
|
|
259
|
+
def simulate_score(self, depth, point, player):
|
|
260
|
+
if depth == 1:
|
|
261
|
+
return 0, 0
|
|
262
|
+
|
|
263
|
+
trail = 2
|
|
264
|
+
self.latest_removes.append([])
|
|
265
|
+
black_area_ratio, white_area_ratio, unclaimed_area_ratio = 0, 0, 0
|
|
266
|
+
mean_rival_area_ratio, gain, mean_rival_gain = 0, 0, 0
|
|
267
|
+
try:
|
|
268
|
+
# 假设在 point 落子,计算得分,需要考虑复原棋盘的状态
|
|
269
|
+
self.play(point, player, turn_check=False) # 模拟落子
|
|
270
|
+
black_area_ratio, white_area_ratio, unclaimed_area_ratio = self.score() # 计算得分
|
|
271
|
+
|
|
272
|
+
empty_points = sample(self.get_empties(-player), trail)
|
|
273
|
+
total_rival_area_ratio, total_rival_gain = 0, 0
|
|
274
|
+
for rival_point in empty_points:
|
|
275
|
+
rival_area_ratio, rival_gain = self.simulate_score(depth + 1, rival_point, -player) # 递归计算对手的得分
|
|
276
|
+
total_rival_area_ratio += rival_area_ratio
|
|
277
|
+
total_rival_gain += rival_gain
|
|
278
|
+
mean_rival_area_ratio = total_rival_area_ratio / trail
|
|
279
|
+
mean_rival_gain = total_rival_gain / trail
|
|
280
|
+
except ValueError as e:
|
|
281
|
+
print(e)
|
|
282
|
+
if 'suicide' in str(e):
|
|
283
|
+
raise e
|
|
284
|
+
|
|
285
|
+
self.board[point] = 0 # 恢复棋盘状态
|
|
286
|
+
gain = 0
|
|
287
|
+
if self.latest_removes and len(self.latest_removes) > 0:
|
|
288
|
+
for removed in self.latest_removes[-1]:
|
|
289
|
+
self.board[removed] = -self.current_player
|
|
290
|
+
gain = len(self.latest_removes[-1]) / len(self.board)
|
|
291
|
+
self.latest_removes.pop()
|
|
292
|
+
|
|
293
|
+
if self.counter > self.orginal_counter:
|
|
294
|
+
self.turns.pop(self.counter - 1)
|
|
295
|
+
|
|
296
|
+
if player == BLACK:
|
|
297
|
+
# print(black_area_ratio, mean_rival_area_ratio, gain, mean_rival_gain)
|
|
298
|
+
return black_area_ratio - mean_rival_area_ratio, gain - mean_rival_gain
|
|
299
|
+
else:
|
|
300
|
+
# print(white_area_ratio, mean_rival_area_ratio, gain, mean_rival_gain)
|
|
301
|
+
return white_area_ratio - mean_rival_area_ratio, gain - mean_rival_gain
|