Skip to content

Commit b180fd3

Browse files
committed
Add Referential Sieve (v1.9.0)
- RefSieve is now playable. It understands the basics, sarcastic discards, loaded plays and trash push. - Notably, it does not understand delayed plays, prompts, or finesses yet. - NOTE: Loaded rank play clues are right referential and not direct (as in the doc). This is a more powerful convention that has seen success in 2p. - Fixed some issues with counting bad touch. - Fixed a faulty self-elim check in good touch elim. - No longer fetches the variants JSON for every self-play game (fixes #910).
1 parent 3e5c202 commit b180fd3

33 files changed

+1786
-89
lines changed

README.md

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# hanabi-bot
22
A deterministic NodeJS bot that plays on the [hanab.live](https://hanab.live/) interface. Basic structure and ideas were taken from [Zamiell's example bot](https://github.com/Zamiell/hanabi-live-bot) (Python). You can play with it by inviting any of the `will-bot`'s to your table.
33

4-
It can play with [H-Group](https://hanabi.github.io/) and [Playful Sieve](https://hackmd.io/@sodiumdebt/playful_sieve) conventions. The goal of the bot is to play with humans, so it can handle suboptimal play within reason. However, it still expects that the conventions are followed (in terms of focus, chop, etc.) and does not perform any "learning" during a game.
4+
It can play with [H-Group](https://hanabi.github.io/), [Referential Sieve](https://hackmd.io/Ui6LXAK3TdC7AKSDcN20PQ?view) and [Playful Sieve](https://hackmd.io/@sodiumdebt/playful_sieve) conventions. The goal of the bot is to play with humans, so it can handle suboptimal play within reason. However, it still expects that the conventions are followed (in terms of focus, chop, etc.) and does not perform any "learning" during a game.
55

66
A demo game at H-Group level 3:
77

@@ -40,20 +40,20 @@ Send a PM to the bot on hanab.live (`/pm <HANABI_USERNAME> <message>`) to intera
4040
- `/leave` to kick the bot from your table.
4141
- `/create <name> <maxPlayers> <password>` to have the bot create a table. The name can't have spaces.
4242
- `/start` to have the bot start the game (only works if it is the table leader).
43-
- `/settings [conventions=HGroup,PlayfulSieve] [level]` to set the bot's conventions. To view the current settings, provide no parameters. The bot remembers its settings between games, but plays with H-Group conventions at level 1 on first boot.
43+
- `/settings [conventions=HGroup,RefSieve,PlayfulSieve] [level]` to set the bot's conventions. To view the current settings, provide no parameters. The bot remembers its settings between games, but plays with H-Group conventions at level 1 on first boot.
4444
- If only a level is provided (without a convention set), H-Group is assumed.
4545
- `/restart` and `/remake` to have the bot perform the corresponding room actions after the game has finished (only works if it is the table leader).
4646

4747
Some commands can be sent inside a room to affect all bots that have joined.
48-
- `/setall [conventions=HGroup, PlayfulSieve] [level]` to set conventions and level for all bots.
48+
- `/setall [conventions=HGroup,RefSieve,PlayfulSieve] [level]` to set conventions and level for all bots.
4949
- `/leaveall` to kick all bots from the table.
5050

5151
## Watching replays
5252
A replay from hanab.live or from a file (in JSON) can be simulated using `npm run replay [-- <options>]`. Possible options:
5353
- `id=<id>` indicates the ID of the hanab.live replay to load
5454
- `file=<filePath>` indicates the path to the JSON replay to load (relative from the root directory)
5555
- `index=<index>` sets the index of the player the bot will simulate as (defaults to 0)
56-
- `convention=<HGroup, PlayfulSieve>` sets the conventions for the bot (defaults to HGroup)
56+
- `convention=<HGroup,RefSieve,PlayfulSieve>` sets the conventions for the bot (defaults to HGroup)
5757
- `level=<level>` sets the HGroup level for the bot (defaults to 1)
5858

5959
In a replay, the following commands are also supported (in addition to `hand` and `state`):
@@ -68,7 +68,7 @@ The bot can play games with copies of itself using `npm run self-play [-- <optio
6868
- `seed=<seed>` sets the seed of the first game to be played (defaults to 0)
6969
- The seeding algorithm is different from the one used on hanab.live.
7070
- `variant=<variantName>` sets the variant to be played for all games (defaults to No Variant)
71-
- `convention=<HGroup, PlayfulSieve>` sets the conventions for the bot (defaults to HGroup)
71+
- `convention=<HGroup,RefSieve,PlayfulSieve>` sets the conventions for the bot (defaults to HGroup)
7272
- `level=<level>` sets the HGroup level for the bot (defaults to 1)
7373

7474
The final score for each seed as well as how each game terminated are logged to the console. JSON replays of each game are saved to a `seeds` folder, which can be loaded into hanab.live for viewing.

src/StateProxy.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,8 @@ function finalizeObject(state, path, patches) {
224224
}
225225
}
226226

227-
// return copy;
228-
return Object.freeze(copy);
227+
return copy;
228+
// return Object.freeze(copy);
229229
}
230230

231231
/**
@@ -244,7 +244,7 @@ function finalizeNonProxiedObject(state) {
244244
finalizeNonProxiedObject(value);
245245

246246
}
247-
Object.freeze(state);
247+
// Object.freeze(state);
248248
}
249249

250250
/**

src/basics/Card.js

+8-27
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export class Card extends ActualCard {
159159
superposition = false; // Whether the card is currently in a superposition
160160
hidden = false;
161161
called_to_discard = false;
162+
permission_to_discard = false;
162163
certain_finessed = false;
163164
trash = false;
164165
uncertain = false;
@@ -179,36 +180,11 @@ export class Card extends ActualCard {
179180
* @param {boolean} [clued]
180181
* @param {boolean} [newly_clued]
181182
* @param {(BaseClue & { giver: number, turn: number })[]} [clues] List of clues that have touched this card
182-
* @param {Partial<Card>} extras
183183
*/
184-
constructor(suitIndex, rank, possible, inferred, order = -1, drawn_index = -1, clued = false, newly_clued = false, clues = [], extras = {}) {
184+
constructor(suitIndex, rank, possible, inferred, order = -1, drawn_index = -1, clued = false, newly_clued = false, clues = []) {
185185
super(suitIndex, rank, order, drawn_index, clued, newly_clued, clues);
186186
this.possible = possible;
187187
this.inferred = inferred;
188-
this.rewind_ids = extras.rewind_ids;
189-
this.finesse_ids = extras.finesse_ids;
190-
this.old_inferred = extras.old_inferred;
191-
this.old_possible = extras.old_possible;
192-
this.focused = extras.focused ?? false;
193-
this.finessed = extras.finessed ?? false;
194-
this.bluffed = extras.bluffed ?? false;
195-
this.possibly_bluffed = extras.possibly_bluffed ?? false;
196-
this.chop_moved = extras.chop_moved ?? false;
197-
this.reset = extras.reset ?? false;
198-
this.chop_when_first_clued = extras.chop_when_first_clued ?? false;
199-
this.was_cm = extras.was_cm ?? false;
200-
this.superposition = extras.superposition ?? false;
201-
this.hidden = extras.hidden ?? false;
202-
this.called_to_discard = extras.called_to_discard ?? false;
203-
this.certain_finessed = extras.certain_finessed ?? false;
204-
this.trash = extras.trash ?? false;
205-
this.uncertain = extras.uncertain ?? false;
206-
this.known = extras.known ?? false;
207-
this.info_lock = extras.info_lock ?? undefined;
208-
this.finesse_index = extras.finesse_index ?? -1;
209-
this.reasoning = extras.reasoning?.slice() ?? [];
210-
this.reasoning_turn = extras.reasoning_turn?.slice() ?? [];
211-
this.rewinded = extras.rewinded ?? false;
212188
}
213189

214190
/** @param {Card} json */
@@ -254,7 +230,12 @@ export class Card extends ActualCard {
254230
}
255231

256232
shallowCopy() {
257-
return new Card(this.suitIndex, this.rank, this.possible, this.inferred, this.order, this.drawn_index, this.clued, this.newly_clued, this.clues.slice(), this);
233+
const copy = new Card(this.suitIndex, this.rank, this.possible, this.inferred, this.order, this.drawn_index, this.clued, this.newly_clued, this.clues);
234+
235+
for (const property of Object.getOwnPropertyNames(this))
236+
copy[property] = this[property];
237+
238+
return copy;
258239
}
259240

260241
get possibilities() {

src/basics/Player.js

+23-4
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,25 @@ export class Player {
144144
return copy;
145145
}
146146

147+
/**
148+
* Finds the order referred to by the given order.
149+
* @param {'left' | 'right'} direction
150+
* @param {number[]} hand
151+
* @param {number} order
152+
*/
153+
refer(direction, hand, order) {
154+
const offset = direction === 'right' ? 1 : -1;
155+
const index = hand.indexOf(order);
156+
157+
let target_index = (index + offset + hand.length) % hand.length;
158+
159+
while (this.thoughts[hand[target_index]].touched && !this.thoughts[hand[target_index]].newly_clued)
160+
target_index = (target_index + offset + hand.length) % hand.length;
161+
162+
return hand[target_index];
163+
}
164+
165+
147166
/**
148167
* Returns whether they think the given player is locked (i.e. every card is clued, chop moved, or finessed AND not loaded).
149168
* @param {State} state
@@ -163,7 +182,7 @@ export class Player {
163182
* Returns whether they they think the given player is loaded (i.e. has a known playable or trash).
164183
* @param {State} state
165184
* @param {number} playerIndex
166-
* @param {{assume?: boolean}} options
185+
* @param {{assume?: boolean, symmetric?: boolean}} options
167186
*/
168187
thinksLoaded(state, playerIndex, options = {}) {
169188
return this.thinksPlayables(state, playerIndex, options).length > 0 || this.thinksTrash(state, playerIndex).length > 0;
@@ -173,7 +192,7 @@ export class Player {
173192
* Returns playables in the given player's hand, according to this player.
174193
* @param {State} state
175194
* @param {number} playerIndex
176-
* @param {{assume?: boolean}} options
195+
* @param {{assume?: boolean, symmetric?: boolean}} options
177196
*/
178197
thinksPlayables(state, playerIndex, options = {}) {
179198
const linked_orders = this.linkedOrders(state);
@@ -213,8 +232,8 @@ export class Player {
213232

214233
return card.possibilities.every(p => (card.chop_moved ? state.isBasicTrash(p) : false) || state.isPlayable(p)) && // cm cards can ignore trash ids
215234
card.possibilities.some(p => state.isPlayable(p)) && // Exclude empty case
216-
((options?.assume ?? true) || known_playable() || ((!card.uncertain || playerIndex === state.ourPlayerIndex) && !conflicting_conn())) &&
217-
state.hasConsistentInferences(card);
235+
((options.assume ?? true) || known_playable() || ((!card.uncertain || playerIndex === state.ourPlayerIndex) && !conflicting_conn())) &&
236+
(options.symmetric || state.hasConsistentInferences(card));
218237
});
219238
}
220239

src/basics/clue-result.js

+9-8
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ export function elim_result(state, player, hypo_player, hand, list) {
4343
* @param {number} target
4444
*/
4545
export function bad_touch_result(game, hypo_game, hypo_player, giver, target) {
46-
const { me: old_me } = game;
4746
const { me, state } = hypo_game;
4847

4948
const dupe_scores = game.players.map((player, pi) => {
@@ -94,16 +93,18 @@ export function bad_touch_result(game, hypo_game, hypo_player, giver, target) {
9493
cm_dupe.push(order);
9594
continue;
9695
}
96+
}
9797

98-
const duplicates = state.hands.flatMap(hand => hand.filter(o => {
99-
const old_thoughts = old_me.thoughts[o];
100-
const thoughts = me.thoughts[o];
98+
for (const order of state.hands[target]) {
99+
const card = state.deck[order];
100+
101+
if (!card.newly_clued || bad_touch.includes(order) || trash.includes(order) || cm_dupe.includes(order))
102+
continue;
101103

102-
// We need to check old thoughts, since the clue may cause good touch elim that removes earlier notes
103-
return o !== order && old_thoughts.matches(card, { infer: true }) && (old_thoughts.touched || thoughts.touched);
104-
}));
104+
const duplicates = state.hands.flatMap((hand, i) => hand.filter(o =>
105+
((c = me.thoughts[o]) => c.touched && c.matches(card, { infer: true }) && !trash.includes(order) && (i !== target || o < order))()));
105106

106-
if (duplicates.length > 0 && !(duplicates.every(o => state.deck[o].newly_clued) && order < Math.min(...duplicates)))
107+
if (duplicates.length > 0)
107108
bad_touch.push(order);
108109
}
109110

src/basics/hanabi-util.js

-14
Original file line numberDiff line numberDiff line change
@@ -127,20 +127,6 @@ export function cardValue(state, player, identity, order = -1) {
127127
return 5 - (rank - player.hypo_stacks[suitIndex]);
128128
}
129129

130-
/**
131-
* Finds the index to the right referred to by the given index.
132-
* @param {ActualCard[]} hand
133-
* @param {number} index
134-
*/
135-
export function refer_right(hand, index) {
136-
let target_index = (index + 1) % hand.length;
137-
138-
while (hand[target_index].clued && !hand[target_index].newly_clued)
139-
target_index = (target_index + 1) % hand.length;
140-
141-
return target_index;
142-
}
143-
144130
/**
145131
* Returns whether the order is known to be a particular special suit from empathy.
146132
* @param {Game} game

src/basics/helper.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -124,15 +124,15 @@ export function checkFix(game, oldThoughts, clueAction) {
124124
}
125125

126126
// Any clued cards that lost all inferences
127-
const clued_reset = list.find(order => all_resets.has(order) && !state.deck[order].newly_clued);
127+
const clued_resets = list.filter(order => all_resets.has(order) && !state.deck[order].newly_clued);
128128

129-
if (clued_reset)
130-
logger.info('clued card', clued_reset, 'was newly reset!');
129+
if (clued_resets.length > 0)
130+
logger.info('clued cards', clued_resets, 'were newly reset!');
131131

132-
const duplicate_reveal = state.hands[target].find(order => {
132+
const duplicate_reveal = list.filter(order => {
133133
const card = common.thoughts[order];
134134

135-
if (!list.includes(order) || game.common.thoughts[order].identity() === undefined)
135+
if (game.common.thoughts[order].identity() === undefined || card.clues.filter(clue => clue.type === card.clues.at(-1).type && clue.value === card.clues.at(-1).value ).length > 1)
136136
return false;
137137

138138
// The fix can be in anyone's hand except the giver's
@@ -145,7 +145,7 @@ export function checkFix(game, oldThoughts, clueAction) {
145145
return copy !== undefined;
146146
});
147147

148-
return { fix: clued_reset !== undefined || duplicate_reveal !== undefined };
148+
return { clued_resets, duplicate_reveal };
149149
}
150150

151151
/**

src/basics/player-elim.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ export function good_touch_elim(state, only_self = false) {
330330
if (asymmetric_gt)
331331
continue;
332332

333-
const self_elim = this.playerIndex !== -1 && matches_arr.every(o =>
333+
const self_elim = this.playerIndex !== -1 && matches_arr.length > 0 && matches_arr.every(o =>
334334
state.hands[playerIndex].includes(o) && this.thoughts[o].identity({ infer: true, symmetric: true}) === undefined);
335335
if (self_elim)
336336
continue;

src/command-handler.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import logger from './tools/logger.js';
33
import * as Utils from './tools/util.js';
44

55
import HGroup from './conventions/h-group.js';
6+
import RefSieve from './conventions/ref-sieve.js';
67
import PlayfulSieve from './conventions/playful-sieve.js';
78
import { BOT_VERSION, MAX_H_LEVEL } from './constants.js';
89
import { State } from './basics/State.js';
@@ -17,7 +18,7 @@ import { logPerformAction } from './tools/log.js';
1718
* @typedef {import('./types-live.js').Table} Table
1819
*/
1920

20-
const conventions = { HGroup, PlayfulSieve };
21+
const conventions = { HGroup, RefSieve, PlayfulSieve };
2122
const settings = {
2223
convention: 'HGroup',
2324
level: parseInt(process.env['HANABI_LEVEL'] ?? '1')
@@ -218,7 +219,7 @@ export const handle = {
218219
const state = new State(playerNames, ourPlayerIndex, variant, options);
219220

220221
// Initialize game state using convention set
221-
game = new conventions[/** @type {'HGroup' | 'PlayfulSieve'} */ (settings.convention)](tableID, state, true, settings.level);
222+
game = new conventions[/** @type {'HGroup' | 'RefSieve' | 'PlayfulSieve'} */ (settings.convention)](tableID, state, true, settings.level);
222223

223224
Utils.globalModify({ game, cache: new Map() });
224225

src/constants.js

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { find_all_clues, find_all_discards } from './conventions/h-group/take-action.js';
1+
import { find_all_clues as h_find, find_all_discards as h_dc } from './conventions/h-group/take-action.js';
2+
import { find_all_clues as rs_find, find_all_discards as rs_dc } from './conventions/ref-sieve/take-action.js';
23

34
export const MAX_H_LEVEL = 11;
4-
export const BOT_VERSION = '1.8.19';
5+
export const BOT_VERSION = '1.9.0';
56

67
export const ACTION = /** @type {const} */ ({
78
PLAY: 0,
@@ -31,8 +32,12 @@ export const HAND_SIZE = [-1, -1, 5, 5, 4, 4, 3];
3132

3233
export const ENDGAME_SOLVING_FUNCS = {
3334
HGroup: {
34-
find_clues: find_all_clues,
35-
find_discards: find_all_discards
35+
find_clues: h_find,
36+
find_discards: h_dc
37+
},
38+
RefSieve: {
39+
find_clues: rs_find,
40+
find_discards: rs_dc
3641
}
3742
};
3843

src/conventions/h-group/clue-finder/determine-clue.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,13 @@ function acceptable_clue(game, hypo_game, action, result) {
7676
if (card.reset)
7777
return `card ${logCard(state.deck[order])} ${order} lost all inferences and was reset`;
7878

79-
if (id !== undefined && !visible_card.matches(id))
80-
return `card ${logCard(visible_card)} incorrectly inferred to be ${logCard(id)}`;
79+
const linked = hypo_game.common.linkedOrders(hypo_game.state).has(order);
80+
81+
if (linked)
82+
continue;
83+
84+
if (id !== undefined && linked && !visible_card.matches(id))
85+
return `card ${logCard(visible_card)} ${order} incorrectly inferred to be ${logCard(id)}`;
8186

8287
const looks_playable = hypo_game.common.unknown_plays.has(order) ||
8388
hypo_game.common.hypo_stacks[visible_card.suitIndex] >= visible_card.rank ||

src/conventions/h-group/clue-interpretation/interpret-clue.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ function apply_good_touch(game, action, oldThoughts) {
7373
}
7474
}
7575

76-
return checkFix(game, oldThoughts, action);
76+
const { clued_resets, duplicate_reveal } = checkFix(game, oldThoughts, action);
77+
return { fix: clued_resets.length > 0 || duplicate_reveal.length > 0 };
7778
}
7879

7980
/**

src/conventions/h-group/take-action.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as Utils from '../../tools/util.js';
1313

1414
import { Worker } from 'worker_threads';
1515
import * as path from 'path';
16+
import { shortForms } from '../../variants.js';
1617

1718
/**
1819
* @typedef {import('../h-group.js').default} Game
@@ -456,7 +457,7 @@ export async function take_action(game) {
456457
if (state.inEndgame()) {
457458
logger.highlight('purple', 'Attempting to solve endgame...');
458459

459-
const workerData = { game: Utils.toJSON(game), playerTurn: state.ourPlayerIndex, conv: 'HGroup', logLevel: logger.level };
460+
const workerData = { game: Utils.toJSON(game), playerTurn: state.ourPlayerIndex, conv: 'HGroup', logLevel: logger.level, shortForms };
460461
const worker = new Worker(path.resolve(import.meta.dirname, '../', 'shared', 'endgame.js'), { workerData });
461462

462463
const result = await new Promise((resolve, reject) => {

src/conventions/playful-sieve/interpret-clue.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CLUE } from '../../constants.js';
22
import { IdentitySet } from '../../basics/IdentitySet.js';
3-
import { isTrash, refer_right } from '../../basics/hanabi-util.js';
3+
import { isTrash } from '../../basics/hanabi-util.js';
44
import { checkFix, team_elim } from '../../basics/helper.js';
55
import * as Basics from '../../basics.js';
66
import * as Utils from '../../tools/util.js';
@@ -111,7 +111,9 @@ export function interpret_clue(game, action) {
111111

112112
Basics.onClue(game, action);
113113

114-
let { fix } = checkFix(game, oldCommon.thoughts, action);
114+
const { clued_resets, duplicate_reveal } = checkFix(game, oldCommon.thoughts, action);
115+
116+
let fix = clued_resets.length > 0 || duplicate_reveal.length > 0;
115117

116118
for (const order of hand) {
117119
const card = common.thoughts[order];
@@ -187,7 +189,7 @@ export function interpret_clue(game, action) {
187189
// Referential play (right)
188190
if (clue.type === CLUE.COLOUR || trash_push) {
189191
if (newly_touched.length > 0) {
190-
const referred = newly_touched.map(index => refer_right(hand.map(o => state.deck[o]), index));
192+
const referred = newly_touched.map(index => hand.indexOf(common.refer('right', hand, hand[index])));
191193
const target_index = referred.reduce((max, curr) => Math.max(max, curr));
192194

193195
// Telling chop to play while not loaded, lock

0 commit comments

Comments
 (0)