Skip to content

Commit 78a4bf0

Browse files
committed
games: Add Play/exchange buttons and refactor API
1 parent dec6946 commit 78a4bf0

10 files changed

Lines changed: 113 additions & 65 deletions

File tree

src/ps/games/game.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ export class Game<State extends BaseState> {
5555
turns: State['turn'][] = [];
5656

5757
renderCtx: {
58+
// Automatically include info like room and game ID in the command
5859
msg: string;
60+
// Remove game ID handling and use the generic message instead
61+
simpleMsg: string;
5962
};
6063

6164
players: Record<BaseState['turn'], Player> = {};
@@ -97,7 +100,10 @@ export class Game<State extends BaseState> {
97100
this.$T = ctx.$T;
98101

99102
this.meta = ctx.meta;
100-
this.renderCtx = { msg: `/msgroom ${ctx.room.id},/botmsg ${this.parent.status.userid},${prefix}@${ctx.room.id} ${ctx.meta.id}` };
103+
this.renderCtx = {
104+
msg: `/msgroom ${ctx.room.id},/botmsg ${this.parent.status.userid},${prefix}@${ctx.id}`,
105+
simpleMsg: `/msgroom ${ctx.room.id},/botmsg ${this.parent.status.userid},${prefix}@${ctx.room.id} ${ctx.meta.id}`,
106+
};
101107

102108
if (ctx.meta.turns) this.turns = Object.keys(ctx.meta.turns);
103109
this.sides = !!ctx.meta.turns;
@@ -376,7 +382,9 @@ export class Game<State extends BaseState> {
376382
this.throw('GAME.NON_PLAYER_OR_SPEC');
377383
}
378384
// TODO: Add ping to ps-client HTML opts
379-
Object.keys(this.players).forEach(side => this.sendHTML(this.players[side].id, this.render(side)));
385+
Object.entries(this.players).forEach(([side, player]) => {
386+
if (!player.out) this.sendHTML(player.id, this.render(side));
387+
});
380388
this.room.send(`/highlighthtmlpage ${this.players[this.turn!].id}, ${this.id}, ${this.$T('GAME.YOUR_TURN')}` as TranslatedText);
381389
if (this.spectators.length > 0) this.room.pageHTML(this.spectators, this.render(null), { name: this.id });
382390
}

src/ps/games/mastermind/render.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function renderCloseSignups(this: Mastermind): ReactElement {
1212
<>
1313
<hr />
1414
{player} is playing a round of {this.meta.name}!
15-
<Button value={`${this.renderCtx.msg} watch ${this.id}`} style={{ marginLeft: 16 }}>
15+
<Button value={`${this.renderCtx.msg} watch`} style={{ marginLeft: 16 }}>
1616
Watch
1717
</Button>
1818
{this.setBy || !hasGuessed ? (
@@ -24,7 +24,7 @@ export function renderCloseSignups(this: Mastermind): ReactElement {
2424
{this.setBy ? (
2525
`${this.setBy.name} has set a code for ${player}.`
2626
) : !hasGuessed ? (
27-
<Form value={`${this.renderCtx.msg} audience ${this.id}, {code}`}>
27+
<Form value={`${this.renderCtx.msg} audience, {code}`}>
2828
<label htmlFor="choosecode">Set Code: </label>
2929
<input type="text" id="choosecode" name="code" style={{ width: 30 }} /> &nbsp;&nbsp;
3030
<input type="submit" value="Set" />
@@ -48,7 +48,7 @@ const COLORS: { color: string; text: string; index: number }[] = [
4848

4949
const scale = 3.5;
5050

51-
type This = { msg: string };
51+
type This = { msg: string; simpleMsg: string };
5252
function Pin({ red, white }: { red?: boolean; white?: boolean }): ReactElement {
5353
return (
5454
<div
@@ -196,7 +196,7 @@ export function render(this: This, data: State, mode: 'playing' | 'over' | 'spec
196196
{mode !== 'spectator' ? (
197197
<div style={{ border: '1px solid', padding: 20, display: 'inline-block', verticalAlign: 'top' }}>
198198
{mode === 'over' ? (
199-
<Button value={`${this.msg} create ${data.cap}`}>Play Again</Button>
199+
<Button value={`${this.simpleMsg} create ${data.cap}`}>Play Again</Button>
200200
) : (
201201
<Form value={`${this.msg} play {guess}`}>
202202
<input type="text" name="guess" placeholder="Your guess!" />

src/ps/games/menus.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ export function renderMenu(room: PSRoomTranslated, meta: Meta, isStaff: boolean)
2020
{game.started ? (
2121
<>
2222
{Object.values(game.players)
23-
.map(player => <Username name={player.name} />)
23+
.map(player => {
24+
const username = <Username name={player.name} />;
25+
return player.out ? <s>{username}</s> : username;
26+
})
2427
.space('/')}
2528
<Button value={`${cmd} watch`} style={{ marginLeft: 10 }}>
2629
Watch
@@ -50,6 +53,11 @@ export function renderMenu(room: PSRoomTranslated, meta: Meta, isStaff: boolean)
5053
)}
5154
{isStaff ? (
5255
<>
56+
{game.meta.autostart === false && game.startable() ? (
57+
<Button value={`${cmd} start ${game.id}`} style={{ marginLeft: 20 }}>
58+
Start
59+
</Button>
60+
) : null}
5361
<Button value={`${cmd} end ${game.id}`} style={{ marginLeft: 20 }}>
5462
End
5563
</Button>

src/ps/games/othello/render.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function renderBoard(this: This, ctx: RenderCtx) {
1818
<span style={{ ...roundStyles, background: cell === 'W' ? 'white' : 'black' }} />
1919
) : action ? (
2020
<Button
21-
value={`${this.msg} play ${ctx.id}, ${i}-${j}`}
21+
value={`${this.msg} play ${i}-${j}`}
2222
style={{ ...roundStyles, border: '1px dashed black', background: '#6666' }}
2323
>
2424
{' '}

src/ps/games/render.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,20 @@ export function renderSignups<State extends BaseState>(this: Game<State>, staff:
1515
? Object.entries(this.meta.turns!)
1616
.filter(([turn]) => !this.players[turn])
1717
.map(([side, sideName]) => (
18-
<Button key={side} value={`${this.renderCtx.msg} join ${this.id}, ${side}`} style={{ margin: 5 }}>
18+
<Button key={side} value={`${this.renderCtx.msg} join ${side}`} style={{ margin: 5 }}>
1919
{sideName}
2020
</Button>
2121
))
2222
: null}
23+
{/* TODO Disable this in validation for 1 side left */}
2324
{this.sides && this.turns.length - Object.keys(this.players).length > 1 ? (
24-
<Button value={`${this.renderCtx.msg} join ${this.id}, -`} style={{ margin: 5 }}>
25+
<Button value={`${this.renderCtx.msg} join -`} style={{ margin: 5 }}>
2526
Random
2627
</Button>
2728
) : null}
28-
{!this.sides ? <Button value={`${this.renderCtx.msg} join ${this.id}`}>Join</Button> : null}
29+
{!this.sides ? <Button value={`${this.renderCtx.msg} join`}>Join</Button> : null}
2930
{staff && startable ? (
30-
<Button value={`${this.renderCtx.msg} start ${this.id}`} style={{ marginLeft: 8 }}>
31+
<Button value={`${this.renderCtx.msg} start`} style={{ marginLeft: 8 }}>
3132
Start
3233
</Button>
3334
) : null}
@@ -41,7 +42,7 @@ export function renderCloseSignups<State extends BaseState>(this: Game<State>):
4142
<>
4243
<hr />
4344
<h1>{this.meta.name} Signups have closed.</h1>
44-
<Button value={`${this.renderCtx.msg} watch ${this.id}`}>Watch</Button>
45+
<Button value={`${this.renderCtx.msg} watch`}>Watch</Button>
4546
<hr />
4647
</>
4748
);

src/ps/games/scrabble/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const BaseBoard: BaseBoardType = [
2323
];
2424

2525
export const RACK_SIZE = 7;
26+
export const WIDE_LETTERS = ['Q', 'W', 'Z'];
2627

2728
export const LETTER_COUNTS = {
2829
A: 9,

src/ps/games/scrabble/index.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -325,32 +325,29 @@ export class Scrabble extends Game<State> {
325325
}
326326

327327
render(side: string | null) {
328+
const isActive = !!side && side === this.turn;
328329
const ctx: RenderCtx = {
329330
id: this.id,
330331
baseBoard: this.state.baseBoard,
331332
board: this.state.board,
332333
bag: this.state.bag.length,
333334
getPoints: tile => this.points[tile],
335+
rack: side ? this.state.racks[side] : undefined,
334336
players: Object.fromEntries(
335-
Object.values(this.players).map(({ id, name }) => [
337+
Object.values(this.players).map(({ id, name, out }) => [
336338
id,
337-
{
338-
id,
339-
name,
340-
score: this.state.score[id],
341-
rack: this.state.racks[id].length,
342-
},
339+
{ id, name, score: this.state.score[id], rack: this.state.racks[id].length, out },
343340
])
344341
),
342+
isActive,
345343
side,
346344
turn: this.turn!,
347345
selected: side && side === this.turn ? this.selected : null,
348346
};
349347
if (this.winCtx) {
350348
ctx.header = 'Game ended.';
351-
} else if (side && side === this.turn) {
349+
} else if (isActive) {
352350
ctx.header = 'Your turn!';
353-
ctx.rack = this.state.racks[side];
354351
} else if (side) {
355352
ctx.header = `Waiting for ${this.players[this.turn!]?.name}...`;
356353
ctx.dimHeader = true;

src/ps/games/scrabble/render.tsx

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import type { CellRenderer } from '@/ps/games/render';
77
import type { Scrabble } from '@/ps/games/scrabble';
88
import type { Log } from '@/ps/games/scrabble/logs';
99
import type { BoardTile, Bonus, RenderCtx } from '@/ps/games/scrabble/types';
10-
import type { ReactElement, ReactNode } from 'react';
10+
import type { CSSProperties, ReactElement, ReactNode } from 'react';
11+
import { WIDE_LETTERS } from '@/ps/games/scrabble/constants';
1112

1213
export function renderMove(logEntry: Log, { id, players, $T, renderCtx: { msg } }: Scrabble): [ReactElement, { name: string }] {
1314
const Wrapper = ({ children }: { children: ReactNode }): ReactElement => (
1415
<>
1516
<hr />
1617
{children}
17-
<Button name="send" value={`${msg} watch ${id}`} style={{ float: 'right' }}>
18+
<Button name="send" value={`${msg} watch`} style={{ float: 'right' }}>
1819
Watch!
1920
</Button>
2021
<hr />
@@ -92,6 +93,12 @@ function renderBoard(this: This, ctx: RenderCtx) {
9293
const Cell: CellRenderer<BoardTile | null> = ({ cell, i, j }): ReactElement => {
9394
const baseCell = ctx.baseBoard[i][j];
9495
const isSelected = !!ctx.selected && coincident([i, j], ctx.selected);
96+
const buttonStyles: CSSProperties = {
97+
border: !isSelected ? 'none' : undefined,
98+
background: 'none',
99+
height: 20,
100+
width: 20,
101+
};
95102
return (
96103
<td
97104
style={{
@@ -103,15 +110,13 @@ function renderBoard(this: This, ctx: RenderCtx) {
103110
>
104111
{cell ? (
105112
<Button
106-
value={`${this.msg} ! ${ctx.id},s${encodePos([i, j])}`}
113+
value={`${this.msg} ! s${encodePos([i, j])}`}
107114
style={{
108-
color: cell.blank ? 'grey' : '#000',
115+
...buttonStyles,
116+
color: cell.blank ? '#333' : '#000',
109117
padding: 0,
110-
border: !isSelected ? 'none' : undefined,
111-
background: 'none',
112-
height: 20,
113-
width: 20,
114118
fontSize: 16,
119+
overflow: WIDE_LETTERS.includes(cell.letter) && cell.points ? 'hidden' : undefined,
115120
}}
116121
>
117122
<b>
@@ -122,14 +127,18 @@ function renderBoard(this: This, ctx: RenderCtx) {
122127
) : null}
123128
{!cell && clickable ? (
124129
<Button
125-
value={`${this.msg} ! ${ctx.id},s${encodePos([i, j])}`}
130+
value={`${this.msg} ! s${encodePos([i, j])}`}
126131
style={{
127-
border: !isSelected ? 'none' : undefined,
128-
background: 'none',
129-
height: 20,
130-
width: 20,
131-
fontSize: 16,
132-
...(baseCell === '2*' ? { color: '#000', padding: 0, lineHeight: '15px' } : {}),
132+
...buttonStyles,
133+
...(baseCell === '2*'
134+
? {
135+
color: '#000',
136+
padding: 0,
137+
fontSize: 16,
138+
textAlign: 'center',
139+
lineHeight: '18px',
140+
}
141+
: {}),
133142
}}
134143
>
135144
{baseCell === '2*' ? '★' : ' '}
@@ -167,41 +176,50 @@ function Letter({ letter, points }: { letter: string; points: number }): ReactEl
167176

168177
function Scores({ players }: { players: RenderCtx['players'] }): ReactElement[] {
169178
return Object.values(players).map(player => {
179+
const username = <Username name={player.name} />;
170180
return (
171181
<div>
172-
<Username name={player.name} />: {player.score}p ({player.rack} tiles in rack)
182+
{player.out ? <s>{username}</s> : username}: {player.score} ({player.rack} tile(s) in rack)
173183
</div>
174184
);
175185
});
176186
}
177187

178188
function renderInput(this: This, ctx: RenderCtx): ReactElement | null {
179189
// ctx.selected is only passed for the active player
180-
if (!ctx.selected) {
181-
if (ctx.side && ctx.side === ctx.turn) return <h3>Select a tile to play from.</h3>;
182-
return null;
183-
}
190+
if (!ctx.isActive) return null;
184191
return (
185192
<>
186-
<br />
187-
<Form value={`${this.msg} ! ${ctx.id},p${encodePos(ctx.selected)}{dir} {word}`}>
188-
<center style={{ display: 'inline-block' }}>
189-
<input name="word" type="text" width="200" placeholder="Your word here" />
190-
<br />
191-
<button>Play!</button>
192-
</center>
193+
{ctx.selected ? (
194+
<Form value={`${this.msg} ! p${encodePos(ctx.selected)}{dir} {word}`} style={{ display: 'inline-block', margin: '0 8px' }}>
195+
<center style={{ display: 'inline-block' }}>
196+
<input name="word" type="text" width="200" placeholder="Your word here" />
197+
<br />
198+
<div style={{ textAlign: 'left', margin: '4px 0' }}>
199+
<select name="dir">
200+
<option value="r">Right</option>
201+
<option value="d">Down</option>
202+
</select>
203+
<button style={{ float: 'right' }}>Go!</button>
204+
</div>
205+
</center>
206+
</Form>
207+
) : (
208+
<h3>Select a tile to play from!</h3>
209+
)}
210+
{
193211
<div style={{ display: 'inline-block' }}>
194-
<label>
195-
<input type="radio" name="dir" value="r" style={{ position: 'relative', top: 2 }} required checked />
196-
Right
197-
</label>
198-
<br />
199-
<label>
200-
<input type="radio" name="dir" value="d" style={{ position: 'relative', top: 2 }} />
201-
Down
202-
</label>
212+
<div style={{ textAlign: 'right' }}>
213+
<Button name="send" value={`${this.msg} ! -`}>
214+
Pass
215+
</Button>
216+
</div>
217+
<Form value={`${this.msg} ! x {tiles}`} style={{ margin: '4px 0' }}>
218+
<input name="tiles" placeholder="Exchange tiles" width="100" style={{ marginRight: 4 }} />
219+
<button>Exchange</button>
220+
</Form>
203221
</div>
204-
</Form>
222+
}
205223
</>
206224
);
207225
}

src/ps/games/scrabble/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ export type RenderCtx = {
3535
board: Board;
3636
header?: string;
3737
dimHeader?: boolean;
38-
players: Record<string, { score: number; name: string; rack: number }>;
38+
players: Record<string, { score: number; name: string; rack: number; out?: boolean }>;
3939
getPoints: (tile: string) => number;
4040
bag: number;
4141
rack?: string[];
42+
isActive: boolean;
4243
side: string | null;
4344
turn: string;
4445
selected?: Point | null;

src/ps/handlers/chat.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Message } from 'ps-client';
1+
import { Message, type Room } from 'ps-client';
22

3-
import { PSAliases, PSCommands } from '@/cache';
3+
import { PSAliases, PSCommands, PSGames } from '@/cache';
44
import { prefix } from '@/config/ps';
55
import { i18n } from '@/i18n';
66
import { checkPermissions } from '@/ps/handlers/permissions';
@@ -82,8 +82,22 @@ export function parseArgs(
8282
}
8383

8484
function spoofMessage(argData: string, message: PSMessage, $T: TranslationFn): PSMessage {
85-
const [roomId, newArgData] = argData.slice(1).lazySplit(' ', 1);
86-
const room = message.parent.getRoom(roomId);
85+
let [roomId, newArgData] = argData.lazySplit(' ', 1);
86+
let room: Room | undefined;
87+
if (roomId.startsWith('#')) {
88+
// This logic is game-specific handling, since the room and game type are available from ID
89+
// Messages will look something like `,@#GAME subcommand args`
90+
// This needs to be interpreted as `,@GAME.ROOM GAME.TYPE subcommand GAME.ID, args`
91+
const [gameId, subcommand, args] = argData.lazySplit(' ', 2);
92+
const game = Object.values(PSGames)
93+
.flatMap(games => Object.values(games))
94+
.find(game => game.id === gameId);
95+
if (!game) throw new ChatError($T('INVALID_ROOM_ID'));
96+
if (!subcommand) throw new ChatError($T('GAME.INVALID_INPUT'));
97+
room = game.room;
98+
newArgData = `${game.meta.id} ${subcommand} ${game.id}, ${args ?? ''}`;
99+
}
100+
if (!room) room = message.parent.getRoom(roomId);
87101
if (!room) throw new ChatError($T('INVALID_ROOM_ID'));
88102
const by = room.users.find(user => toId(user) === message.author.id);
89103
if (!by) throw new ChatError($T('NOT_IN_ROOM'));
@@ -110,7 +124,7 @@ export default async function chatHandler(message: PSMessage, originalMessage?:
110124
// Check if this is a spoof message. If so, spoof and pass to the room.
111125
// Will only trigger commands with `flags.routePMs` enabled.
112126
if (argData.startsWith('@')) {
113-
const mockMessage = spoofMessage(argData, message, $T);
127+
const mockMessage = spoofMessage(argData.slice(1), message, $T);
114128
return chatHandler(mockMessage, message);
115129
}
116130
const args = argData.split(/ +/);

0 commit comments

Comments
 (0)