Skip to content

Commit d101a55

Browse files
authored
Treat saves past finessed players as still urgent. (#303)
- Treat saves past finessed players as still urgent. This allows saving critical cards while allowing following players to play into their finesses. Fixes #273. - Improves symmetry in detecting urgent actions. - Use inspect in test-utils which can handle circular references.
1 parent f99b8e9 commit d101a55

File tree

8 files changed

+180
-72
lines changed

8 files changed

+180
-72
lines changed

.vscode/settings.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"editor.detectIndentation": false,
55
"eslint.format.enable": true,
66
"editor.codeActionsOnSave": {
7-
"source.fixAll.eslint": true
7+
"source.fixAll.eslint": "explicit"
88
},
99
// Never trim trailing whitespace in js files, because we allow trailing
1010
// whitespace in comments

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

+54-39
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export function evaluate_clue(game, action, clue, target, target_card, bad_touch
3333
logger.highlight('green', `------- ENTERING HYPO ${logClue(clue)} --------`);
3434

3535
const hypo_game = game.simulate_clue(action, { enableLogs: true });
36+
// This is emulating the needed side effects of handle_action for a clue action.
37+
// It might be simpler to call handle_action on the hypo_game.
38+
hypo_game.last_actions[action.giver] = action;
39+
hypo_game.handle_action({ type: 'turn', num: hypo_game.state.turn_count, currentPlayerIndex: hypo_game.state.nextPlayerIndex(hypo_game.state.ourPlayerIndex) }, true);
3640

3741
logger.highlight('green', '------- EXITING HYPO --------');
3842

@@ -44,55 +48,66 @@ export function evaluate_clue(game, action, clue, target, target_card, bad_touch
4448
/** @type {string} */
4549
let reason;
4650

47-
for (const { order, clued } of state.hands[target]) {
48-
const card = hypo_game.common.thoughts[order];
49-
const visible_card = state.deck[order];
51+
const get_finessed_cards = (game) => {
52+
return game.state.hands[action.giver].filter(c => !game.common.thoughts[c.order].clued && game.common.thoughts[c.order].finessed);
53+
};
54+
55+
const finessed_before_clue = get_finessed_cards(game);
56+
const finessed_after_clue = get_finessed_cards(hypo_game);
57+
const lost_finesse = finessed_before_clue.filter(c => finessed_after_clue.find(other => other.order == c.order) === undefined);
58+
if (lost_finesse.length > 0) {
59+
reason = `cards ${lost_finesse.map(c => logCard(state.deck[c.order])).join(', ')} lost finesse`;
60+
} else {
61+
for (const { order, clued } of state.hands[target]) {
62+
const card = hypo_game.common.thoughts[order];
63+
const visible_card = state.deck[order];
64+
65+
// The focused card must not have been reset and must match inferences
66+
if (order === target_card.order) {
67+
if (card.reset) {
68+
reason = `card ${logCard(state.deck[card.order])} ${card.order} lost all inferences and was reset`;
69+
break;
70+
}
71+
72+
if (!card.inferred.has(visible_card)) {
73+
reason = `card ${logCard(visible_card)} has inferences [${card.inferred.map(logCard).join(',')}]`;
74+
break;
75+
}
76+
continue;
77+
}
78+
79+
const old_card = game.common.thoughts[order];
80+
81+
const allowable_trash = card.chop_moved || // Chop moved (might have become trash)
82+
old_card.reset || !state.hasConsistentInferences(old_card) || old_card.inferred.length === 0 || // Didn't match inference even before clue
83+
(clued && isTrash(state, game.me, visible_card, order, { infer: true })) || // Previously-clued duplicate or recently became basic trash
84+
bad_touch_cards.some(b => b.order === order) || // Bad touched
85+
card.possible.every(id => isTrash(hypo_game.state, hypo_game.common, id, order, { infer: true })); // Known trash
86+
87+
if (allowable_trash || card.possible.length === 1)
88+
continue;
5089

51-
// The focused card must not have been reset and must match inferences
52-
if (order === target_card.order) {
90+
const id = card.identity({ infer: true });
91+
92+
// For non-focused cards:
5393
if (card.reset) {
5494
reason = `card ${logCard(state.deck[card.order])} ${card.order} lost all inferences and was reset`;
5595
break;
5696
}
5797

58-
if (!card.inferred.has(visible_card)) {
59-
reason = `card ${logCard(visible_card)} has inferences [${card.inferred.map(logCard).join(',')}]`;
98+
if (id !== undefined && !visible_card.matches(id)) {
99+
reason = `card ${logCard(visible_card)} incorrectly inferred to be ${logCard(id)}`;
60100
break;
61101
}
62-
continue;
63-
}
64-
65-
const old_card = game.common.thoughts[order];
66-
67-
const allowable_trash = card.chop_moved || // Chop moved (might have become trash)
68-
old_card.reset || !state.hasConsistentInferences(old_card) || old_card.inferred.length === 0 || // Didn't match inference even before clue
69-
(clued && isTrash(state, game.me, visible_card, order, { infer: true })) || // Previously-clued duplicate or recently became basic trash
70-
bad_touch_cards.some(b => b.order === order) || // Bad touched
71-
card.possible.every(id => isTrash(hypo_game.state, hypo_game.common, id, order, { infer: true })); // Known trash
72102

73-
if (allowable_trash || card.possible.length === 1)
74-
continue;
103+
const looks_playable = hypo_game.common.unknown_plays.has(order) ||
104+
hypo_game.common.hypo_stacks[visible_card.suitIndex] >= visible_card.rank ||
105+
card.inferred.every(i => i.rank <= hypo_game.common.hypo_stacks[i.suitIndex] + 1);
75106

76-
const id = card.identity({ infer: true });
77-
78-
// For non-focused cards:
79-
if (card.reset) {
80-
reason = `card ${logCard(state.deck[card.order])} ${card.order} lost all inferences and was reset`;
81-
break;
82-
}
83-
84-
if (id !== undefined && !visible_card.matches(id)) {
85-
reason = `card ${logCard(visible_card)} incorrectly inferred to be ${logCard(id)}`;
86-
break;
87-
}
88-
89-
const looks_playable = hypo_game.common.unknown_plays.has(order) ||
90-
hypo_game.common.hypo_stacks[visible_card.suitIndex] >= visible_card.rank ||
91-
card.inferred.every(i => i.rank <= hypo_game.common.hypo_stacks[i.suitIndex] + 1);
92-
93-
if (looks_playable && !card.inferred.has(visible_card)) {
94-
reason = `card ${logCard(visible_card)} looks incorrectly playable with inferences [${card.inferred.map(logCard).join(',')}]`;
95-
break;
107+
if (looks_playable && !card.inferred.has(visible_card)) {
108+
reason = `card ${logCard(visible_card)} looks incorrectly playable with inferences [${card.inferred.map(logCard).join(',')}]`;
109+
break;
110+
}
96111
}
97112
}
98113

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

+47-3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as Utils from '../../../tools/util.js';
1414

1515
import logger from '../../../tools/logger.js';
1616
import { logCard, logConnection, logConnections, logHand } from '../../../tools/log.js';
17+
import { IdentitySet } from '../../../basics/IdentitySet.js';
1718

1819
/**
1920
* @typedef {import('../../h-group.js').default} Game
@@ -250,9 +251,52 @@ export function interpret_clue(game, action) {
250251
if (chop) {
251252
focus_thoughts.chop_when_first_clued = true;
252253

253-
// A save is important if no one else could have given the save.
254-
if (!old_focus_thoughts.saved && focus_thoughts.saved && (giver + 1) % state.numPlayers == target && !common.thinksLoaded(state, target, {assume: false}))
255-
action.important = true;
254+
// Check whether this is an urgent save.
255+
if (!old_focus_thoughts.saved && focus_thoughts.saved && !common.thinksLoaded(state, target, {assume: false})) {
256+
const hypo_game = game.minimalCopy();
257+
const hypo_state = hypo_game.state;
258+
let played = new IdentitySet(state.variant.suits.length);
259+
260+
const get_finessed_card = (index) => {
261+
// Find the finessed card with the lowest finesse_index.
262+
let result = undefined;
263+
for (const c of hypo_state.hands[index]) {
264+
const card = hypo_game.common.thoughts[c.order];
265+
if (!card.finessed || result !== undefined && card.finesse_index > result.finesse_index)
266+
continue;
267+
result = card;
268+
}
269+
// Only return the card if it is thought to currently be playable.
270+
if (!result || result.inferred.some(id => !played.has(id) && !hypo_state.isPlayable(id)))
271+
return undefined;
272+
return result;
273+
};
274+
275+
// If there is at least one player without a finessed play between the giver and target, the save was not urgent.
276+
let urgent = true;
277+
let playerIndex = giver;
278+
279+
while (playerIndex !== target) {
280+
const finessed_play = get_finessed_card(playerIndex);
281+
if (!finessed_play) {
282+
urgent = false;
283+
break;
284+
}
285+
286+
// If we know what the card is, update the play stacks. If we don't, then
287+
// we can't know if playing it would make someone else's cards playable.
288+
const card = hypo_game.common.thoughts[finessed_play.order].identity({infer: true});
289+
if (card !== undefined) {
290+
played = played.union(card);
291+
hypo_state.play_stacks[card.suitIndex]++;
292+
}
293+
294+
playerIndex = (playerIndex + 1) % state.numPlayers;
295+
}
296+
297+
if (urgent)
298+
action.important = true;
299+
}
256300
}
257301

258302
if (focus_thoughts.inferred.length === 0 && oldCommon.thoughts[focused_card.order].possible.length > 1) {

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

+11-18
Original file line numberDiff line numberDiff line change
@@ -178,32 +178,21 @@ export function take_action(game) {
178178
logger.info('discards', logHand(discards));
179179

180180
const playable_priorities = determine_playable_card(game, playable_cards);
181-
const urgent_actions = find_urgent_actions(game, play_clues, save_clues, fix_clues, stall_clues, playable_priorities);
182-
183-
if (urgent_actions.some(actions => actions.length > 0))
184-
logger.info('all urgent actions', urgent_actions.flatMap((actions, index) => actions.map(action => ({ [index]: logPerformAction(action) }))));
185181

186182
const actionPrioritySize = Object.keys(ACTION_PRIORITY).length;
187183
const { priority, best_playable_card } = playable_priorities.some(playables => playables.length > 0) ?
188184
find_best_playable(game, playable_cards, playable_priorities) :
189185
{ priority: -1, best_playable_card: undefined };
186+
const is_finessed = playable_cards.length > 0 && priority === 0;
190187

191-
// If we have a finesse
192-
if (playable_cards.length > 0 && priority === 0) {
193-
// Bluffs should never be deferred as they can lead to significant desync with human players
194-
if (playable_cards.some(c => common.thoughts[c.order].bluffed))
195-
return { tableID, type: ACTION.PLAY, target: best_playable_card.order };
188+
// Bluffs should never be deferred as they can lead to significant desync with human players
189+
if (is_finessed && playable_cards.some(c => common.thoughts[c.order].bluffed))
190+
return { tableID, type: ACTION.PLAY, target: best_playable_card.order };
196191

197-
// Before playing a finesse, look for any urgent saves.
198-
// TODO: We should be able to delay a finesse for any urgent action. This will require
199-
// the other players being able to recognize the urgent action in update-turn.js.
200-
const save_action = urgent_actions[ACTION_PRIORITY.ONLY_SAVE].find(action => (action.type === ACTION.RANK || action.type === ACTION.COLOUR) && action.target == nextPlayerIndex);
201-
if (save_action)
202-
return save_action;
192+
const urgent_actions = find_urgent_actions(game, play_clues, save_clues, fix_clues, stall_clues, playable_priorities, is_finessed ? best_playable_card : undefined);
203193

204-
// If no urgent saves, play into the finesse
205-
return { tableID, type: ACTION.PLAY, target: best_playable_card.order };
206-
}
194+
if (urgent_actions.some(actions => actions.length > 0))
195+
logger.info('all urgent actions', urgent_actions.flatMap((actions, index) => actions.map(action => ({ [index]: logPerformAction(action) }))));
207196

208197
// Unlock next player
209198
if (urgent_actions[ACTION_PRIORITY.UNLOCK].length > 0)
@@ -217,6 +206,10 @@ export function take_action(game) {
217206
return action;
218207
}
219208

209+
// If we have a finesse and there were no actions urgent enough to delay a finesse, play into the finesse.
210+
if (is_finessed)
211+
return { tableID, type: ACTION.PLAY, target: best_playable_card.order };
212+
220213
const discardable = trash_cards[0] ?? common.chop(state.hands[state.ourPlayerIndex]);
221214

222215
if (state.clue_tokens === 0 && state.numPlayers > 2 && discardable !== undefined) {

src/conventions/h-group/urgent-actions.js

+11-9
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as Utils from '../../tools/util.js';
1111

1212
import logger from '../../tools/logger.js';
1313
import { logClue } from '../../tools/log.js';
14+
import { ActualCard } from '../../basics/Card.js';
1415

1516
/**
1617
* @typedef {import('../h-group.js').default} Game
@@ -187,8 +188,9 @@ export function early_game_clue(game, playerIndex) {
187188
* @param {FixClue[][]} fix_clues
188189
* @param {Clue[][]} stall_clues
189190
* @param {Card[][]} playable_priorities
191+
* @param {ActualCard} finessed_card
190192
*/
191-
export function find_urgent_actions(game, play_clues, save_clues, fix_clues, stall_clues, playable_priorities) {
193+
export function find_urgent_actions(game, play_clues, save_clues, fix_clues, stall_clues, playable_priorities, finessed_card) {
192194
const { common, me, state, tableID } = game;
193195
const prioritySize = Object.keys(PRIORITY).length;
194196
const urgent_actions = /** @type {PerformAction[][]} */ (Array.from({ length: prioritySize * 2 + 1 }, _ => []));
@@ -216,19 +218,19 @@ export function find_urgent_actions(game, play_clues, save_clues, fix_clues, sta
216218
// They are locked, we should try to unlock
217219
if (common.thinksLocked(state, target)) {
218220
const unlock_order = find_unlock(game, target);
219-
if (unlock_order !== undefined) {
221+
if (unlock_order !== undefined && (!finessed_card || finessed_card.order == unlock_order)) {
220222
urgent_actions[PRIORITY.UNLOCK + nextPriority].push({ tableID, type: ACTION.PLAY, target: unlock_order });
221223
continue;
222224
}
223225

224226
const play_over_save = find_play_over_save(game, target, play_clues.flat());
225-
if (play_over_save !== undefined) {
227+
if (!finessed_card && play_over_save !== undefined) {
226228
urgent_actions[PRIORITY.PLAY_OVER_SAVE + nextPriority].push(play_over_save);
227229
continue;
228230
}
229231

230232
const trash_fixes = fix_clues[target].filter(clue => clue.trash);
231-
if (trash_fixes.length > 0) {
233+
if (!finessed_card && trash_fixes.length > 0) {
232234
const trash_fix = Utils.maxOn(trash_fixes, ({ result }) => find_clue_value(result));
233235
urgent_actions[PRIORITY.TRASH_FIX + nextPriority].push(Utils.clueToAction(trash_fix, tableID));
234236
continue;
@@ -254,7 +256,7 @@ export function find_urgent_actions(game, play_clues, save_clues, fix_clues, sta
254256
// Try to see if they have a playable card that connects directly through our hand
255257
// Although this is only optimal for the next player, it is often a "good enough" action for future players.
256258
const unlock_order = find_unlock(game, target);
257-
if (unlock_order !== undefined) {
259+
if (unlock_order !== undefined && (!finessed_card || finessed_card.order == unlock_order)) {
258260
urgent_actions[PRIORITY.UNLOCK + nextPriority].push({ tableID, type: ACTION.PLAY, target: unlock_order });
259261
continue;
260262
}
@@ -263,14 +265,14 @@ export function find_urgent_actions(game, play_clues, save_clues, fix_clues, sta
263265

264266
// Give them a fix clue with known trash if possible (TODO: Re-examine if this should only be urgent fixes)
265267
const trash_fixes = fix_clues[target].filter(clue => clue.trash);
266-
if (trash_fixes.length > 0) {
268+
if (!finessed_card && trash_fixes.length > 0) {
267269
const trash_fix = Utils.maxOn(trash_fixes, ({ result }) => find_clue_value(result));
268270
urgent_actions[PRIORITY.TRASH_FIX + nextPriority].push(Utils.clueToAction(trash_fix, tableID));
269271
continue;
270272
}
271273

272274
// Check if Order Chop Move is available - 4 (unknown card) must be highest priority, they must be 1s, and this cannot be a playable save
273-
if (game.level >= LEVEL.BASIC_CM &&
275+
if (!finessed_card && game.level >= LEVEL.BASIC_CM &&
274276
playable_priorities.every((cards, priority) => priority >= 4 || cards.length === 0) &&
275277
!save.playable
276278
) {
@@ -301,7 +303,7 @@ export function find_urgent_actions(game, play_clues, save_clues, fix_clues, sta
301303
}
302304

303305
// Check if Scream/Shout Discard is available (only to next player)
304-
if (game.level >= LEVEL.LAST_RESORTS && playable_priorities.some(p => p.length > 0) && target === state.nextPlayerIndex(state.ourPlayerIndex)) {
306+
if (!finessed_card && game.level >= LEVEL.LAST_RESORTS && playable_priorities.some(p => p.length > 0) && target === state.nextPlayerIndex(state.ourPlayerIndex)) {
305307
const trash = me.thinksTrash(state, state.ourPlayerIndex).filter(c => c.clued && me.thoughts[c.order].inferred.every(i => state.isBasicTrash(i)));
306308

307309
if (trash.length > 0) {
@@ -371,7 +373,7 @@ export function find_urgent_actions(game, play_clues, save_clues, fix_clues, sta
371373
}
372374

373375
// They require a fix clue
374-
if (fix_clues[target].length > 0) {
376+
if (!finessed_card && fix_clues[target].length > 0) {
375377
const urgent_fixes = fix_clues[target].filter(clue => clue.urgent);
376378

377379
// Urgent fix on the next player is particularly urgent, but we should prioritize urgent fixes for others too

test/extra-asserts.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { strict as assert } from 'node:assert';
2+
import { inspect } from 'node:util';
23
import { expandShortCard } from './test-utils.js';
34
import * as Utils from '../src/tools/util.js';
45
import { logCard } from '../src/tools/log.js';
@@ -38,7 +39,7 @@ export function cardHasPossibilities(card, possibilities, message) {
3839
* @param {string | Error} [message] The error message if the assertion fails.
3940
*/
4041
export function objHasProperties(obj, properties, message) {
41-
assert.ok(typeof obj === 'object', `Object (${JSON.stringify(obj)}) is not of type 'object'.`);
42+
assert.ok(typeof obj === 'object', `Object (${inspect(obj)}) is not of type 'object'.`);
4243
assert.ok(typeof properties === 'object', `Properties (${JSON.stringify(properties)} is not of type 'object'.`);
4344

4445
assert.deepEqual(Utils.objPick(obj, Object.keys(properties)), properties, message);

test/h-group/level-4.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ describe('trash chop move', () => {
107107

108108
const { play_clues, save_clues, fix_clues, stall_clues } = find_clues(game);
109109
const playable_priorities = determine_playable_card(game, game.me.thinksPlayables(game.state, PLAYER.ALICE));
110-
const urgent_actions = find_urgent_actions(game, play_clues, save_clues, fix_clues, stall_clues, playable_priorities);
110+
const urgent_actions = find_urgent_actions(game, play_clues, save_clues, fix_clues, stall_clues, playable_priorities, undefined);
111111

112112
assert.deepEqual(urgent_actions[PRIORITY.ONLY_SAVE], []);
113113
ExAsserts.objHasProperties(urgent_actions[PRIORITY.PLAY_OVER_SAVE][0], { type: ACTION.COLOUR, value: 1 });

0 commit comments

Comments
 (0)