From 58403986d71b04e28281c66a5f7a6088eb991855 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Wed, 23 Apr 2025 20:26:28 +0100 Subject: [PATCH] JS RPC --- demo/public/party.css | 6 + demo/src/app/lib/Game.zig | 83 ++++++++++++ demo/src/app/views/websockets.zig | 154 ++++++----------------- demo/src/app/views/websockets/index.zmpl | 40 +++++- src/jetzig/channels/Channel.zig | 30 +++++ 5 files changed, 193 insertions(+), 120 deletions(-) create mode 100644 demo/src/app/lib/Game.zig diff --git a/demo/public/party.css b/demo/public/party.css index 9f3c769..c7d9524 100644 --- a/demo/public/party.css +++ b/demo/public/party.css @@ -114,3 +114,9 @@ body { .trophy { font-size: 5rem; } +#victor { + margin: 3rem; + font-size: 5rem; + text-align: center; + vertical-align: center; +} diff --git a/demo/src/app/lib/Game.zig b/demo/src/app/lib/Game.zig new file mode 100644 index 0000000..eaf9994 --- /dev/null +++ b/demo/src/app/lib/Game.zig @@ -0,0 +1,83 @@ +const std = @import("std"); +const jetzig = @import("jetzig"); + +grid: Grid, +victor: ?State = null, + +pub const Grid = [9]State; +pub const State = enum { empty, player, cpu, tie }; + +const Game = @This(); + +pub fn gridFromValues(values: []*jetzig.data.Value) Grid { + var grid: [9]Game.State = undefined; + for (0..9) |id| { + if (values[id].* != .null) { + grid[id] = if (values[id].eql("player")) .player else .cpu; + } else { + grid[id] = .empty; + } + } + return grid; +} + +pub fn movePlayer(game: *Game, cell: usize) bool { + if (cell >= game.grid.len) return false; + if (game.grid[cell] != .empty) return false; + + game.grid[cell] = .player; + game.evaluate(); + return true; +} + +pub fn moveCpu(game: *Game) usize { + std.debug.assert(game.victor == null); + var available: [9]usize = undefined; + var available_len: usize = 0; + for (game.grid, 0..) |cell, cell_index| { + if (cell == .empty) { + available[available_len] = cell_index; + available_len += 1; + } + } + std.debug.assert(available_len > 0); + const choice = available[std.crypto.random.intRangeAtMost(usize, 0, available_len - 1)]; + game.grid[choice] = .cpu; + game.evaluate(); + return choice; +} + +pub fn evaluate(game: *Game) void { + var full = true; + for (game.grid) |cell| { + if (cell == .empty) full = false; + } + if (full) { + game.victor = .tie; + return; + } + + const patterns = [_][3]usize{ + .{ 0, 1, 2 }, + .{ 3, 4, 5 }, + .{ 6, 7, 8 }, + .{ 0, 3, 6 }, + .{ 1, 4, 7 }, + .{ 2, 5, 8 }, + .{ 0, 4, 8 }, + .{ 2, 4, 6 }, + }; + for (patterns) |pattern| { + var cpu_victor = true; + var player_victor = true; + + for (pattern) |cell_index| { + if (game.grid[cell_index] != .cpu) cpu_victor = false; + if (game.grid[cell_index] != .player) player_victor = false; + } + + std.debug.assert(!(cpu_victor and player_victor)); + if (cpu_victor) game.victor = .cpu; + if (player_victor) game.victor = .player; + } +} diff --git a/demo/src/app/views/websockets.zig b/demo/src/app/views/websockets.zig index 4a9a2e7..28ee8b1 100644 --- a/demo/src/app/views/websockets.zig +++ b/demo/src/app/views/websockets.zig @@ -1,6 +1,8 @@ const std = @import("std"); const jetzig = @import("jetzig"); +const Game = @import("../lib/Game.zig"); + pub fn index(request: *jetzig.Request) !jetzig.View { return request.render(.ok); } @@ -11,56 +13,22 @@ pub const Channel = struct { try channel.sync(); } - pub fn receive(message: jetzig.channels.Message) !void { - const params = try message.params() orelse return; - - if (params.remove("reset")) { - try resetGame(message.channel); - try message.channel.sync(); - return; - } - - const cell: usize = if (params.getT(.integer, "cell")) |integer| - @intCast(integer) - else - return; - const cells = message.channel.getT(.array, "cells") orelse return; - const items = cells.items(); - - var grid: [9]Game.State = undefined; - for (0..9) |id| { - if (items[id].* != .null) { - grid[id] = if (items[id].eql("player")) .player else .cpu; - } else { - grid[id] = .empty; - } - } - - var game = Game{ .grid = grid }; - game.evaluate(); - - if (game.winner != null) { - try message.channel.publish(.{ .err = "Game is already over." }); - return; - } - - if (game.movePlayer(cell)) { - items[cell] = message.data.string("player"); - if (game.winner == null) { - items[game.moveCpu()] = message.data.string("cpu"); - } - if (game.winner) |winner| { - try message.channel.put("winner", @tagName(winner)); - var results = message.channel.getT(.object, "results") orelse return; - const count = results.getT(.integer, @tagName(winner)) orelse return; - try results.put(@tagName(winner), count + 1); - } - } - - try message.channel.sync(); - } - pub const Actions = struct { + pub fn move(channel: jetzig.channels.Channel, cell: usize) !void { + const cells = channel.getT(.array, "cells") orelse return; + const grid = Game.gridFromValues(cells.items()); + var game = Game{ .grid = grid }; + game.evaluate(); + + if (game.victor != null) { + try channel.publish(.{ .err = "Game is already over." }); + return; + } else { + try movePlayer(channel, &game, cells, cell); + try channel.sync(); + } + } + pub fn reset(channel: jetzig.channels.Channel) !void { try resetGame(channel); try channel.sync(); @@ -68,7 +36,7 @@ pub const Channel = struct { }; fn resetGame(channel: jetzig.channels.Channel) !void { - try channel.put("winner", null); + try channel.put("victor", null); var cells = try channel.put("cells", .array); for (0..9) |_| try cells.append(null); } @@ -77,75 +45,31 @@ pub const Channel = struct { var results = try channel.put("results", .object); try results.put("cpu", 0); try results.put("player", 0); - try results.put("ties", 0); + try results.put("tie", 0); try resetGame(channel); } -}; -const Game = struct { - grid: [9]State, - winner: ?State = null, - - pub const State = enum { empty, player, cpu, tie }; - - pub fn movePlayer(game: *Game, cell: usize) bool { - if (cell >= game.grid.len) return false; - if (game.grid[cell] != .empty) return false; - - game.grid[cell] = .player; - game.evaluate(); - return true; + fn movePlayer( + channel: jetzig.channels.Channel, + game: *Game, + cells: *const jetzig.data.Array, + cell: usize, + ) !void { + const values = cells.items(); + if (game.movePlayer(cell)) { + values[cell] = channel.data.string("player"); + if (game.victor == null) { + values[game.moveCpu()] = channel.data.string("cpu"); + } + if (game.victor) |victor| try setVictor(channel, victor); + } } - pub fn moveCpu(game: *Game) usize { - std.debug.assert(game.winner == null); - var available: [9]usize = undefined; - var available_len: usize = 0; - for (game.grid, 0..) |cell, cell_index| { - if (cell == .empty) { - available[available_len] = cell_index; - available_len += 1; - } - } - std.debug.assert(available_len > 0); - const choice = available[std.crypto.random.intRangeAtMost(usize, 0, available_len - 1)]; - game.grid[choice] = .cpu; - game.evaluate(); - return choice; - } - - fn evaluate(game: *Game) void { - var full = true; - for (game.grid) |cell| { - if (cell == .empty) full = false; - } - if (full) { - game.winner = .tie; - return; - } - - const patterns = [_][3]usize{ - .{ 0, 1, 2 }, - .{ 3, 4, 5 }, - .{ 6, 7, 8 }, - .{ 0, 3, 6 }, - .{ 1, 4, 7 }, - .{ 2, 5, 8 }, - .{ 0, 4, 8 }, - .{ 2, 4, 6 }, - }; - for (patterns) |pattern| { - var cpu_winner = true; - var player_winner = true; - - for (pattern) |cell_index| { - if (game.grid[cell_index] != .cpu) cpu_winner = false; - if (game.grid[cell_index] != .player) player_winner = false; - } - - std.debug.assert(!(cpu_winner and player_winner)); - if (cpu_winner) game.winner = .cpu; - if (player_winner) game.winner = .player; - } + fn setVictor(channel: jetzig.channels.Channel, victor: Game.State) !void { + try channel.put("victor", @tagName(victor)); + var results = channel.getT(.object, "results") orelse return; + const count = results.getT(.integer, @tagName(victor)) orelse return; + try results.put(@tagName(victor), count + 1); + try channel.invoke(.victor, .{ .type = @tagName(victor) }); } }; diff --git a/demo/src/app/views/websockets/index.zmpl b/demo/src/app/views/websockets/index.zmpl index 4295edf..5ca5202 100644 --- a/demo/src/app/views/websockets/index.zmpl +++ b/demo/src/app/views/websockets/index.zmpl @@ -4,8 +4,16 @@ actions: {}, stateChangedCallbacks: [], messageCallbacks: [], + invokeCallbacks: {}, onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); }, onMessage: function(callback) { this.messageCallbacks.push(callback); }, + receive: function(event, callback) { + if (Object.hasOwn(this.invokeCallbacks, event)) { + this.invokeCallbacks[event].push(callback); + } else { + this.invokeCallbacks[event] = [callback]; + } + }, publish: function(data) { if (this.websocket) { const json = JSON.stringify(data); @@ -22,12 +30,20 @@ channel.websocket.addEventListener("message", (event) => { const state_tag = "__jetzig_channel_state__:"; const actions_tag = "__jetzig_actions__:"; + const event_tag = "__jetzig_event__:"; if (event.data.startsWith(state_tag)) { const state = JSON.parse(event.data.slice(state_tag.length)); channel.stateChangedCallbacks.forEach((callback) => { callback(state); }); + } else if (event.data.startsWith(event_tag)) { + const data = JSON.parse(event.data.slice(event_tag.length)); + if (Object.hasOwn(channel.invokeCallbacks, data.method)) { + channel.invokeCallbacks[data.method].forEach(callback => { + callback(data); + }); + } } else if (event.data.startsWith(actions_tag)) { const data = JSON.parse(event.data.slice(actions_tag.length)); data.actions.forEach(action => { @@ -95,19 +111,20 @@ +
diff --git a/src/jetzig/channels/Channel.zig b/src/jetzig/channels/Channel.zig index e28012a..503126e 100644 --- a/src/jetzig/channels/Channel.zig +++ b/src/jetzig/channels/Channel.zig @@ -23,6 +23,32 @@ pub fn RoutedChannel(Routes: type) type { const writer = write_buffer.writer(); try std.json.stringify(data, .{}, writer); try write_buffer.flush(); + channel.websocket.logger.DEBUG( + "Published Channel message for `{s}`", + .{channel.websocket.route.path}, + ) catch {}; + } + + pub fn invoke( + channel: Channel, + comptime method: @TypeOf(.enum_literal), + args: anytype, + ) !void { + // TODO: DRY + var stack_fallback = std.heap.stackFallback(4096, channel.allocator); + const allocator = stack_fallback.get(); + + var write_buffer = channel.websocket.connection.writeBuffer(allocator, .text); + defer write_buffer.deinit(); + + const writer = write_buffer.writer(); + try writer.writeAll("__jetzig_event__:"); + try std.json.stringify(.{ .method = method, .params = args }, .{}, writer); + try write_buffer.flush(); + channel.websocket.logger.DEBUG( + "Invoked Javascript function `{s}` for `{s}`", + .{ @tagName(method), channel.websocket.route.path }, + ) catch {}; } pub fn getT( @@ -45,6 +71,10 @@ pub fn RoutedChannel(Routes: type) type { return try channel.state.put(key, value); } + pub fn remove(channel: Channel, key: []const u8) bool { + return channel.state.remove(key); + } + pub fn sync(channel: Channel) !void { try channel.websocket.syncState(channel); }