const MAX_PROMPT_ITEMS = 20; function undisplay(element) { element.style.display="none"; } function add_message(severity, message) { message = `
× ${message}
`; const message_area = document.getElementById('message-area'); message_area.insertAdjacentHTML('beforeend', message); } /********************************************************* * Handling server-sent event stream * *********************************************************/ const events = new EventSource("events"); events.onerror = function(event) { if (event.target.readyState === EventSource.CLOSED) { setTimeout(() => { add_message("danger", "Connection to server lost."); }, 1000); } }; events.addEventListener("game-info", event => { const info = JSON.parse(event.data); window.game.set_game_info(info); }); events.addEventListener("player-info", event => { const info = JSON.parse(event.data); window.game.set_player_info(info); }); events.addEventListener("player-enter", event => { const info = JSON.parse(event.data); window.game.set_other_player_info(info); }); events.addEventListener("player-exit", event => { const info = JSON.parse(event.data); window.game.remove_player(info); }); events.addEventListener("player-update", event => { const info = JSON.parse(event.data); if (info.id === window.game.state.player_info.id) window.game.set_player_info(info); else window.game.set_other_player_info(info); }); events.addEventListener("game-state", event => { const state = JSON.parse(event.data); window.game.reset_game_state(); window.game.set_prompts(state.prompts); window.game.set_active_prompt(state.active_prompt); window.game.set_players_answered(state.players_answered); window.game.set_players_answering(state.players_answering); window.game.set_answering_idle(state.answering_idle); window.game.set_end_answers(state.end_answers); window.game.set_ambiguities(state.ambiguities); window.game.set_players_judged(state.players_judged); window.game.set_players_judging(state.players_judging); window.game.set_judging_idle(state.judging_idle); window.game.set_end_judging(state.end_judging); window.game.set_scores(state.scores); window.game.set_new_game_votes(state.new_game_votes); window.game.state_ready(); }); events.addEventListener("prompt", event => { const prompt = JSON.parse(event.data); window.game.add_or_update_prompt(prompt); }); events.addEventListener("start", event => { const prompt = JSON.parse(event.data); window.game.set_active_prompt(prompt); }); events.addEventListener("player-answered", event => { const player = JSON.parse(event.data); window.game.set_player_answered(player); }); events.addEventListener("player-answering", event => { const player = JSON.parse(event.data); window.game.set_player_answering(player); }); events.addEventListener("answering-idle", event => { const value = JSON.parse(event.data); window.game.set_answering_idle(value); }); events.addEventListener("vote-end-answers", event => { const player = JSON.parse(event.data); window.game.set_player_vote_end_answers(player); }); events.addEventListener("unvote-end-answers", event => { const player = JSON.parse(event.data); window.game.set_player_unvote_end_answers(player); }); events.addEventListener("ambiguities", event => { const ambiguities = JSON.parse(event.data); window.game.set_ambiguities(ambiguities); }); events.addEventListener("player-judged", event => { const player = JSON.parse(event.data); window.game.set_player_judged(player); }); events.addEventListener("player-judging", event => { const player = JSON.parse(event.data); window.game.set_player_judging(player); }); events.addEventListener("judging-idle", event => { const value = JSON.parse(event.data); window.game.set_judging_idle(value); }); events.addEventListener("vote-end-judging", event => { const player = JSON.parse(event.data); window.game.set_player_vote_end_judging(player); }); events.addEventListener("unvote-end-judging", event => { const player = JSON.parse(event.data); window.game.set_player_unvote_end_judging(player); }); events.addEventListener("scores", event => { const scores = JSON.parse(event.data); window.game.set_scores(scores); }); events.addEventListener("vote-new-game", event => { const player = JSON.parse(event.data); window.game.set_player_vote_new_game(player); }); events.addEventListener("unvote-new-game", event => { const player = JSON.parse(event.data); window.game.set_player_unvote_new_game(player); }); /********************************************************* * Game and supporting classes * *********************************************************/ function copy_to_clipboard(id) { const tmp = document.createElement("input"); tmp.setAttribute("value", document.getElementById(id).innerHTML); document.body.appendChild(tmp); tmp.select(); document.execCommand("copy"); document.body.removeChild(tmp); } const GameInfo = React.memo(props => { if (! props.id) return null; return (
{props.id} {" "} Share this link to invite friends:{" "} {props.url} {" "}
); }); const PlayerInfo = React.memo(props => { if (! props.player.id) return null; const all_players = [props.player, ...props.other_players]; const sorted_players = all_players.sort((a,b) => { return b.score - a.score; }); const names_and_scores = sorted_players.map(player => { if (player.score) return `${player.name} (${player.score})`; else return player.name; }).join(', '); return (
Players: {names_and_scores}
); }); function fetch_method_json(method, api = '', data = {}) { const response = fetch(api, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); return response; } function fetch_post_json(api = '', data = {}) { return fetch_method_json('POST', api, data); } async function fetch_put_json(api = '', data = {}) { return fetch_method_json('PUT', api, data); } class CategoryRequest extends React.PureComponent { constructor(props) { super(props); this.category = React.createRef(); this.handle_change = this.handle_change.bind(this); this.handle_submit = this.handle_submit.bind(this); } handle_change(event) { const category_input = this.category.current; const category = category_input.value; const match = category.match(/[0-9]+/); if (match) { const num_items = parseInt(match[0], 10); if (num_items > 0 && num_items <= MAX_PROMPT_ITEMS) category_input.setCustomValidity(""); } } async handle_submit(event) { const form = event.currentTarget; const category_input = this.category.current; const category = category_input.value; /* Prevent the default page-changing form-submission behavior. */ event.preventDefault(); const match = category.match(/[0-9]+/); if (match === null) { category_input.setCustomValidity("Category must include a number"); form.reportValidity(); return; } const num_items = parseInt(match[0], 10); if (num_items > MAX_PROMPT_ITEMS) { category_input.setCustomValidity(`Maximum number of items is ${MAX_PROMPT_ITEMS}`); form.reportValidity(); return; } if (num_items < 1) { category_input.setCustomValidity("Category must require at least one item."); form.reportValidity(); return; } const response = await fetch_post_json("prompts", { items: num_items, prompt: category }); if (response.status === 200) { const result = await response.json(); if (! result.valid) { add_message("danger", result.message); return; } } else { add_message("danger", "An error occurred submitting your category"); } form.reset(); } render() { return (

Submit a Category

Suggest a category to play. Don't forget to include the number of items for each person to submit.

); } } const PromptOption = React.memo(props => { const prompt = props.prompt; if (prompt.votes_against.find(v => v === props.player.name)) return false; return ( ); }); const PromptOptions = React.memo(props => { if (props.prompts.length === 0) return null; return (

Vote on Categories

Select any categories below that you'd like to play. You can choose as many as you'd like.

{props.prompts.map( prompt => )}
); }); const LetsPlay = React.memo(props => { const quorum = Math.max(0, props.num_players - props.prompts.length); const max_votes = props.prompts.reduce( (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0); if (max_votes < quorum) { let text = `Before we play, we should collect a bit more information about what category would be interesting for this group. So, either type a new category option above, or else`; if (props.prompts.length) { if (props.prompts.length > 1) text += " vote on some of the categories below."; else text += " vote on the category below."; } else { text += " wait for others to submit, and then vote on them below."; } return (

{text}

); } const candidates = props.prompts.filter(p => p.votes.length >= max_votes); const index = Math.floor(Math.random() * candidates.length); const winner = candidates[index]; return (

Let's Play

That should be enough voting. If you're not waiting for any other players to join, then let's start.

); }); class Ambiguities extends React.PureComponent { constructor(props) { super(props); function canonize(word) { return word.replace(/((a|an|the) )?(.*?)s?$/i, '$3'); } const word_sets = []; for (let word of props.words) { const word_canon = canonize(word); let found_match = false; for (let set of word_sets) { const set_canon = canonize(set.values().next().value); if (word_canon === set_canon) { set.add(word); found_match = true;; break; } } if (! found_match) { const set = new Set(); set.add(word); word_sets.push(set); } } this.state = { word_sets: word_sets, selected: null, starred: null }; this.submitted = false; this.judging_sent_recently = false; } async handle_submit() { /* Don't submit a second time. */ if (this.submitted) return; const response = await fetch_post_json( `judged/${this.props.prompt.id}`,{ word_groups: this.state.word_sets.map( set => ({ words: Array.from(set), kudos: this.state.starred === set ? true : false })) } ); if (response.status === 200) { const result = await response.json(); if (! result.valid) { add_message("danger", result.message); return; } } else { add_message("danger", "An error occurred submitting the results of your judging"); return; } this.submitted = true; } handle_click(word) { /* Let the server know we are doing some judging, (but rate limit * this so we don't send a "judging" notification more frquently * than necessary. */ if (! this.judging_sent_recently) { fetch_post_json(`judging/${this.props.prompt.id}`); this.judging_sent_recently = true; setTimeout(() => { this.judging_sent_recently = false; }, 1000); } if (this.state.selected == word) { /* Second click on same word removes the word from the group. */ const idx = this.state.word_sets.findIndex(s => s.has(word)); const set = this.state.word_sets[idx]; if (set.size === 1) { /* When the word is already alone, there's nothing to do but * to un-select it. */ this.setState({ selected: null }); return; } const new_set = new Set([...set].filter(w => w !== word)); this.setState({ selected: null, word_sets: [...this.state.word_sets.slice(0, idx), new_set, new Set().add(word), ...this.state.word_sets.slice(idx+1)] }); } else if (this.state.selected) { /* Click of a second word groups the two together. */ const idx1 = this.state.word_sets.findIndex(s => s.has(this.state.selected)); const idx2 = this.state.word_sets.findIndex(s => s.has(word)); const set1 = this.state.word_sets[idx1]; const set2 = this.state.word_sets[idx2]; const new_set = new Set([...set2, ...set1]); if (idx1 < idx2) { this.setState({ selected: null, word_sets: [...this.state.word_sets.slice(0, idx1), ...this.state.word_sets.slice(idx1 + 1, idx2), new_set, ...this.state.word_sets.slice(idx2 + 1)] }); } else { this.setState({ selected: null, word_sets: [...this.state.word_sets.slice(0, idx2), new_set, ...this.state.word_sets.slice(idx2 + 1, idx1), ...this.state.word_sets.slice(idx1 + 1)] }); } } else { /* First click of a word selects it. */ this.setState({ selected: word }); } } render() { let move_on_button = null; if (this.props.idle) { move_on_button = ( ); } let still_waiting = null; const judging_players = Object.keys(this.props.players_judging); if (judging_players.length) { still_waiting = (

Still waiting for the following player {judging_players.length > 1 ? 's' : '' } :

); } if (this.props.players_judged.has(this.props.player.name)) { return (

Submission received

The following players have completed judging:{' '} {[...this.props.players_judged].join(', ')}

{still_waiting} {move_on_button}
); } const btn_class = "ambiguity-button"; const btn_selected_class = btn_class + " selected"; return (

Judging Answers

Click/tap on each pair of answers that should be scored as equivalent, (or click a word twice to split it out from a group). Remember, what goes around comes around, so it's best to be generous when judging.

Also, for an especially fun or witty answer, you can give kudos by clicking the star on the right. You may only do this for one word/group.

{this.props.prompt.prompt}

{this.state.word_sets.map(set => { return (
{Array.from(set).map(word => { return ( ); })} { event.stopPropagation(); this.setState({ starred: set }); }} > {this.state.starred === set ? '★' : '☆' }
); })}

Click here when done judging:

); } } class ActivePrompt extends React.PureComponent { constructor(props) { super(props); const items = props.prompt.items; this.submitted = false; this.answers = [...Array(items)].map(() => React.createRef()); this.answering_sent_recently = false; this.handle_submit = this.handle_submit.bind(this); this.handle_change = this.handle_change.bind(this); } handle_change(event) { /* We don't care (or even look) at what the player is typing at * this point. We simply want to be informed that the player _is_ * typing so that we can tell the server (which will tell other * players) that there is activity here. */ /* Rate limit so that we don't send an "answering" notification * more frequently than necessary. */ if (! this.answering_sent_recently) { fetch_post_json(`answering/${this.props.prompt.id}`); this.answering_sent_recently = true; setTimeout(() => { this.answering_sent_recently = false; }, 1000); } } async handle_submit(event) { const form = event.currentTarget; /* Prevent the default page-changing form-submission behavior. */ event.preventDefault(); /* And don't submit a second time. */ if (this.submitted) return; const response = await fetch_post_json(`answer/${this.props.prompt.id}`, { answers: this.answers.map(r => r.current.value) }); if (response.status === 200) { const result = await response.json(); if (! result.valid) { add_message("danger", result.message); return; } } else { add_message("danger", "An error occurred submitting your answers"); return; } /* Everything worked. Server is happy with our answers. */ form.reset(); this.submitted = true; } render() { let still_waiting = null; const answering_players = Object.keys(this.props.players_answering);; if (answering_players.length) { still_waiting = (

Still waiting for the following player {answering_players.length > 1 ? 's' : ''} :

); } let move_on_button = null; if (this.props.idle) { move_on_button =( ); } if (this.props.players_answered.has(this.props.player.name)) { return (

Submission received

The following players have submitted their answers:{' '} {[...this.props.players_answered].join(', ')}

{still_waiting} {move_on_button}
); } return (

The Game of Empathy

Remember, you're trying to match your answers with what the other players submit. Give {this.props.prompt.items} answer {this.props.prompt.items > 1 ? 's' : ''} for the following prompt:

{this.props.prompt.prompt}

{[...Array(this.props.prompt.items)].map((whocares,i) => { return (
); })}
); } } class Game extends React.PureComponent { constructor(props) { super(props); this.state = { game_info: {}, player_info: {}, other_players: [], prompts: [], active_prompt: null, players_answered: new Set(), players_answering: {}, answering_idle: false, end_answers_votes: new Set(), ambiguities: null, players_judged: new Set(), players_judging: {}, judging_idle: false, end_judging_votes: new Set(), scores: null, new_game_votes: new Set(), ready: false }; } set_game_info(info) { this.setState({ game_info: info }); } set_player_info(info) { this.setState({ player_info: info }); } set_other_player_info(info) { const other_players_copy = [...this.state.other_players]; const idx = other_players_copy.findIndex(o => o.id === info.id); if (idx >= 0) { other_players_copy[idx] = info; } else { other_players_copy.push(info); } this.setState({ other_players: other_players_copy }); } remove_player(info) { this.setState({ other_players: this.state.other_players.filter(o => o.id !== info.id) }); } reset_game_state() { this.setState({ prompts: [], active_prompt: null, players_answered: new Set(), players_answering: {}, answering_idle: false, end_answers_votes: new Set(), ambiguities: null, players_judged: new Set(), players_judging: {}, judging_idle: false, end_judging_votes: new Set(), scores: null, new_game_votes: new Set(), ready: false }); } set_prompts(prompts) { this.setState({ prompts: prompts }); } add_or_update_prompt(prompt) { const prompts_copy = [...this.state.prompts]; const idx = prompts_copy.findIndex(p => p.id === prompt.id); if (idx >= 0) { prompts_copy[idx] = prompt; } else { prompts_copy.push(prompt); } this.setState({ prompts: prompts_copy }); } set_active_prompt(prompt) { this.setState({ active_prompt: prompt }); } set_players_answered(players) { this.setState({ players_answered: new Set(players) }); } set_player_answered(player) { const new_players_answering = {...this.state.players_answering}; delete new_players_answering[player]; this.setState({ players_answered: new Set([...this.state.players_answered, player]), players_answering: new_players_answering }); } set_players_answering(players) { const players_answering = {}; for (let player of players) { players_answering[player] = {active: false}; } this.setState({ players_answering: players_answering }); } set_player_answering(player) { /* Set the player as actively answering now. */ this.setState({ players_answering: { ...this.state.players_answering, [player]: {active: true} } }); /* And arrange to have them marked idle very shortly. * * Note: This timeout is intentionally very, very short. We only * need it long enough that the browser has latched onto the state * change to "active" above. We actually use a CSS transition * delay to control the user-perceptible length of time after * which an active player appears inactive. */ setTimeout(() => { this.setState({ players_answering: { ...this.state.players_answering, [player]: {active: false} } }); }, 100); } set_answering_idle(value) { this.setState({ answering_idle: value }); } set_end_answers(players) { this.setState({ end_answers_votes: new Set(players) }); } set_player_vote_end_answers(player) { this.setState({ end_answers_votes: new Set([...this.state.end_answers_votes, player]) }); } set_player_unvote_end_answers(player) { this.setState({ end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player)) }); } set_ambiguities(ambiguities) { this.setState({ ambiguities: ambiguities }); } set_players_judged(players) { this.setState({ players_judged: new Set(players) }); } set_player_judged(player) { const new_players_judging = {...this.state.players_judging}; delete new_players_judging[player]; this.setState({ players_judged: new Set([...this.state.players_judged, player]), players_judging: new_players_judging }); } set_players_judging(players) { const players_judging = {}; for (let player of players) { players_judging[player] = {active: false}; } this.setState({ players_judging: players_judging }); } set_player_judging(player) { /* Set the player as actively judging now. */ this.setState({ players_judging: { ...this.state.players_judging, [player]: {active: true} } }); /* And arrange to have them marked idle very shortly. * * Note: This timeout is intentionally very, very short. We only * need it long enough that the browser has latched onto the state * change to "active" above. We actually use a CSS transition * delay to control the user-perceptible length of time after * which an active player appears inactive. */ setTimeout(() => { this.setState({ players_judging: { ...this.state.players_judging, [player]: {active: false} } }); }, 100); } set_judging_idle(value) { this.setState({ judging_idle: value }); } set_end_judging(players) { this.setState({ end_judging_votes: new Set(players) }); } set_player_vote_end_judging(player) { this.setState({ end_judging_votes: new Set([...this.state.end_judging_votes, player]) }); } set_player_unvote_end_judging(player) { this.setState({ end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player)) }); } set_scores(scores) { this.setState({ scores: scores }); } set_new_game_votes(players) { this.setState({ new_game_votes: new Set(players) }); } set_player_vote_new_game(player) { this.setState({ new_game_votes: new Set([...this.state.new_game_votes, player]) }); } set_player_unvote_new_game(player) { this.setState({ new_game_votes: new Set([...this.state.new_game_votes].filter(p => p !== player)) }); } state_ready() { this.setState({ ready: true }); } render() { const state = this.state; const players_total = 1 + state.other_players.length; if (state.scores) { let perfect_score = 0; for (let i = 0; i < state.active_prompt.items && i < state.scores.words.length; i++) { perfect_score += state.scores.words[i].players.length; } return (

{state.active_prompt.prompt}

Scores

Words submitted

); } if (state.ambiguities){ return ; } if (state.active_prompt) { return ; } if (! state.ready) return null; return [ , ,

, , , ]; } } ReactDOM.render( window.game = me} />, document.getElementById("empathy"));