Skip to content

Commit

Permalink
games: Mastermind
Browse files Browse the repository at this point in the history
  • Loading branch information
PartMan7 committed Jan 13, 2025
1 parent b9083ec commit 70d9995
Show file tree
Hide file tree
Showing 14 changed files with 267 additions and 59 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"ignorePatterns": ["node_modules"],
"rules": {
"@typescript-eslint/no-unused-vars": [
"warn",
"error",
{
"varsIgnorePattern": "^_"
}
Expand Down
2 changes: 1 addition & 1 deletion src/discord/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import chatHandler from '@/discord/handlers/chat';
import loadDiscord from '@/discord/loaders';
import { log } from '@/utils/logger';

const Discord = new Client({ intents: [GatewayIntentBits.Guilds] });
const Discord = new Client({ intents: GatewayIntentBits.Guilds });
Discord.once(Events.ClientReady, readyClient => log(`Connected to Discord! [${readyClient.user.tag}]`));

if (process.env.USE_DISCORD) loadDiscord().then(() => Discord.login(token));
Expand Down
2 changes: 1 addition & 1 deletion src/ps/commands/games.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => {
throw new ChatError($T('GAME.ALREADY_JOINED'));
}
}
const id = generateId();
const id = Game.meta.players === 'single' ? message.author.id : generateId();
const game = new Game.instance({ id, meta: Game.meta, room: message.target, $T, args, by: message.author });
if (game.meta.players === 'many') {
message.reply(`/notifyrank all, ${Game.meta.name}, A game of ${Game.meta.name} has been created!,${gameId}signup`);
Expand Down
2 changes: 1 addition & 1 deletion src/ps/games/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ export type BaseState = { board: unknown; turn: string };

export type ActionResponse<T = undefined> = { success: true; data?: T } | { success: false; error: string };

export type EndType = 'regular' | 'force' | 'dq';
export type EndType = 'regular' | 'force' | 'dq' | 'loss';
80 changes: 41 additions & 39 deletions src/ps/games/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import { Timer } from '@/utils/timer';

import type { GameModel } from '@/database/games';
import type { TranslationFn } from '@/i18n/types';
import type { ActionResponse, BaseState, Meta, Player } from '@/ps/games/common';
import type { Games } from '@/ps/games/index';
import type { ActionResponse, BaseState, EndType, Meta, Player } from '@/ps/games/common';
import type { EmbedBuilder } from 'discord.js';
import type { Client, Room, User } from 'ps-client';
import type { ReactElement } from 'react';
Expand Down Expand Up @@ -76,7 +75,10 @@ export class Game<State extends BaseState> {
onForfeitPlayer?(player: Player, ctx: string | User): ActionResponse;
onReplacePlayer?(turn: BaseState['turn'], withPlayer: User): ActionResponse<Player>;
onStart?(): ActionResponse;
onEnd?(type?: 'force' | 'dq'): string;
onEnd(type?: EndType): string;
onEnd() {
return 'Game ended';
}
trySkipPlayer?(turn: BaseState['turn']): boolean;

constructor(ctx: BaseContext) {
Expand All @@ -96,14 +98,10 @@ export class Game<State extends BaseState> {
this.timerLength = ctx.meta.timer;
this.pokeTimerLength = ctx.meta.pokeTimer ?? ctx.meta.timer;
}

if (ctx.meta.players === 'single') {
this.addPlayer(ctx.by, null);
this.start();
}

}
persist(ctx: BaseContext) {
if (!PSGames[this.meta.id]) PSGames[this.meta.id] = {};
PSGames[this.meta.id]![this.id] = this as unknown as InstanceType<Games[typeof this.meta.id]['instance']>;
PSGames[this.meta.id]![this.id] = this as BaseGame;
if (ctx.backup) {
const parsedBackup: Pick<BaseGame, (typeof backupKeys)[number]> = JSON.parse(ctx.backup);
backupKeys.forEach(key => {
Expand Down Expand Up @@ -133,6 +131,12 @@ export class Game<State extends BaseState> {
});
}
}
after(ctx: BaseContext) {
if (this.meta.players === 'single') {
this.addPlayer(ctx.by, null);
this.start();
}
}

setTimer(comment: string): void {
if (!this.timerLength || !this.pokeTimerLength) return;
Expand Down Expand Up @@ -329,25 +333,23 @@ export class Game<State extends BaseState> {

update(user?: string) {
if (!this.started) return;
if ('render' in this && typeof this.render === 'function') {
if (user) {
const asPlayer = Object.values(this.players).find(player => player.id === user);
if (asPlayer) return this.sendHTML(asPlayer.id, this.render(asPlayer.turn));
if (this.spectators.includes(user)) return this.sendHTML(user, this.render(null));
throw new ChatError('User not in players/spectators');
}
// TODO: Add ping to ps-client HTML opts
Object.keys(this.players).forEach(side => this.sendHTML(this.players[side].id, this.render!(side)));
this.room.send(`/highlighthtmlpage ${this.players[this.turn!].id}, ${this.id}, Your turn!`);
this.room.pageHTML(this.spectators, this.render(null), { name: this.id });
if (user) {
const asPlayer = Object.values(this.players).find(player => player.id === user);
if (asPlayer) return this.sendHTML(asPlayer.id, this.render(asPlayer.turn));
if (this.spectators.includes(user)) return this.sendHTML(user, this.render(null));
throw new ChatError('User not in players/spectators');
}
// TODO: Add ping to ps-client HTML opts
Object.keys(this.players).forEach(side => this.sendHTML(this.players[side].id, this.render!(side)));
this.room.send(`/highlighthtmlpage ${this.players[this.turn!].id}, ${this.id}, Your turn!`);
this.room.pageHTML(this.spectators, this.render(null), { name: this.id });
}

end(type?: 'force' | 'dq'): void {
end(type?: EndType): void {
const message = this.onEnd!(type);
this.clearTimer();
this.update();
if (this.started) {
if (this.started && this.meta.players !== 'single') {
// TODO: Revisit broadcasting logic for single-player games
this.room.sendHTML(this.render!(null));
}
Expand All @@ -360,22 +362,22 @@ export class Game<State extends BaseState> {
if (channel && channel.type === ChannelType.GuildText) channel.send({ embeds: [embed] });
}
// Upload to DB
const model: GameModel = {
id: this.id,
game: this.meta.id,
room: this.roomid,
players: new Map(Object.entries(this.players)),

log: this.log.map(entry => JSON.stringify(entry)),

created: this.createdAt,
started: this.startedAt!,
ended: this.endedAt!,
};
uploadGame(model).catch(err => {
log(err);
throw new Error(`Failed to upload game ${this.id}`);
});
if (this.meta.players !== 'single') {
const model: GameModel = {
id: this.id,
game: this.meta.id,
room: this.roomid,
players: new Map(Object.entries(this.players)),
log: this.log.map(entry => JSON.stringify(entry)),
created: this.createdAt,
started: this.startedAt!,
ended: this.endedAt!,
};
uploadGame(model).catch(err => {
log(err);
throw new ChatError(`Failed to upload game ${this.id}`);
});
}
// Delete from cache
delete PSGames[this.meta.id]![this.id];
gameCache.delete(this.id);
Expand Down
30 changes: 20 additions & 10 deletions src/ps/games/mastermind/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,29 @@ import { render } from '@/ps/games/mastermind/render';
import { ChatError } from '@/utils/chatError';
import { sample } from '@/utils/random';

import type { EndType } from '@/ps/games/common';
import type { BaseContext } from '@/ps/games/game';
import type { Guess, GuessResult, State } from '@/ps/games/mastermind/types';
import type { User } from 'ps-client';

export { meta } from '@/ps/games/mastermind/meta';

export class Mastermind extends Game<State> {
ended = false;

constructor(ctx: BaseContext) {
super(ctx);

this.state.cap = parseInt(ctx.args.join(''));
if (this.state.cap > 12 || this.state.cap < 4) throw new ChatError(this.$T('GAME.INVALID_INPUT'));
super.persist(ctx);

if (ctx.backup) return;
this.state.board = [];
this.state.solution = Array.from({ length: 4 }, () => sample(8, this.prng)) as Guess;
this.state.cap = parseInt(ctx.args.join(''));
if (isNaN(this.state.cap)) this.state.cap = 6;
if (isNaN(this.state.cap)) this.state.cap = 8;

super.after(ctx);
}
action(user: User, ctx: string): void {
if (!this.started) throw new ChatError(this.$T('GAME.NOT_STARTED'));
Expand All @@ -28,10 +36,10 @@ export class Mastermind extends Game<State> {
const result = this.guess(guess);
this.state.board.push({ guess, result });
if (result.exact === this.state.solution.length) {
return this.end(); // Set winCtx?
return this.end();
}
if (this.state.board.length >= this.state.cap) {
return this.end('loss'); // FIXME support loss
return this.end('loss');
}
this.nextPlayer();
}
Expand All @@ -54,15 +62,17 @@ export class Mastermind extends Game<State> {
return { exact, moved };
}

// TODO
onEnd(type: any): string {
onEnd(type: EndType): string {
this.ended = true;
const player = Object.values(this.players)[0].name;
if (type === 'loss') {
return `${Object.values(this.players)[0].name} was unable to guess ${this.state.solution.join('')} in ${this.state.cap} guesses.`;
return `${player} was unable to guess ${this.state.solution.join('')} in ${this.state.cap} guesses.`;
}
return `Ended in ${this.state.board.length} turn(s).`; // TODO
const guesses = this.state.board.length;
return `${player} guessed ${this.state.solution.join('')} in ${guesses} turn${guesses === 1 ? '' : 's'}!`;
}

render() {
return render(this.state.board);
render(asPlayer: string | null) {
return render.bind(this.renderCtx)(this.state, asPlayer ? (this.ended ? 'over' : 'playing') : 'spectator');
}
}
Loading

0 comments on commit 70d9995

Please sign in to comment.