Add chance and a cast that changes between runs, and a pack to try them
An option used to do exactly what it said. Now it may carry a check: one of
four attributes (Nerve, Wits, Charm, Resolve) against a set difficulty or
against a named person's own attribute, leaning on how they regard you, with a
success and a failure. The player sees only the odds in words ("a long shot",
"likely"), never a number. The roll is one draw from the run's generator, so a
run still replays exactly from its seed and choices.
A character may be a role with several authored candidates, one drawn per run,
or a group of two to four drawn into numbered slots. Everyone carries a name, a
description and the same four attributes as the player, so a replayed pack
meets a different office. Text names people as {partner} or {clerk}, and an
event about "a clerk" binds one present clerk for the turn. The draw and the
binding are saved with the run, and a save from before a pack drew a cast gets
the same one every time it is loaded.
All of it is generic and optional: Corporate Ladder and the Frontier use none
of it and play as before. The Counting House, a one-stage office about 1910
built from the story-to-pack catalogue, exercises it, and was tuned against the
conformance suite by measurement. DECISIONS #41. Version 0.4.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MhABSrqkwTdcybXmheDPr
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
112e558dbb
commit
3108319a2b
@@ -0,0 +1,185 @@
|
||||
// The cast drawn per run, names in text, and options whose outcome is a check.
|
||||
// See DECISIONS #41. Runs against test/fixtures/cast-pack.js, never real content.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { validatePack } from '../src/engine/validate.js';
|
||||
import { hydrate, createState, resolveSchema } from '../src/engine/state.js';
|
||||
import { presentPeople, presentSlots, personIn } from '../src/engine/cast.js';
|
||||
import { fillText } from '../src/engine/text.js';
|
||||
import { chanceOf, oddsWords } from '../src/engine/checks.js';
|
||||
import { getPath, setPath } from '../src/engine/paths.js';
|
||||
import { castPack } from './fixtures/cast-pack.js';
|
||||
import { testPack } from './fixtures/test-pack.js';
|
||||
|
||||
const clone = (value) => structuredClone(value);
|
||||
|
||||
/** Advance until the current event is `eventId`, taking `work` otherwise. */
|
||||
function until(state, eventId, pack = castPack) {
|
||||
let next = state;
|
||||
for (let i = 0; i < 200 && currentTurn(pack, next).event.id !== eventId; i++) {
|
||||
next = takeTurn(pack, next, currentTurn(pack, next).options[0].option.id).state;
|
||||
}
|
||||
assert.equal(currentTurn(pack, next).event.id, eventId);
|
||||
return next;
|
||||
}
|
||||
|
||||
test('the cast pack is valid, with no warnings', () => {
|
||||
const { errors, warnings } = validatePack(castPack);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('a run draws one boss and two to four different co-workers', () => {
|
||||
const counts = new Set();
|
||||
for (let seed = 0; seed < 40; seed++) {
|
||||
const state = startGame(castPack, { seed });
|
||||
const people = presentPeople(castPack, state);
|
||||
assert.equal(people.filter((p) => p.role === 'boss').length, 1);
|
||||
const coworkers = presentSlots(castPack, state, 'coworker');
|
||||
assert.ok(coworkers.length >= 2 && coworkers.length <= 4, `drew ${coworkers.length}`);
|
||||
counts.add(coworkers.length);
|
||||
const names = people.map((p) => p.name);
|
||||
assert.equal(new Set(names).size, names.length, 'nobody is drawn twice');
|
||||
assert.ok(names.includes('Old Sam'), 'a fixed character is always present');
|
||||
}
|
||||
assert.deepEqual([...counts].sort(), [2, 3, 4], 'every head count turns up across seeds');
|
||||
});
|
||||
|
||||
test('the cast is the seed\'s: the same seed draws the same people, others differ', () => {
|
||||
const names = (seed) => presentPeople(castPack, startGame(castPack, { seed })).map((p) => p.name).join('|');
|
||||
assert.equal(names('a'), names('a'));
|
||||
const distinct = new Set(Array.from({ length: 20 }, (_, i) => names(`s${i}`)));
|
||||
assert.ok(distinct.size > 10, `only ${distinct.size} different casts in 20 runs`);
|
||||
});
|
||||
|
||||
test('an empty group slot is not in the run, and a drawn person brings their own starting regard', () => {
|
||||
let seed = 0;
|
||||
let state;
|
||||
do state = startGame(castPack, { seed: seed++ });
|
||||
while (presentSlots(castPack, state, 'coworker').length !== 2 || personIn(castPack, state, 'boss').name !== 'Walter Grose');
|
||||
assert.equal(getPath(state, 'cast.coworker_3'), null);
|
||||
assert.equal(personIn(castPack, state, 'coworker_3'), undefined);
|
||||
assert.equal(getPath(state, 'relationships.boss.trust'), 30);
|
||||
});
|
||||
|
||||
test('names are filled into text from whoever was drawn', () => {
|
||||
const state = startGame(castPack, { seed: 'names' });
|
||||
const boss = personIn(castPack, state, 'boss');
|
||||
assert.equal(fillText(castPack, state, 'Ask {boss.first}, the {boss.title}.'),
|
||||
`Ask ${boss.name.split(' ')[0]}, the Manager.`);
|
||||
assert.equal(fillText(castPack, state, '{porter} nods.'), 'Old Sam nods.');
|
||||
assert.equal(fillText(castPack, state, '{nobody} stays as written.'), '{nobody} stays as written.');
|
||||
});
|
||||
|
||||
test('an event about a co-worker binds one who is present, and its effects land on them', () => {
|
||||
const state = until(startGame(castPack, { seed: 'bind' }), 'needled');
|
||||
const turn = currentTurn(castPack, state);
|
||||
const slot = turn.bindings.coworker;
|
||||
assert.ok(presentSlots(castPack, state, 'coworker').includes(slot));
|
||||
assert.match(fillText(castPack, state, turn.event.description), new RegExp(personIn(castPack, state, slot).name));
|
||||
|
||||
const before = getPath(state, `relationships.${slot}.regard`);
|
||||
const { state: after, record } = takeTurn(castPack, state, 'ignore');
|
||||
assert.equal(getPath(after, `relationships.${slot}.regard`), before - 2);
|
||||
assert.deepEqual(record.bindings, { coworker: slot });
|
||||
});
|
||||
|
||||
test('the chance follows the formula, and the odds are words', () => {
|
||||
let state = startGame(castPack, { seed: 'odds' });
|
||||
const option = castPack.events[0].options[2]; // wits against difficulty 5
|
||||
state = setPath(state, 'attributes.wits', 5);
|
||||
assert.equal(chanceOf(castPack, state, option).chance, 50);
|
||||
state = setPath(state, 'attributes.wits', 7);
|
||||
assert.equal(chanceOf(castPack, state, option).chance, 70);
|
||||
state = setPath(state, 'attributes.wits', 10);
|
||||
assert.equal(chanceOf(castPack, state, option).chance, 100, 'clamped at the ceiling');
|
||||
assert.equal(oddsWords(castPack, 15), 'a long shot');
|
||||
assert.equal(oddsWords(castPack, 50), 'an even chance');
|
||||
assert.equal(oddsWords(castPack, 90), 'a safe bet');
|
||||
});
|
||||
|
||||
test('an opposed check reads the other person\'s attribute, and their regard moves it', () => {
|
||||
let state = startGame(castPack, { seed: 'opposed' });
|
||||
const option = castPack.events[0].options[1]; // charm against the boss's wits, leaning on trust
|
||||
const boss = personIn(castPack, state, 'boss');
|
||||
state = setPath(state, 'attributes.charm', 6);
|
||||
state = setPath(state, 'relationships.boss.trust', 50);
|
||||
const even = chanceOf(castPack, state, option);
|
||||
assert.equal(even.theirs, boss.attributes.wits);
|
||||
assert.equal(even.chance, Math.min(100, Math.max(0, 50 + 10 * (6 - boss.attributes.wits))));
|
||||
state = setPath(state, 'relationships.boss.trust', 70);
|
||||
assert.equal(chanceOf(castPack, state, option).chance, Math.min(100, even.chance + 10));
|
||||
});
|
||||
|
||||
test('a check applies the branch it rolled, plus the option\'s own effects', () => {
|
||||
let state = until(startGame(castPack, { seed: 'roll' }), 'routine');
|
||||
const sure = setPath(setPath(state, 'attributes.charm', 10), 'relationships.boss.trust', 100);
|
||||
const won = takeTurn(castPack, sure, 'ask_for_more');
|
||||
assert.equal(won.record.check.outcome, 'success');
|
||||
assert.equal(getPath(won.state, 'money'), getPath(sure, 'money') + 50);
|
||||
assert.equal(getPath(won.state, 'relationships.boss.regard'), getPath(sure, 'relationships.boss.regard') - 1);
|
||||
|
||||
const hopeless = setPath(setPath(state, 'attributes.charm', 0), 'relationships.boss.trust', 0);
|
||||
const lost = takeTurn(castPack, hopeless, 'ask_for_more');
|
||||
assert.equal(lost.record.check.outcome, 'failure');
|
||||
assert.equal(getPath(lost.state, 'money'), getPath(hopeless, 'money'));
|
||||
assert.equal(getPath(lost.state, 'relationships.boss.trust'), 0, 'clamped');
|
||||
assert.match(lost.record.check.odds, /long shot/);
|
||||
});
|
||||
|
||||
test('a run with checks and bindings replays identically from its seed and choices', () => {
|
||||
const play = () => {
|
||||
let state = startGame(castPack, { seed: 'replay' });
|
||||
const records = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const turn = currentTurn(castPack, state);
|
||||
const pick = turn.options[i % turn.options.length].option.id;
|
||||
const result = takeTurn(castPack, state, pick);
|
||||
records.push(result.record);
|
||||
state = i === 30 ? JSON.parse(JSON.stringify(result.state)) : result.state;
|
||||
}
|
||||
return { state, records };
|
||||
};
|
||||
const a = play();
|
||||
const b = play();
|
||||
assert.deepEqual(a.records.map((r) => r.check?.outcome ?? '-'), b.records.map((r) => r.check?.outcome ?? '-'));
|
||||
assert.deepEqual(a.state, b.state);
|
||||
assert.ok(a.records.some((r) => r.check?.outcome === 'success') && a.records.some((r) => r.check?.outcome === 'failure'));
|
||||
});
|
||||
|
||||
test('a save from before the pack drew a cast gets one on load, the same one every time', () => {
|
||||
const schema = resolveSchema(castPack);
|
||||
const fresh = createState(castPack, { seed: 'old', schema });
|
||||
const old = clone(fresh);
|
||||
delete old.cast;
|
||||
const once = hydrate(castPack, clone(old), schema);
|
||||
const twice = hydrate(castPack, clone(old), schema);
|
||||
assert.ok(once.added.includes('cast'));
|
||||
assert.deepEqual(once.state.cast, twice.state.cast);
|
||||
assert.deepEqual(once.state.cast, fresh.cast);
|
||||
});
|
||||
|
||||
test('a pack with no drawn cast gets no cast and no bindings: its runs are unchanged', () => {
|
||||
const state = startGame(testPack, { seed: 'plain' });
|
||||
assert.equal(state.cast, undefined);
|
||||
assert.equal(state.meta.bindings, undefined);
|
||||
assert.equal(presentPeople(testPack, state).map((p) => p.name).join(), 'The Chief,Pat');
|
||||
});
|
||||
|
||||
test('the validator catches cast and check mistakes', () => {
|
||||
const broken = clone(castPack);
|
||||
broken.events[0].options[1].check.attribute = 'strength';
|
||||
broken.events[0].options[2].failure = undefined;
|
||||
broken.events[0].description = 'Blame {coworker}.';
|
||||
broken.events.push({ id: 'ghost', stage: 'desk', description: '{ghost} appears.', options: [{ id: 'x', label: 'x', effects: [] }] });
|
||||
broken.characters[1].count = [2, 6];
|
||||
const { errors } = validatePack(broken);
|
||||
const has = (fragment) => assert.ok(errors.some((e) => e.includes(fragment)), `expected an error mentioning "${fragment}":\n${errors.join('\n')}`);
|
||||
has('attribute "strength" is not in pack.attributes');
|
||||
has('has no failure');
|
||||
has('{coworker} is a group');
|
||||
has('{ghost} names no character');
|
||||
has('has only 5 candidates');
|
||||
});
|
||||
@@ -144,7 +144,12 @@ for (const pack of packs) {
|
||||
const overcharging = [];
|
||||
for (const event of pack.events) {
|
||||
for (const option of event.options ?? []) {
|
||||
const effects = option.effects ?? [];
|
||||
// A check's branches pay and spend too (DECISIONS #41): each is judged
|
||||
// together with the effects that apply either way.
|
||||
const branches = option.check
|
||||
? [option.success, option.failure].map((b) => [...(option.effects ?? []), ...(b?.effects ?? [])])
|
||||
: [option.effects ?? []];
|
||||
for (const effects of branches) {
|
||||
const spends = effects.some((e) => e.path === 'money' && e.op === 'add'
|
||||
&& (typeof e.value === 'number' ? e.value < 0 : (e.value?.times ?? 1) < 0));
|
||||
if (!spends) continue;
|
||||
@@ -155,6 +160,7 @@ for (const pack of packs) {
|
||||
.some((r) => r.path === e.path && r.op === '>=' && r.value >= -e.value);
|
||||
if (!covered) overcharging.push(`${event.id}.${option.id} (${e.path} ${e.value})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
|
||||
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
// A small pack for the cast and checks (DECISIONS #41): a drawn single role, a
|
||||
// group role of two to four, a fixed character with attributes, opposed and
|
||||
// unopposed checks, and an event about "one of the co-workers". Its checks run
|
||||
// from 0 to 100 so tests can force an outcome; a real pack keeps a floor and a
|
||||
// ceiling so nothing is ever certain.
|
||||
|
||||
export const castPack = {
|
||||
id: 'castpack',
|
||||
name: 'Cast Pack',
|
||||
version: '1.0.0',
|
||||
turnUnit: 'day',
|
||||
startingStage: 'desk',
|
||||
|
||||
attributes: {
|
||||
nerve: { label: 'Nerve' },
|
||||
wits: { label: 'Wits' },
|
||||
charm: { label: 'Charm' },
|
||||
resolve: { label: 'Resolve' },
|
||||
},
|
||||
|
||||
checks: {
|
||||
base: 50, perPoint: 10, unopposed: 5,
|
||||
relationshipPerPoint: 0.5, relationshipCentre: 50,
|
||||
floor: 0, ceiling: 100,
|
||||
},
|
||||
|
||||
state: {
|
||||
'money': { initial: 100, min: -500, max: 10000 },
|
||||
'attributes.nerve': { initial: 5, min: 0, max: 10 },
|
||||
'attributes.wits': { initial: 5, min: 0, max: 10 },
|
||||
'attributes.charm': { initial: 5, min: 0, max: 10 },
|
||||
'attributes.resolve': { initial: 5, min: 0, max: 10 },
|
||||
},
|
||||
|
||||
relationshipStats: {
|
||||
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
|
||||
regard: { label: 'Thinks of you', initial: 50, min: 0, max: 100 },
|
||||
},
|
||||
|
||||
characters: [
|
||||
{
|
||||
id: 'boss',
|
||||
role_type: 'boss',
|
||||
title: 'Manager',
|
||||
candidates: [
|
||||
{ name: 'Agnes Pell', description: 'Reads every memo twice.', attributes: { nerve: 6, wits: 8, charm: 3, resolve: 7 } },
|
||||
{ name: 'Walter Grose', description: 'Loud, and quicker than he looks.', attributes: { nerve: 7, wits: 6, charm: 6, resolve: 4 },
|
||||
startingRelationship: { trust: 30 } },
|
||||
{ name: 'Mina Hart', description: 'Has never raised her voice.', attributes: { nerve: 4, wits: 7, charm: 8, resolve: 6 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'coworker',
|
||||
role_type: 'peer',
|
||||
title: 'Clerk',
|
||||
count: [2, 4],
|
||||
candidates: [
|
||||
{ name: 'Ben Aske', description: 'Owes {boss} a favour.', attributes: { nerve: 3, wits: 4, charm: 6, resolve: 5 } },
|
||||
{ name: 'Cora Dunn', description: 'First in, last out.', attributes: { nerve: 5, wits: 5, charm: 4, resolve: 8 } },
|
||||
{ name: 'Del Fane', description: 'Knows everyone\'s business.', attributes: { nerve: 4, wits: 7, charm: 5, resolve: 3 } },
|
||||
{ name: 'Edie Gow', description: 'Quiet until she is not.', attributes: { nerve: 7, wits: 5, charm: 3, resolve: 6 } },
|
||||
{ name: 'Fitz Hale', description: 'Charming, unreliable.', attributes: { nerve: 5, wits: 3, charm: 8, resolve: 2 } },
|
||||
],
|
||||
},
|
||||
{ id: 'porter', name: 'Old Sam', role_type: 'staff', description: 'Has seen everyone come and go.',
|
||||
attributes: { nerve: 2, wits: 9, charm: 4, resolve: 9 } },
|
||||
],
|
||||
|
||||
events: [
|
||||
{
|
||||
id: 'routine',
|
||||
stage: 'desk',
|
||||
weight: 100,
|
||||
description: 'A day at the desk under {boss}.',
|
||||
options: [
|
||||
{ id: 'work', label: 'Work.', effects: [{ path: 'money', op: 'add', value: 10 }] },
|
||||
{
|
||||
id: 'ask_for_more',
|
||||
label: 'Ask {boss.first} for more money.',
|
||||
check: { attribute: 'charm', against: 'boss', opposedBy: 'wits', relationship: 'trust' },
|
||||
effects: [{ path: 'relationships.boss.regard', op: 'add', value: -1 }],
|
||||
success: { result: '{boss} agrees.', effects: [{ path: 'money', op: 'add', value: 50 }] },
|
||||
failure: { result: '{boss} laughs.', effects: [{ path: 'relationships.boss.trust', op: 'add', value: -5 }] },
|
||||
},
|
||||
{
|
||||
id: 'hard_sum',
|
||||
label: 'Try the hard sum.',
|
||||
check: { attribute: 'wits', difficulty: 5 },
|
||||
success: { result: 'It adds up.', effects: [{ path: 'attributes.wits', op: 'add', value: 1 }] },
|
||||
failure: { result: 'It does not.', effects: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'needled',
|
||||
stage: 'desk',
|
||||
weight: 100,
|
||||
involves: ['coworker'],
|
||||
description: '{coworker} makes a remark about your tie.',
|
||||
options: [
|
||||
{ id: 'ignore', label: 'Let it go.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: -2 }] },
|
||||
{
|
||||
id: 'retort',
|
||||
label: 'Answer {coworker.first} back.',
|
||||
check: { attribute: 'nerve', against: '{coworker}', relationship: 'regard' },
|
||||
success: { result: '{coworker} laughs, and means it.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: 5 }] },
|
||||
failure: { result: 'It lands badly.', effects: [{ path: 'relationships.{coworker}.trust', op: 'add', value: -5 }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default castPack;
|
||||
Vendored
+24
-7
@@ -24,13 +24,30 @@ const amountOf = (effect) => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const moneyGain = (resolved) => (resolved.option.effects ?? [])
|
||||
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||
.reduce((sum, e) => sum + amountOf(e), 0);
|
||||
/**
|
||||
* Every effect an option can have, with the weight a player reading the odds
|
||||
* would give it: plain effects count in full, a check's success and failure by
|
||||
* their chance (DECISIONS #41). An option with no check is just its effects.
|
||||
*/
|
||||
export const weightedEffects = (resolved) => {
|
||||
const { option, odds } = resolved;
|
||||
const plain = (option.effects ?? []).map((effect) => ({ effect, weight: 1 }));
|
||||
if (!option.check) return plain;
|
||||
const p = (odds?.chance ?? 50) / 100;
|
||||
return [
|
||||
...plain,
|
||||
...(option.success?.effects ?? []).map((effect) => ({ effect, weight: p })),
|
||||
...(option.failure?.effects ?? []).map((effect) => ({ effect, weight: 1 - p })),
|
||||
];
|
||||
};
|
||||
|
||||
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
|
||||
.filter((e) => e.path.startsWith('relationships.'))
|
||||
.reduce((sum, e) => sum + amountOf(e), 0);
|
||||
const moneyGain = (resolved) => weightedEffects(resolved)
|
||||
.filter(({ effect }) => effect.path === 'money' && effect.op === 'add')
|
||||
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
|
||||
|
||||
const relationshipGain = (resolved) => weightedEffects(resolved)
|
||||
.filter(({ effect }) => effect.path.startsWith('relationships.'))
|
||||
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
|
||||
|
||||
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
|
||||
|
||||
@@ -59,7 +76,7 @@ export const strategies = {
|
||||
*/
|
||||
lifer: (options) => {
|
||||
const stays = options.filter(
|
||||
(o) => !(o.option.effects ?? []).some((e) => e.path === 'stage'),
|
||||
(o) => !weightedEffects(o).some(({ effect }) => effect.path === 'stage'),
|
||||
);
|
||||
return best(stays.length ? stays : options, relationshipGain);
|
||||
},
|
||||
|
||||
@@ -527,3 +527,52 @@ test('the header reads the second pack\'s display map, not the first\'s', async
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- a drawn cast, and checks (DECISIONS #41) ----------------------------------
|
||||
|
||||
test('a pack with a drawn cast shows whoever this run drew, and never a placeholder', async () => {
|
||||
const dom = await boot({ pack: 'countinghouse' });
|
||||
try {
|
||||
const people = byClass(dom.root, 'person');
|
||||
// One partner, one chief clerk, and two to four clerks.
|
||||
assert.ok(people.length >= 4 && people.length <= 6, `${people.length} people in the panel`);
|
||||
const titles = byClass(dom.root, 'person__title').map((t) => t.textContent);
|
||||
assert.equal(titles.filter((t) => t === 'Senior Partner').length, 1);
|
||||
assert.equal(titles.filter((t) => t === 'Chief Clerk').length, 1);
|
||||
const clerks = titles.filter((t) => t === 'Clerk').length;
|
||||
assert.ok(clerks >= 2 && clerks <= 4, `${clerks} clerks`);
|
||||
for (let turn = 0; turn < 25; turn++) {
|
||||
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the screen');
|
||||
enabledOptions(dom.root)[0].click();
|
||||
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the outcome');
|
||||
firstByClass(dom.root, 'button--next').click();
|
||||
}
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a checked option shows its odds in words, and the outcome says whether it came off', async () => {
|
||||
const dom = await boot({ pack: 'countinghouse' });
|
||||
try {
|
||||
let checked;
|
||||
for (let turn = 0; turn < 40 && !checked; turn++) {
|
||||
checked = optionButtons(dom.root).find((b) => !b.disabled && firstByClass(b, 'option__odds'));
|
||||
if (checked) break;
|
||||
enabledOptions(dom.root)[0].click();
|
||||
firstByClass(dom.root, 'button--next').click();
|
||||
}
|
||||
assert.ok(checked, 'a checked option turned up within forty days');
|
||||
const odds = firstByClass(checked, 'option__odds').textContent;
|
||||
assert.match(odds, /^(a long shot|unlikely|an even chance|likely|a safe bet)$/);
|
||||
assert.doesNotMatch(text(dom.root), /\d+%/, 'no percentage is ever shown');
|
||||
|
||||
checked.click();
|
||||
const said = text(firstByClass(dom.root, 'outcome__check'));
|
||||
assert.match(said, /and it (came|did not come) off\./);
|
||||
assert.ok(said.toLowerCase().startsWith(odds), `the outcome repeats the odds: "${said}"`);
|
||||
assert.ok(firstByClass(dom.root, 'outcome__text'), 'and says what happened');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user