Skip to content

Commit e1309ca

Browse files
committed
Clue finding improvements, crash fixes (v1.4.12)
- Fixed a hypo stack-related regression that caused crashes. - Fixed the clue finding algorithm to be more symmetric, no longer gives bad stall clues (fixes #278, fixes #285). - Now connects on promised links (fixes #270). - Now writes trash on cards from a TCM (fixes #271). - No longer performs Shout Discards with non-basic trash (fixes #272). - Fixes a bug where it thought a card was prompted when it was playable when finding own finesses (fixes #289). - Correctly re-interprets clues that reveal a hidden/layered finesse. - Allows giving plays over saves where the play clue would touch all cards needing to be saved. - Now urgently plays into cards part of a finesse (even if they were previously clued). - Fixed a bug that occasionally caused it to give clues containing a finesse that duplicated clued cards.
1 parent 553d061 commit e1309ca

29 files changed

+360
-107
lines changed

src/action-handler.js

+12-7
Original file line numberDiff line numberDiff line change
@@ -138,22 +138,27 @@ export function handle_action(action, catchup = false) {
138138
break;
139139
}
140140
case 'identify': {
141-
const { order, playerIndex, suitIndex, rank, infer = false } = action;
141+
const { order, playerIndex, identities, infer = false } = action;
142142
const card = this.common.thoughts[order];
143143

144144
if (state.hands[playerIndex].findOrder(order) === undefined)
145145
throw new Error('Could not find card to rewrite!');
146146

147-
logger.info(`identifying card with order ${order} as ${logCard({ suitIndex, rank })}, infer? ${infer}`);
147+
logger.info(`identifying card with order ${order} as ${identities.map(logCard)}, infer? ${infer}`);
148148

149149
if (!infer) {
150-
Object.assign(card, { suitIndex, rank });
151-
Object.assign(this.me.thoughts[order], { suitIndex, rank });
152-
Object.assign(state.hands[playerIndex].findOrder(order), { suitIndex, rank });
153-
Object.assign(state.deck[order], { suitIndex, rank });
150+
if (identities.length === 1) {
151+
Object.assign(card, identities[0]);
152+
Object.assign(this.me.thoughts[order], identities[0]);
153+
Object.assign(state.hands[playerIndex].findOrder(order), identities[0]);
154+
Object.assign(state.deck[order], identities[0]);
155+
}
156+
else {
157+
card.rewind_ids = identities;
158+
}
154159
}
155160
else {
156-
card.inferred = card.inferred.intersect({ suitIndex, rank });
161+
card.inferred = card.inferred.intersect(identities);
157162
}
158163
card.rewinded = true;
159164
team_elim(this);

src/basics/Card.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ export class Card extends BasicCard {
106106
*/
107107
inferred;
108108

109+
/**
110+
* All possibilities of the card (from future information)
111+
* @type {Identity[] | undefined}
112+
*/
113+
rewind_ids;
114+
109115
/**
110116
* Only used when undoing a finesse.
111117
* @type {IdentitySet | undefined}
@@ -123,6 +129,7 @@ export class Card extends BasicCard {
123129
hidden = false;
124130
called_to_discard = false;
125131
certain_finessed = false;
132+
trash = false;
126133

127134
finesse_index = -1; // Action index of when the card was finessed
128135
reasoning = /** @type {number[]} */ ([]); // The action indexes of when the card's possibilities/inferences were updated
@@ -148,9 +155,9 @@ export class Card extends BasicCard {
148155
rank: this.rank
149156
});
150157

151-
for (const field of ['inferred', 'possible', 'old_inferred', 'focused',
158+
for (const field of ['inferred', 'possible', 'rewind_ids', 'old_inferred', 'focused',
152159
'finessed', 'bluffed', 'chop_moved', 'reset', 'chop_when_first_clued', 'superposition',
153-
'hidden', 'called_to_discard', 'certain_finessed','finesse_index', 'rewinded'])
160+
'hidden', 'called_to_discard', 'certain_finessed', 'trash', 'finesse_index', 'rewinded'])
154161
new_card[field] = this[field];
155162

156163
for (const field of ['reasoning', 'reasoning_turn'])

src/basics/Game.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ export class Game {
181181
* @param {number} action_index
182182
* @param {Action} rewind_action The rewind action to insert before the target action
183183
* @param {boolean} [mistake] Whether the target action was a mistake
184+
* @param {boolean} [ephemeral] Whether the action should be saved in the action list
184185
*/
185-
rewind(action_index, rewind_action, mistake = false) {
186+
rewind(action_index, rewind_action, mistake = false, ephemeral = false) {
186187
const { actionList } = this.state;
187188

188189
this.rewinds++;
@@ -257,6 +258,8 @@ export class Game {
257258

258259
// Rewrite and save as a rewind action
259260
newGame.handle_action(rewind_action, true);
261+
if (ephemeral)
262+
newGame.state.actionList.pop();
260263
newGame.handle_action(pivotal_action, true);
261264

262265
// Redo all the following actions

src/basics/IdentitySet.js

+4
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export class IdentitySet {
9696
return this.#array;
9797
}
9898

99+
clone() {
100+
return new IdentitySet(this.numSuits, this.value);
101+
}
102+
99103
/**
100104
* @template T
101105
* @param {(i: BasicCard) => T} func

src/basics/Player.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class Player {
126126
card.possible.some(p => state.play_stacks[p.suitIndex] + 1 < p.rank && p.rank <= state.max_ranks[p.suitIndex]) ||
127127
Array.from(linked_orders).some(o => this.thoughts[o].focused && o !== c.order));
128128

129-
return !unsafe_linked &&
129+
return !card.trash && !unsafe_linked &&
130130
card.possibilities.every(p => (card.chop_moved ? state.isBasicTrash(p) : false) || state.isPlayable(p)) && // cm cards can ignore trash ids
131131
card.possibilities.some(p => state.isPlayable(p)) && // Exclude empty case
132132
((options?.assume ?? true) || !this.waiting_connections.some((wc, i1) =>
@@ -161,6 +161,9 @@ export class Player {
161161
});
162162

163163
return Array.from(state.hands[playerIndex].filter(c => {
164+
if (this.thoughts[c.order].trash)
165+
return true;
166+
164167
const poss = this.thoughts[c.order].possibilities;
165168

166169
// Every possibility is trash or duplicated somewhere

src/basics/helper.js

+3-4
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function team_elim(game) {
3737

3838
card.old_inferred = ccard.old_inferred;
3939

40-
for (const property of ['focused', 'finessed', 'chop_moved', 'reset', 'chop_when_first_clued', 'hidden', 'called_to_discard', 'finesse_index', 'rewinded', 'certain_finessed'])
40+
for (const property of ['rewind_ids', 'focused', 'finessed', 'chop_moved', 'reset', 'chop_when_first_clued', 'hidden', 'called_to_discard', 'finesse_index', 'rewinded', 'certain_finessed', 'trash'])
4141
card[property] = ccard[property];
4242

4343
card.reasoning = ccard.reasoning.slice();
@@ -82,8 +82,7 @@ export function checkFix(game, oldThoughts, clueAction) {
8282
// There is a waiting connection that depends on this card
8383
if (reset_order !== undefined) {
8484
const reset_card = common.thoughts[reset_order];
85-
const { suitIndex, rank } = reset_card.possible.array[0];
86-
game.rewind(reset_card.drawn_index, { type: 'identify', order: reset_card.order, playerIndex: target, suitIndex, rank });
85+
game.rewind(reset_card.drawn_index, { type: 'identify', order: reset_card.order, playerIndex: target, identities: [reset_card.possible.array[0].raw()] });
8786
return;
8887
}
8988

@@ -96,7 +95,7 @@ export function checkFix(game, oldThoughts, clueAction) {
9695
if (old_id !== undefined) {
9796
infs_to_recheck.push(old_id);
9897

99-
common.hypo_stacks[old_id.suitIndex] = old_id.rank - 1;
98+
common.hypo_stacks[old_id.suitIndex] = Math.min(common.hypo_stacks[old_id.suitIndex], old_id.rank - 1);
10099
logger.info('setting hypo stacks to', common.hypo_stacks);
101100

102101
const id_hash = logCard(old_id);

src/basics/player-elim.js

+3
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ export function good_touch_elim(state, only_self = false) {
211211
for (const { order } of state.hands[i]) {
212212
addToMaps(order);
213213

214+
if (this.thoughts[order].trash)
215+
continue;
216+
214217
const card = this.thoughts[order];
215218

216219
if (card.inferred.length > 0 && card.inferred.some(inf => !state.isBasicTrash(inf)) && !card.certain_finessed) {

src/constants.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const MAX_H_LEVEL = 11;
2-
export const BOT_VERSION = '1.4.11';
2+
export const BOT_VERSION = '1.4.12';
33

44
export const ACTION = /** @type {const} */ ({
55
PLAY: 0,

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function order_1s(state, player, cards) {
9898
* @param {ActualCard[]} playable_cards
9999
*/
100100
export function determine_playable_card(game, playable_cards) {
101-
const { state } = game;
101+
const { common, state } = game;
102102

103103
/** @type {Card[][]} */
104104
const priorities = [[], [], [], [], [], []];
@@ -108,7 +108,7 @@ export function determine_playable_card(game, playable_cards) {
108108
const card = game.me.thoughts[order];
109109

110110
// Part of a finesse
111-
if (card.finessed) {
111+
if (card.finessed || common.dependentConnections(order).some(wc => wc.connections.some((conn, i) => i >= wc.conn_index && conn.type === 'finesse'))) {
112112
priorities[state.numPlayers > 2 ? 0 : 1].push(card);
113113
continue;
114114
}

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

+32-53
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { CLUE } from '../../../constants.js';
21
import { CLUE_INTERP, LEVEL } from '../h-constants.js';
32
import { cardTouched } from '../../../variants.js';
43
import { clue_safe } from './clue-safe.js';
54
import { find_fix_clues } from './fix-clues.js';
65
import { evaluate_clue, get_result } from './determine-clue.js';
7-
import { determine_focus, stall_severity, valuable_tempo_clue } from '../hanabi-logic.js';
6+
import { determine_focus, valuable_tempo_clue } from '../hanabi-logic.js';
87
import { cardValue, isTrash, visibleFind } from '../../../basics/hanabi-util.js';
98
import { find_clue_value } from '../action-helper.js';
109

@@ -160,9 +159,6 @@ export function find_clues(game, giver = game.state.ourPlayerIndex, early_exits
160159
};
161160
logger.info('result,', JSON.stringify(result_log), find_clue_value(Object.assign(result, { remainder })));
162161

163-
/** @type {typeof CLUE_INTERP[keyof typeof CLUE_INTERP]} */
164-
let clue_interp;
165-
166162
if ((/** @type {any} */ ([CLUE_INTERP.SAVE, CLUE_INTERP.CM_5, CLUE_INTERP.CM_TRASH]).includes(hypo_game.moveHistory.at(-1).move))) {
167163
if (chop && focused_card.rank === 2) {
168164
const copies = visibleFind(state, player, focused_card);
@@ -174,72 +170,55 @@ export function find_clues(game, giver = game.state.ourPlayerIndex, early_exits
174170
}
175171
}
176172

177-
if (game.level < LEVEL.CONTEXT || clue.result.avoidable_dupe == 0) {
173+
if (game.level < LEVEL.CONTEXT || clue.result.avoidable_dupe == 0)
178174
saves.push(Object.assign(clue, { game: hypo_game, playable: playables.length > 0, cm: chop_moved, safe }));
179-
clue_interp = CLUE_INTERP.SAVE;
180-
}
181-
else {
175+
else
182176
logger.highlight('yellow', `${logClue(clue)} save results in avoidable potential duplication`);
183-
}
184177
}
185178

186-
const focus_known_bluff = hypo_game.common.waiting_connections.some(c => {
187-
return c.connections[0].bluff && c.focused_card.order == focused_card.order;
188-
});
189-
// Clues where the focus isn't playable but may be assumed playable or that cause chop moves aren't plays/stalls
190-
if ((playables.length > 0 && !playables.some(({ card }) => card.order === focused_card.order) && !focus_known_bluff) ||
191-
(playables.length === 0 && chop_moved.length > 0) ||
192-
isTrash(state, player, focused_card, focused_card.order)) {
193-
logger.highlight('yellow', 'invalid play clue');
179+
if (!safe)
194180
continue;
195-
}
196181

197-
if (playables.length > 0) {
198-
if (safe) {
199-
const { tempo, valuable } = valuable_tempo_clue(game, clue, playables, focused_card);
182+
switch (hypo_game.moveHistory.at(-1).move) {
183+
case CLUE_INTERP.CM_TEMPO: {
184+
const { tempo, valuable } = valuable_tempo_clue(game, clue, clue.result.playables, focused_card);
185+
200186
if (tempo && !valuable) {
187+
logger.info('tempo clue chop move', logClue(clue));
201188
stall_clues[1].push(clue);
202-
clue_interp = CLUE_INTERP.CM_TEMPO;
203-
}
204-
else if (game.level < LEVEL.CONTEXT || clue.result.avoidable_dupe == 0) {
205-
play_clues[target].push(clue);
206-
clue_interp = CLUE_INTERP.PLAY;
207189
}
208190
else {
209-
logger.highlight('yellow', `${logClue(clue)} results in avoidable potential duplication`);
191+
logger.info('clue', logClue(clue), tempo, valuable);
210192
}
193+
break;
211194
}
212-
else {
213-
logger.highlight('yellow', `${logClue(clue)} is an unsafe play clue`);
214-
}
215-
}
216-
// Stall clues
217-
else if (stall_severity(state, common, giver) > 0 && safe) {
218-
if (clue.type === CLUE.RANK && clue.value === 5 && !focused_card.clued) {
195+
case CLUE_INTERP.PLAY:
196+
if (clue.result.playables.length === 0)
197+
continue;
198+
199+
if (game.level < LEVEL.CONTEXT || clue.result.avoidable_dupe == 0)
200+
play_clues[target].push(clue);
201+
else
202+
logger.highlight('yellow', `${logClue(clue)} results in avoidable potential duplication`);
203+
break;
204+
205+
case CLUE_INTERP.STALL_5:
219206
logger.info('5 stall', logClue(clue));
220207
stall_clues[0].push(clue);
221-
clue_interp = CLUE_INTERP.STALL_5;
222-
}
223-
else if (player.thinksLocked(state, giver) && chop) {
208+
break;
209+
210+
case CLUE_INTERP.STALL_LOCKED:
224211
logger.info('locked hand save', logClue(clue));
225212
stall_clues[3].push(clue);
226-
clue_interp = CLUE_INTERP.STALL_LOCKED;
227-
}
228-
else if (new_touched.length === 0) {
229-
if (elim > 0) {
230-
logger.info('fill in', logClue(clue));
231-
stall_clues[2].push(clue);
232-
clue_interp = CLUE_INTERP.STALL_FILLIN;
233-
}
234-
else {
235-
logger.info('hard burn', logClue(clue));
236-
stall_clues[5].push(clue);
237-
clue_interp = CLUE_INTERP.STALL_BURN;
238-
}
239-
}
213+
break;
214+
215+
case CLUE_INTERP.STALL_BURN:
216+
logger.info('hard burn', logClue(clue));
217+
stall_clues[5].push(clue);
218+
break;
240219
}
241220

242-
if (clue_interp !== undefined && early_exits(game, clue, clue_interp)) {
221+
if (early_exits(game, clue, /** @type {typeof CLUE_INTERP[keyof typeof CLUE_INTERP]} */ (hypo_game.moveHistory.at(-1).move))) {
243222
early_exit = true;
244223
break;
245224
}

src/conventions/h-group/clue-interpretation/connecting-cards.js

+20-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ActualCard } from '../../../basics/Card.js';
1414
* @typedef {import('../../../basics/Card.js').Card} Card
1515
* @typedef {import('../../../types.js').Connection} Connection
1616
* @typedef {import('../../../types.js').Identity} Identity
17+
* @typedef {import('../../../types.js').Link} Link
1718
*/
1819

1920
/**
@@ -65,6 +66,23 @@ export function find_known_connecting(game, giver, identity, ignoreOrders = [],
6566

6667
if (globally_known)
6768
return { type: 'known', reacting: playerIndex, card: globally_known, identities: [identity] };
69+
70+
/** @type {Link} */
71+
let known_link;
72+
73+
const known_linked = state.hands[playerIndex].find(({ order }) => {
74+
if (ignoreOrders.includes(order))
75+
return false;
76+
77+
known_link = common.links.find(link =>
78+
link.promised &&
79+
link.identities.some(i => i.suitIndex === identity.suitIndex && i.rank === identity.rank) &&
80+
link.cards.some(c => c.order === order));
81+
return known_link !== undefined;
82+
});
83+
84+
if (known_linked)
85+
return { type: 'playable', reacting: playerIndex, card: known_linked, linked: known_link.cards, identities: [identity] };
6886
}
6987

7088
// Visible and already going to be played (excluding giver)
@@ -151,7 +169,8 @@ function find_unknown_connecting(game, giver, target, reacting, identity, looksD
151169
const card = common.thoughts[order];
152170

153171
return card.touched && !card.newly_clued &&
154-
common.dependentConnections(order).every(wc => !wc.symmetric && wc.focused_card.matches(wc.inference, { assume: true }));
172+
(state.deck[order].identity() !== undefined || common.dependentConnections(order).every(wc =>
173+
!wc.symmetric && wc.focused_card.matches(wc.inference, { assume: true })));
155174
};
156175

157176
if (state.hands.some((hand, index) => index !== giver && hand.some(c => order_touched(c.order) && c.matches(finesse)))) {

src/conventions/h-group/clue-interpretation/connection-helper.js

+12-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CLUE } from '../../../constants.js';
22
import { IdentitySet } from '../../../basics/IdentitySet.js';
33
import { determine_focus } from '../hanabi-logic.js';
4-
import { IllegalInterpretation, find_own_finesses } from './own-finesses.js';
4+
import { IllegalInterpretation, RewindEscape, find_own_finesses } from './own-finesses.js';
55

66
import logger from '../../../tools/logger.js';
77
import { logCard, logConnection, logConnections } from '../../../tools/log.js';
@@ -145,11 +145,17 @@ export function find_symmetric_connections(game, action, inf_possibilities, self
145145
non_self_connections.push({ id, connections, fake });
146146
}
147147
catch (error) {
148-
if (error instanceof IllegalInterpretation)
148+
if (error instanceof IllegalInterpretation) {
149149
// Will probably never be seen
150150
logger.warn(error.message);
151-
else
151+
}
152+
else if (error instanceof RewindEscape) {
153+
logger.flush(false);
154+
return [];
155+
}
156+
else {
152157
throw error;
158+
}
153159
}
154160
logger.flush(false);
155161
}
@@ -217,6 +223,9 @@ export function assign_connections(game, connections, giver) {
217223
card.certain_finessed = true;
218224
}
219225

226+
if (connections.some(conn => conn.type === 'finesse'))
227+
card.finesse_index = card.finesse_index ?? state.actionList.length;
228+
220229
if (bluff || hidden) {
221230
const playable_identities = hypo_stacks.map((stack_rank, index) => ({ suitIndex: index, rank: stack_rank + 1 })).filter(id => id.rank <= state.max_ranks[id.suitIndex]);
222231
card.inferred = card.inferred.intersect(playable_identities);

0 commit comments

Comments
 (0)