Implemented AI (“Rando”), refactored card rendering.

This commit is contained in:
Gareth Latty
2019-07-28 18:42:45 +01:00
parent 3836ef8b90
commit 110acdb70f
51 changed files with 1534 additions and 875 deletions
@@ -4,6 +4,7 @@ import * as rules from "../../../games/rules";
import { Handler } from "../../handler";
import * as configure from "../configure";
import * as houseRuleChanged from "../../../events/lobby-event/configured/house-rule-changed";
import * as rando from "../../../games/rules/rando";
/**
* Set the hand size for the lobby.
@@ -24,24 +25,40 @@ export const is = (action: Action): action is ChangeHouseRule =>
action.action === name;
export const handle: Handler<ChangeHouseRule> = (auth, lobby, action) => {
const houseRules = lobby.config.rules.houseRules;
const hr = lobby.config.rules.houseRules;
const events = [];
let changed = false;
switch (action.change.houseRule) {
case "PackingHeat":
houseRules.packingHeat = action.change.settings;
if (hr.packingHeat !== action.change.settings) {
hr.packingHeat = action.change.settings;
changed = true;
}
break;
case "Rando":
houseRules.rando = action.change.settings;
const userEvents = rando.change(lobby, hr.rando, action.change.settings);
if (userEvents !== null) {
events.push(...userEvents);
changed = true;
}
break;
case "Reboot":
houseRules.reboot = action.change.settings;
if (hr.reboot !== action.change.settings) {
hr.reboot = action.change.settings;
changed = true;
}
break;
}
lobby.config.version += 1;
return {
lobby,
events: [
if (changed) {
lobby.config.version += 1;
events.push(
event.targetAll(houseRuleChanged.of(action.change, lobby.config.version))
]
};
);
return {
lobby,
events
};
} else {
return {};
}
};
@@ -1,4 +1,5 @@
import * as card from "../../games/cards/card";
import * as round from "../../games/game/round";
import * as publicRound from "../../games/game/round/public";
/**
@@ -11,10 +12,10 @@ export interface GameStarted {
}
export const of = (
round: publicRound.Playing,
startedRound: round.Playing,
hand?: card.Response[]
): GameStarted => ({
event: "GameStarted",
round,
round: round.censor(startedRound),
...(hand !== undefined ? { hand } : {})
});
@@ -1,6 +1,6 @@
import * as card from "../../games/cards/card";
import { Round } from "../../games/game/round";
import * as user from "../../user";
import * as round from "../../games/game/round";
/**
* Indicates a new round has started.
@@ -14,17 +14,11 @@ export interface RoundStarted {
drawn?: card.Response[];
}
export const of = (
id: round.Id,
czar: user.Id,
players: user.Id[],
call: card.Call,
drawn?: card.Response[]
): RoundStarted => ({
export const of = (round: Round, drawn?: card.Response[]): RoundStarted => ({
event: "RoundStarted",
id: id.toString(),
czar,
players,
call,
id: round.id.toString(),
czar: round.czar,
players: Array.from(round.players.keys()),
call: round.call,
...(drawn === undefined ? {} : { drawn })
});
@@ -1,3 +1,4 @@
import { User } from "../../user";
import * as user from "../../user";
/**
@@ -15,12 +16,16 @@ interface Base {
export interface Joined extends Base {
event: "Joined";
name: user.Name;
privilege?: user.Privilege;
control?: user.Control;
}
export const joined = (user: user.Id, name: user.Name): Joined => ({
export const joined = (id: user.Id, user: User): Joined => ({
event: "Joined",
user,
name
user: id,
name: user.name,
...(user.privilege !== "Unprivileged" ? { privilege: user.privilege } : {}),
...(user.control !== "Human" ? { control: user.control } : {})
});
/**
+103 -5
View File
@@ -1,7 +1,16 @@
import * as event from "../event";
import * as gameStarted from "../events/game-event/game-started";
import * as playSubmitted from "../events/game-event/play-submitted";
import * as roundStarted from "../events/game-event/round-started";
import { ServerState } from "../server-state";
import * as finishedPlaying from "../timeout/finished-playing";
import { User } from "../user";
import * as user from "../user";
import * as util from "../util";
import * as card from "./cards/card";
import { Decks } from "./cards/decks";
import * as decks from "./cards/decks";
import * as play from "./cards/play";
import * as round from "./game/round";
import { Round } from "./game/round";
import * as publicRound from "./game/round/public";
@@ -10,6 +19,8 @@ import * as player from "./player";
import { Player } from "./player";
import * as rules from "./rules";
import { Rules } from "./rules";
import * as lobby from "../lobby";
import * as timeout from "../timeout";
import wu from "wu";
/**
@@ -45,7 +56,6 @@ export const censor: (game: Game) => Public = game => ({
const newPlayerForUser = (decks: Decks, rules: Rules): Player => ({
hand: decks.responses.draw(rules.handSize),
control: "Human",
score: 0
});
@@ -79,8 +89,96 @@ export const start = (
};
};
export const nextCzar = (game: Game): user.Id => {
const current = game.round.czar;
const index = game.playerOrder.findIndex(id => id === current) + 1;
return game.playerOrder[index >= game.playerOrder.length ? 0 : index];
export const atStartOfRound = (
server: ServerState,
first: boolean,
game: Game & { round: round.Playing }
): {
game: Game & { round: round.Playing };
events?: Iterable<event.Distributor>;
timeouts?: Iterable<timeout.TimeoutAfter>;
} => {
const slotCount = card.slotCount(game.round.call);
const events = [];
if (
slotCount > 2 ||
(slotCount === 2 && game.rules.houseRules.packingHeat !== undefined)
) {
const responseDeck = game.decks.responses;
const drawnByPlayer = new Map();
for (const [id, playerState] of game.players) {
if (player.role(game, id) === "Player") {
const drawn = responseDeck.draw(slotCount - 1);
drawnByPlayer.set(id, { drawn });
playerState.hand.push(...drawn);
}
}
if (!first) {
events.push(
event.additionally(roundStarted.of(game.round), drawnByPlayer)
);
}
} else {
if (!first) {
events.push(event.targetAll(roundStarted.of(game.round)));
}
}
if (first) {
events.push(
event.playerSpecificAddition(
gameStarted.of(game.round),
(id, user, player) => ({
hand: player.hand
})
)
);
}
const ais = game.rules.houseRules.rando.current;
for (const ai of ais) {
const player = game.players.get(ai) as Player;
const plays = game.round.plays;
const playId = play.id();
plays.push({
id: playId,
play: player.hand.slice(0, slotCount),
playedBy: ai,
revealed: false
});
events.push(event.targetAll(playSubmitted.of(ai)));
}
const timeouts = [];
const timeout = finishedPlaying.ifNeeded(game.round);
if (timeout !== undefined) {
timeouts.push({
timeout: timeout,
after: server.config.timeouts.finishedPlayingDelay
});
}
return { game, events, timeouts };
};
const canBeCzar = (user: User): boolean =>
user.control !== "Computer" &&
user.presence === "Joined" &&
user.role === "Player";
const czarAfter = (lobby: lobby.WithActiveGame, nextIndex: number): user.Id => {
const game = lobby.game;
const potentialCzar =
game.playerOrder[nextIndex >= game.playerOrder.length ? 0 : nextIndex];
return canBeCzar(lobby.users.get(potentialCzar) as User)
? potentialCzar
: czarAfter(lobby, nextIndex + 1);
};
export const nextCzar = (lobby: lobby.WithActiveGame): user.Id => {
const game = lobby.game;
const current = game.round.czar;
const nextIndex = game.playerOrder.findIndex(id => id === current) + 1;
return czarAfter(lobby, nextIndex);
};
-8
View File
@@ -7,7 +7,6 @@ import { Game } from "./game";
*/
export interface Player {
hand: Hand;
control: Control;
score: Score;
}
@@ -15,7 +14,6 @@ export interface Player {
* A player containing only state all users can see.
*/
export interface Public {
control: Control;
score: Score;
}
@@ -24,11 +22,6 @@ export interface Public {
*/
export type Role = "Czar" | "Player";
/**
* Who controls the player.
*/
export type Control = "Human" | "Computer";
/**
* How many points the player has scored.
* @TJS-type integer
@@ -40,7 +33,6 @@ export type Score = number;
* Produce a public version of the given player.
*/
export const censor = (player: Player): Public => ({
control: player.control,
score: player.score
});
+11 -22
View File
@@ -1,4 +1,9 @@
/** The rules for a standard game.*/
import { HouseRules } from "./rules/houseRules";
import * as houseRules from "./rules/houseRules";
import * as rando from "./rules/rando";
/** The rules for a standard game.
*/
export interface Rules {
/**
* The number of cards in each player's hand.
@@ -21,13 +26,7 @@ export interface Rules {
export interface Public {
handSize: number;
scoreLimit?: number;
houseRules: HouseRules;
}
export interface HouseRules {
packingHeat?: PackingHeat;
reboot?: Reboot;
rando?: Rando;
houseRules: houseRules.Public;
}
/**
@@ -36,13 +35,12 @@ export interface HouseRules {
export const create = (): Rules => ({
handSize: 10,
scoreLimit: 25,
houseRules: {}
houseRules: houseRules.create()
});
/**
* Configuration for the "Packing Heat" house rule.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PackingHeat {}
/**
@@ -60,18 +58,9 @@ export interface Reboot {
cost: number;
}
export interface Rando {
/**
* The number of AI players to add to the game.
* @TJS-type integer
* @minimum 1
* @maximum 10
*/
number: number;
}
export const censor = (rules: Rules): Public => ({
...rules
...rules,
houseRules: houseRules.censor(rules.houseRules)
});
export interface ChangeBase<Name extends string, HouseRule> {
@@ -80,7 +69,7 @@ export interface ChangeBase<Name extends string, HouseRule> {
}
export type ChangePackingHeat = ChangeBase<"PackingHeat", PackingHeat>;
export type ChangeRando = ChangeBase<"Rando", Rando>;
export type ChangeRando = ChangeBase<"Rando", rando.Public>;
export type ChangeReboot = ChangeBase<"Reboot", Reboot>;
export type Change = ChangePackingHeat | ChangeRando | ChangeReboot;
+30
View File
@@ -0,0 +1,30 @@
import { PackingHeat, Reboot, Rules } from "../rules";
import { Rando } from "./rando";
import * as rando from "./rando";
/**
* Non-standard rules that can be applied to a game.
*/
export interface HouseRules {
packingHeat?: PackingHeat;
reboot?: Reboot;
rando: Rando;
}
/**
* The public view of the internal model.
*/
export interface Public {
packingHeat?: PackingHeat;
reboot?: Reboot;
rando?: rando.Public;
}
export const create = (): HouseRules => ({
rando: rando.create()
});
export const censor = (houseRules: HouseRules): Public => ({
...houseRules,
rando: rando.censor(houseRules.rando)
});
+149
View File
@@ -0,0 +1,149 @@
import { RegisterUser } from "../../action/initial/register-user";
import * as event from "../../event";
import * as presenceChanged from "../../events/lobby-event/presence-changed";
import { Lobby } from "../../lobby";
import * as lobby from "../../lobby";
import { User } from "../../user";
import * as util from "../../util";
import * as user from "../../user";
/**
* The maximum number of AI players allowed in a single game.
*/
const max = 10;
/**
* The default name for an AI.
*/
export const aiName = "Rando Cardrissian";
/**
* Apart from the one inherited from Cards against Humanity (a reference to
* Star Wars), we use some references to famous AIs and computers from films
* and games.
*/
export const aiNames = new Set([
"HAL 9000", // 2001: A Space Odyssey
"GLaDOS", // Portal
"Wheatley", // Portal
"TEC-XX", // Paper Mario
"EDI", // Mass Effect
"343 Guilty Spark", // Halo
"Cortana", // Halo
"J.A.R.V.I.S.", // MCU
"Deep Thought", // HHGTTG
"Gibson", // Hackers
"Skynet", // Terminator
"Project 2501", // GITS
"SHODAN", // System Shock
"Mr. House" // Fallout: New Vegas
]);
/**
* The internal model of the Rando house rule.
*/
export interface Rando {
/**
* The ids of active AI players in the game.
*/
current: user.Id[];
/**
* The names or ids of potential AI players that are not currently active in
* the game.
*/
unused: (user.Id | RegisterUser)[];
}
/**
* Create the initial model.
*/
export const create = (): Rando => ({
current: [],
unused: [aiName]
.concat(util.shuffled(aiNames).slice(0, max - 1))
.map(name => ({ name }))
});
/**
* The public view of the Rando house rule.
*/
export interface Public {
/**
* The number of AI players to add to the game.
* @TJS-type integer
* @minimum 1
* @maximum 10
*/
number: number;
}
/**
* Get the public view of the given internal model.
*/
export const censor = (rando: Rando): Public | undefined =>
rando.current.length > 0 ? { number: rando.current.length } : undefined;
const isId = (ai: user.Id | RegisterUser): ai is user.Id =>
typeof ai === "string";
export const createIfNeeded = (
inLobby: Lobby,
ai: user.Id | RegisterUser
): { user: user.Id; events: Iterable<event.Distributor> } => {
if (isId(ai)) {
return {
user: ai,
events: [
event.targetAll(
presenceChanged.joined(ai, inLobby.users.get(ai) as User)
)
]
};
} else {
return lobby.addUser(inLobby, ai, user => ({
...user,
control: "Computer"
}));
}
};
/**
* Change the internal model to match the given public representation.
*/
export function* change(
inLobby: Lobby,
config: Rando,
changeTo?: Public
): Iterable<event.Distributor> | null {
const want = changeTo !== undefined ? changeTo.number : 0;
const have = config.current.length;
const eventsCollection = [];
if (want === have) {
return null;
}
if (want > have) {
const toAdd = want - have;
const added = config.unused
.splice(0, toAdd)
.map(ai => createIfNeeded(inLobby, ai));
for (const { user, events } of added) {
const userData = inLobby.users.get(user) as User;
userData.presence = "Joined";
config.current.push(user);
eventsCollection.push(...events);
}
} else if (have > want) {
const toRemove = have - want;
const removed = config.current.splice(
config.current.length - toRemove,
toRemove
);
for (const ai of removed) {
const user = inLobby.users.get(ai) as User;
user.presence = "Left";
eventsCollection.push(event.targetAll(presenceChanged.left(ai)));
}
config.unused.splice(0, 0, ...removed);
}
yield* eventsCollection;
}
+1 -1
View File
@@ -126,7 +126,7 @@ async function main(): Promise<void> {
return {
change: {
lobby,
events: [event.targetAll(presenceChanged.joined(id, newUser.name))],
events: [event.targetAll(presenceChanged.joined(id, newUser))],
timeouts: [
{
timeout: userDisconnect.of(id),
+22 -1
View File
@@ -1,4 +1,7 @@
import { CreateLobby } from "./action/initial/create-lobby";
import { RegisterUser } from "./action/initial/register-user";
import * as event from "./event";
import * as presenceChanged from "./events/lobby-event/presence-changed";
import * as game from "./games/game";
import { Game } from "./games/game";
import * as rules from "./games/rules";
@@ -8,7 +11,6 @@ import { GameCode } from "./lobby/game-code";
import * as user from "./user";
import { User } from "./user";
import * as util from "./util";
import * as token from "./user/token";
/**
* A game lobby.
@@ -132,3 +134,22 @@ export const censor = (lobby: Lobby): Public => ({
config: config.censor(lobby.config),
...(lobby.game === undefined ? {} : { game: game.censor(lobby.game) })
});
export const addUser = (
lobby: Lobby,
registration: RegisterUser,
change?: (user: User) => User
): {
user: user.Id;
events: Iterable<event.Distributor>;
} => {
const newUser = user.create(registration);
const changedUser = change === undefined ? newUser : change(newUser);
const id = lobby.nextUserId.toString();
lobby.nextUserId += 1;
lobby.users.set(id, changedUser);
return {
user: id,
events: [event.targetAll(presenceChanged.joined(id, changedUser))]
};
};
+6 -2
View File
@@ -15,12 +15,16 @@ export abstract class TaskBase<T> implements Task {
}
protected abstract async begin(server: ServerState): Promise<T>;
protected abstract resolve(lobby: Lobby, work: T): Change;
protected abstract resolve(
lobby: Lobby,
work: T,
server: ServerState
): Change;
public async handle(server: ServerState): Promise<void> {
const work = await this.begin(server);
await change.apply(server, this.gameCode, lobby =>
this.resolve(lobby, work)
this.resolve(lobby, work, server)
);
}
}
+13 -13
View File
@@ -1,11 +1,8 @@
import wu from "wu";
import * as event from "../event";
import * as gameStarted from "../events/game-event/game-started";
import * as decks from "../games/cards/decks";
import * as source from "../games/cards/source";
import * as sources from "../games/cards/sources";
import * as game from "../games/game";
import * as round from "../games/game/round";
import { Lobby } from "../lobby";
import { Change } from "../lobby/change";
import { GameCode } from "../lobby/game-code";
@@ -28,20 +25,23 @@ export class StartGame extends task.TaskBase<decks.Templates[]> {
);
}
protected resolve(lobby: Lobby, work: decks.Templates[]): Change {
protected resolve(
lobby: Lobby,
work: decks.Templates[],
server: ServerState
): Change {
if (lobby.game !== undefined) {
return {};
}
const lobbyGame = game.start(work, lobby.users.keys(), lobby.config.rules);
const gameRound = round.censor(lobbyGame.round);
const baseEvent = gameStarted.of(gameRound);
const events = [
event.playerSpecificAddition(baseEvent, (id, user, player) => ({
hand: player.hand
}))
];
lobby.game = lobbyGame;
return { lobby, events };
const atStartOfRound = game.atStartOfRound(server, true, lobbyGame);
lobby.game = atStartOfRound.game;
return {
lobby,
events: atStartOfRound.events,
timeouts: atStartOfRound.timeouts
};
}
// This is super unlikely timing-wise, and if it happens, the user just has
+24 -39
View File
@@ -1,10 +1,9 @@
import wu from "wu";
import * as event from "../event";
import * as roundStarted from "../events/game-event/round-started";
import { Game } from "../games/game";
import * as game from "../games/game";
import * as lobby from "../lobby";
import { Playing } from "../games/game/round";
import * as timeout from "../timeout";
import * as card from "../games/cards/card";
import * as player from "../games/player";
/**
* Indicates that the round should start if it is still appropriate to do so.
@@ -21,15 +20,14 @@ export const handle: timeout.Handler<RoundStart> = (
server,
timeout,
gameCode,
lobby
inLobby
) => {
const lobbyGame = lobby.game;
if (lobbyGame !== undefined) {
if (lobby.hasActiveGame(inLobby)) {
const lobbyGame = inLobby.game;
const gameRound = lobbyGame.round;
if (gameRound.stage === "Complete") {
const czar = game.nextCzar(lobbyGame);
const czar = game.nextCzar(inLobby);
const [call] = lobbyGame.decks.calls.replace(gameRound.call);
const slotCount = card.slotCount(call);
const roundId = gameRound.id + 1;
const playersInRound = new Set(
wu(lobbyGame.playerOrder).filter(id => id !== czar)
@@ -37,37 +35,24 @@ export const handle: timeout.Handler<RoundStart> = (
lobbyGame.decks.responses.discard(
gameRound.plays.flatMap(play => play.play)
);
lobbyGame.round = {
stage: "Playing",
id: roundId,
czar: czar,
players: playersInRound,
call: call,
plays: []
};
const playersArray = Array.from(playersInRound);
const baseEvent = roundStarted.of(roundId, czar, playersArray, call);
let events;
if (
slotCount > 2 ||
(slotCount === 2 &&
lobbyGame.rules.houseRules.packingHeat !== undefined)
) {
const responseDeck = lobbyGame.decks.responses;
const drawnByPlayer = new Map();
for (const [id, playerState] of lobbyGame.players) {
if (player.role(lobbyGame, id) === "Player") {
const drawn = responseDeck.draw(slotCount - 1);
drawnByPlayer.set(id, { drawn });
playerState.hand.push(...drawn);
}
const updatedGame: Game & { round: Playing } = {
...lobbyGame,
round: {
stage: "Playing",
id: roundId,
czar: czar,
players: playersInRound,
call: call,
plays: []
}
events = [event.additionally(baseEvent, drawnByPlayer)];
} else {
events = [event.targetAll(baseEvent)];
}
return { lobby, events };
};
const atStartOfRound = game.atStartOfRound(server, false, updatedGame);
inLobby.game = atStartOfRound.game;
return {
inLobby,
events: atStartOfRound.events,
timeouts: atStartOfRound.timeouts
};
}
}
return {};
+17 -15
View File
@@ -6,6 +6,7 @@ export interface User {
presence: Presence;
connection: Connection;
privilege: Privilege;
control: Control;
role: Role;
}
@@ -17,6 +18,7 @@ export interface Public {
presence: Presence;
connection: Connection;
privilege: Privilege;
control: Control;
role: Role;
}
@@ -52,6 +54,11 @@ export type Connection = "Connected" | "Disconnected";
*/
export type Role = "Spectator" | "Player";
/**
* Who/what is controlling the player—a human or the computer?
*/
export type Control = "Human" | "Computer";
/**
* If the user is playing.
*/
@@ -69,26 +76,21 @@ export const isSpectating: (user: User) => boolean = user =>
* @param registration The details of the user to create.
* @param privilege The level of privilege the user has.
*/
export function create(
export const create = (
registration: RegisterUser,
privilege: Privilege = "Unprivileged"
): User {
return {
name: registration.name,
presence: "Joined",
connection: "Connected",
privilege: privilege,
role: "Player"
};
}
): User => ({
name: registration.name,
presence: "Joined",
connection: "Connected",
privilege: privilege,
control: "Human",
role: "Player"
});
/**
* Gives a version of the user with only publicly visible properties.
*/
export const censor: (user: User) => Public = user => ({
name: user.name,
presence: user.presence,
connection: user.connection,
privilege: user.privilege,
role: user.role
...user
});