Skip to content

Commit 04f4556

Browse files
committed
Webserver implementation to play games in browser
1 parent 437c3c7 commit 04f4556

17 files changed

Lines changed: 82 additions & 97 deletions

‎.github/workflows/web-docker-smoke.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Web play Docker smoke test
22

3-
# Builds the Docker image and plays the first move of a game through the web server inside it: this checks what can
3+
# Builds the Docker image and plays the first move of a game through the web server inside it. This checks what can
44
# only be checked on Linux in the image (Xvfb, fonts, AWT), as well as the page, the access token and the WebSocket
55
# protocol (see web.WebServer).
66
on:

‎Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ COPY --from=build /app/target/tag-classes.jar /tag/tag-classes.jar
4242
# For the WebServer entry point
4343
EXPOSE 8080
4444

45-
# Every entry point runs under Xvfb, which the others simply do not use, e.g.
45+
# Every entry point runs under Xvfb, though only WebServer uses it, e.g.
4646
# docker run tag RunGames config=...
4747
# docker run -p 8080:8080 tag WebServer games=LawnAndOrder token=...
4848
ENTRYPOINT ["tini", "--", "xvfb-run", "-a", "-s", "-screen 0 2560x1600x24", \

‎src/main/java/gui/AbstractGUIManager.java‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ public Set<Integer> getHumanPlayerIds() {
175175
return humanPlayerIds;
176176
}
177177

178-
/* Read-only access to the standard parts of the GUI, for showing them elsewhere: the web package streams a GUI to
179-
a browser, hides these panels, and draws their contents natively in the page. Call on the Swing thread. */
178+
/* Read-only access to the standard parts of the GUI, so that they can be shown elsewhere (as the web package
179+
does). Call on the Swing thread. */
180180

181181
/**
182182
* The panel of action buttons made by createActionPanel, or null if this GUI does not use one.
@@ -194,19 +194,19 @@ public JComponent getInfoPanel() {
194194
}
195195

196196
/**
197-
* The action buttons (empty if this GUI has none). A button offers an action when it is visible and has text;
198-
* clicking it (doClick) chooses that action, as a click on the GUI would.
197+
* The action buttons (empty if this GUI has none). A button offers an action when it is visible and has text, and
198+
* doClick chooses it.
199199
*/
200200
public JButton[] getActionButtons() {
201201
return actionButtons == null ? new JButton[0] : actionButtons.clone();
202202
}
203203

204204
/**
205-
* The lines of game state information shown in the info panel: status, scores, phase, turn and current player
206-
* (not the list of each player's result, which is mostly noise until the game ends).
205+
* The lines of game state information shown in the info panel: status, scores, phase, turn and current player.
207206
*/
208207
public List<String> getGameStateInfo() {
209208
List<String> lines = new ArrayList<>();
209+
// not playerStatus: each player's result is mostly noise until the game ends
210210
for (JLabel label : new JLabel[]{gameStatus, playerScores, gamePhase, turn, currentPlayer})
211211
if (label.getText() != null && !label.getText().isEmpty())
212212
lines.add(label.getText());

‎src/main/java/gui/GUIMessages.java‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@
44
import java.awt.*;
55

66
/**
7-
* Messages a GUI shows to the player (round over, your turn, ...). On the desktop these are modal dialogs, as
8-
* JOptionPane shows them. A GUI that is not on a desktop can have them shown another way by setting a handler: the web
9-
* package does, as a modal dialog would block the one Swing thread shared by every game it serves, and would never
10-
* reach the browser.
7+
* Messages a GUI shows to the player (round over, your turn, ...). On the desktop these are modal dialogs; a GUI shown
8+
* elsewhere, as by the web package, has a handler show them. GUIs use this rather than JOptionPane.
119
*/
1210
public final class GUIMessages {
1311

@@ -44,8 +42,7 @@ public static void show(Component parent, String message) {
4442
}
4543

4644
/**
47-
* As JOptionPane.showConfirmDialog(parent, message, title, optionType, messageType), but with no answer, as the
48-
* message is for information only.
45+
* As JOptionPane.showConfirmDialog(parent, message, title, optionType, messageType), but with no answer.
4946
*/
5047
public static void showConfirm(Component parent, String message, String title, int optionType, int messageType) {
5148
Handler h = handler;

‎src/main/java/web/AIInsight.java‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@
1515

1616
/**
1717
* Sends the browser what an AI player weighed up for each decision it makes: its most visited actions, with the share
18-
* of its search each took and their value to it, from {@link AbstractPlayer#getDecisionStats()} (MCTS reports these;
19-
* players that do not search report nothing, and nothing is sent).
18+
* of its search each took and their value to it, from {@link AbstractPlayer#getDecisionStats()}.
2019
* <p>
21-
* This shows what the AI knows, including its own hidden information (in a game of simultaneous choices, the card it
22-
* is about to play), so a session sends it only when the browser player asked for it.
20+
* This shows what the AI knows, including its own hidden information (in a game of simultaneous choices, the move it
21+
* is about to make).
2322
*/
2423
class AIInsight implements IGameListener {
2524

@@ -41,6 +40,7 @@ public void onEvent(Event event) {
4140
return;
4241
AbstractPlayer player = game.getPlayers().get(event.playerID);
4342
Map<AbstractAction, Map<String, Object>> stats = player.getDecisionStats();
43+
// players that do not search (random, say) report nothing
4444
if (stats == null || stats.isEmpty()) return;
4545

4646
AbstractGameState state = event.state != null ? event.state : game.getGameState();

‎src/main/java/web/AccessToken.java‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
import java.util.stream.Collectors;
1111

1212
/**
13-
* An optional shared secret for the whole server, so that only people sent the link can play. The link is
14-
* {@code http://host:port/?token=SECRET}: the token is then kept in a cookie (and taken out of the address), and every
15-
* page, API call and WebSocket must carry it. With no token configured, the server is open.
13+
* An optional shared secret for the whole server, so that only people sent the link
14+
* ({@code http://host:port/?token=SECRET}) can play. Every page, API call and WebSocket must carry the token, in the
15+
* query or in the cookie it sets. With no token configured, the server is open.
1616
*/
1717
class AccessToken {
1818

@@ -39,12 +39,12 @@ boolean isOpen() {
3939
}
4040

4141
/**
42-
* Lets an HTTP request through if it carries the token (in the query, which also sets the cookie, or in the
43-
* cookie); otherwise answers 401 and stops it.
42+
* Lets an HTTP request through if it carries the token; otherwise answers 401 and stops it.
4443
*/
4544
void check(Context ctx) {
4645
if (isOpen()) return;
4746
String fromQuery = ctx.queryParam("token");
47+
// a token in the query sets the cookie, which then carries it
4848
if (fromQuery != null && matches(fromQuery)) {
4949
ctx.header("Set-Cookie", COOKIE + "=" + fromQuery + "; Path=/; Max-Age=" + COOKIE_DAYS * 24 * 3600
5050
+ "; HttpOnly; SameSite=Lax" + (ctx.scheme().equals("https") ? "; Secure" : ""));
@@ -67,14 +67,15 @@ <p>This game server is private. Open it with the link you were sent (it ends in
6767
}
6868

6969
/**
70-
* Whether a WebSocket connection carries the token (in its cookie or query).
70+
* Whether a WebSocket connection carries the token.
7171
*/
7272
boolean allows(WsContext ctx) {
7373
return isOpen() || matches(ctx.cookie(COOKIE)) || matches(ctx.queryParam("token"));
7474
}
7575

7676
private boolean matches(String candidate) {
7777
if (candidate == null) return false;
78+
// in constant time, so the time taken gives nothing of the token away
7879
return MessageDigest.isEqual(token, candidate.getBytes(StandardCharsets.UTF_8));
7980
}
8081

‎src/main/java/web/ChromeReader.java‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,7 @@
1212
/**
1313
* Moves a GUI's standard parts (its action buttons, game state information and history; see
1414
* {@link AbstractGUIManager#getActionPanel()}) from the streamed image into the page, where the browser draws them
15-
* natively.
16-
* <p>
17-
* The Swing panels are hidden, but stay in being: after every GUI update their contents are read and sent to the
18-
* browser when they change. An action chosen in the page clicks the same Swing button, and hovering over it sends the
19-
* button the same mouse events, so whatever a game does on a click or a hover (highlights on the board, say) still
20-
* happens. A GUI that does not use a standard panel keeps that part in the image.
15+
* natively. A GUI that does not use a standard panel keeps that part in the image.
2116
* <p>
2217
* Everything here runs on the Swing thread.
2318
*/
@@ -36,6 +31,7 @@ class ChromeReader {
3631
this.out = out;
3732
hasActions = gui.getActionPanel() != null && gui.getActionButtons().length > 0;
3833
hasInfo = gui.getInfoPanel() != null;
34+
// hidden, not removed: update still reads them, and choose clicks their buttons
3935
if (hasActions) gui.getActionPanel().setVisible(false);
4036
if (hasInfo) gui.getInfoPanel().setVisible(false);
4137
}
@@ -110,11 +106,13 @@ private void sendHistory(List<String> history) {
110106
void choose(int i, String label) {
111107
JButton[] buttons = gui.getActionButtons();
112108
if (i < 0 || i >= buttons.length || !offers(buttons[i]) || !buttons[i].getText().equals(label)) return;
109+
// a click on the Swing button, rather than the action itself, so whatever the game does on a click happens
113110
buttons[i].doClick(0);
114111
}
115112

116113
/**
117-
* The mouse entering (or leaving) the page's button for action i, passed on to the Swing button.
114+
* Passes the mouse entering or leaving the page's button for action i on to the Swing button, so a game's hover
115+
* effects (highlights on the board, say) still happen.
118116
*/
119117
void hover(int i, boolean enter) {
120118
JButton[] buttons = gui.getActionButtons();

‎src/main/java/web/FrameStreamer.java‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,8 @@
2121
import java.util.concurrent.TimeUnit;
2222

2323
/**
24-
* Sends what a frame shows to the browser. Every tick the frame's root pane is painted into an image at a scale set by
25-
* the session (the browser's device pixel ratio, times the scale it shows the frame at); the image is split into
26-
* square tiles, and only the tiles that changed since the last tick are sent, each as a PNG.
24+
* Sends what a frame shows to the browser. Every tick the frame's root pane is painted into an image, which is split
25+
* into square tiles, and the tiles that changed since the last tick are sent, each as a PNG.
2726
* <p>
2827
* Each frame is one binary message: a 4-byte big-endian length, a JSON header of that length
2928
* ({@code {seq, w, h, fw, fh, tiles: [{x, y, w, h, len}]}}), then the tiles' PNG bytes in header order. The image and

‎src/main/java/web/GameCatalog.java‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,14 @@
1515

1616
/**
1717
* The games the server offers, and their parameters as a form can show them.
18-
* <p>
19-
* A game's parameters are listed only if its parameter class is a {@link TunableParameters}, and then only those of a
20-
* simple type (numbers, booleans, strings and enums), each with its default and, where the game gives them, its
21-
* possible values.
2218
*/
2319
class GameCatalog {
2420

2521
private final List<GameType> games;
2622

2723
/**
28-
* @param spec a comma-separated list of game names, or "all" (or empty) for every game with a GUI, by name (less
29-
* the template for new games)
24+
* @param spec a comma-separated list of game names, or "all" (or empty) for every game with a GUI except
25+
* GameTemplate, sorted by name
3026
*/
3127
GameCatalog(String spec) {
3228
if (spec == null || spec.isBlank() || spec.equalsIgnoreCase("all")) {
@@ -69,12 +65,17 @@ JsonArray describe() {
6965
return result;
7066
}
7167

68+
/**
69+
* The game's parameters that a form can set, each with its default and any possible values the game lists.
70+
*/
7271
static JsonArray describeParameters(GameType game) {
7372
JsonArray result = new JsonArray();
73+
// only a TunableParameters class lists its parameters
7474
if (!(game.createParameters(0) instanceof TunableParameters<?> params)) return result;
7575
for (String name : params.getParameterNames()) {
7676
Class<?> type = typeOf(params, name);
7777
String kind = kind(type);
78+
// only numbers, booleans, strings and enums
7879
if (kind == null) continue;
7980
JsonObject p = new JsonObject();
8081
p.addProperty("name", name);

‎src/main/java/web/GameSession.java‎

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
import players.human.HumanGUIPlayer;
1717

1818
import javax.swing.*;
19-
import java.util.ArrayList;
2019
import java.awt.*;
20+
import java.util.ArrayList;
2121
import java.util.List;
2222
import java.util.Map;
2323
import java.util.Set;
@@ -59,7 +59,7 @@ public class GameSession {
5959
// the size of the browser's space for the GUI, and its device pixel ratio (Swing thread only)
6060
private Dimension space;
6161
private double dpr;
62-
// the image is drawn at dpr * the scale the browser shows the frame at (see fitFrame)
62+
// image pixels per frame pixel (see fitFrame)
6363
private volatile double imageScale;
6464
private volatile long lastActivity = System.currentTimeMillis();
6565
private final long startedAt = System.currentTimeMillis();
@@ -79,9 +79,7 @@ public GameSession(SessionConfig config, OpponentCatalog opponents, Sender out,
7979
}
8080

8181
/**
82-
* Swing settings for a JVM whose GUIs are all streamed. Swing's own tooltips are switched off, as Swing moves them
83-
* on to a real screen and so out of the frame; the InputForwarder sends their text to the browser instead. Messages
84-
* a GUI would show in a dialog are sent to its browser.
82+
* Swing settings for a JVM whose GUIs are all streamed.
8583
*
8684
* @param lookAndFeel "flat" for FlatLaf's light look and feel, or "default" for Swing's own
8785
*/
@@ -90,8 +88,11 @@ public static void configureSwing(String lookAndFeel) {
9088
FlatLightLaf.setup();
9189
else if (!lookAndFeel.equalsIgnoreCase("default"))
9290
throw new IllegalArgumentException("Unknown look and feel (flat or default): " + lookAndFeel);
91+
// Swing moves a tooltip on to a real screen, and so out of the frame; the InputForwarder sends their text to
92+
// the browser instead
9393
ToolTipManager.sharedInstance().setEnabled(false);
9494
JPopupMenu.setDefaultLightWeightPopupEnabled(true);
95+
// a message a GUI would show in a dialog goes to the browser of the session the GUI belongs to
9596
GUIMessages.setHandler((parent, title, message) -> {
9697
GameSession session = byFrame.get(SwingUtilities.getWindowAncestor(parent));
9798
if (session != null) session.sendMessage(title, message);
@@ -179,6 +180,7 @@ private void start(int w, int h, double dpr) {
179180
sendStarted(seed, players);
180181
guiUpdater = new Timer((int) game.getCoreParameters().frameSleepMS, e -> {
181182
game.updateGUI(gui, frame);
183+
// a GUI can grow as the game goes on
182184
fitFrame();
183185
chrome.update();
184186
reportProgress();
@@ -238,8 +240,7 @@ private JsonArray playerNames(List<AbstractPlayer> players) {
238240
}
239241

240242
/**
241-
* Tells the browser whose turn it is when that changes, and the results once the game is over. Runs on the Swing
242-
* thread, after each GUI update.
243+
* Tells the browser whose turn it is when that changes, and the results once the game is over.
243244
*/
244245
private void reportProgress() {
245246
AbstractGameState state = game.getGameState();
@@ -274,9 +275,8 @@ private void reportProgress() {
274275
}
275276

276277
/**
277-
* A record of the game so far, for a bug report or to set the game up again: the setup (game, seed, players, every
278-
* parameter's value) and the actions taken, described as the browser player saw them (so hiding what they could not
279-
* see), and the results if the game is over.
278+
* A record of the game so far, for a bug report or to set the game up again. The actions are described as the
279+
* browser player saw them.
280280
*/
281281
private JsonObject gameLog() {
282282
JsonObject log = new JsonObject();
@@ -327,10 +327,8 @@ private JsonObject gameLog() {
327327
return log;
328328
}
329329

330-
/**
331-
* The game's action history. The game thread adds to it, so copying it may meet a change; it is then copied again.
332-
*/
333330
private static List<utilities.Pair<Integer, core.actions.AbstractAction>> history(AbstractGameState state) {
331+
// the game thread adds to the history, so copying it may meet a change; it is then copied again
334332
for (int attempt = 0; ; attempt++) {
335333
try {
336334
return state.getHistory();
@@ -356,14 +354,14 @@ private void resize(int w, int h, double dpr) {
356354
}
357355

358356
/**
359-
* Sizes the frame to the browser's space or, where the GUI's layout needs more (many GUIs are laid out at a fixed
360-
* size), to a larger frame of the same proportions, which the browser scales down to fit. The layout's own preferred size is used, not the
361-
* size the GUI may have set on its panel, as that counts the panels the ChromeReader hides. A GUI can grow as the
362-
* game goes on, so this runs after every update. Swing thread only.
357+
* Sizes the frame to the browser's space or, where the GUI needs more, to a larger frame of the same proportions,
358+
* which the browser scales down to fit. Swing thread only.
363359
*/
364360
private void fitFrame() {
365361
Container content = frame.getContentPane();
366362
LayoutManager layout = content.getLayout();
363+
// Many GUIs are laid out at a fixed size. The layout's own preferred size is used, not the size the GUI may have
364+
// set on its panel, as that counts the panels the ChromeReader hides.
367365
Dimension needed = layout != null ? layout.preferredLayoutSize(content) : content.getPreferredSize();
368366
// scaled down by s, a frame of the space's proportions fills the space exactly
369367
double s = Math.min(1, Math.min((double) space.width / needed.width, (double) space.height / needed.height));
@@ -378,23 +376,22 @@ private void fitFrame() {
378376
}
379377

380378
/**
381-
* Ends the session because the browser player has been away too long: tells the browser, then closes the
382-
* connection, which stops the session.
379+
* Tells the browser the game was ended for want of activity, and closes the connection (which stops the session).
383380
*/
384381
public void endIdle(int minutes) {
385382
sendError("This game was ended after " + minutes + (minutes == 1 ? " minute" : " minutes") + " without activity.");
386383
out.close();
387384
}
388385

389386
/**
390-
* Ends the game (the game thread is interrupted while it waits for the browser player's action) and disposes of
391-
* the frame.
387+
* Ends the game and disposes of the frame.
392388
*/
393389
public synchronized void stop() {
394390
if (stopped) return;
395391
stopped = true;
396392
if (!started) return;
397393
game.setStopped(true);
394+
// the game thread may be waiting for the browser player's action
398395
gameThread.interrupt();
399396
streamer.stop();
400397
SwingUtilities.invokeLater(() -> {

0 commit comments

Comments
 (0)