Fix repayments that overcharged, and act on the first Frontier playtest
A debt repayment took its whole instalment even when less was owed: four dollars owed at the store cost twenty. Content could not express "pay what is owed", so an add effect's amount may now be read from state, with a cap. Five repayments across both packs use it, and a conformance test holds every pack to it. The turn record now says which options were hidden, so the analyser stops counting doors nobody saw, and it reads only feedback logs. From the same session: wages are labelled as wages, a clamped change says where it stopped, two unreadable option labels are rewritten, the sale barn no longer offers what the player already owns, Ward hints at what the promotion waits for, and the office has five more events. DECISIONS #38-40. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
This commit is contained in:
co-authored by
Claude Opus 5
parent
d84ae495f4
commit
112e558dbb
@@ -132,6 +132,35 @@ for (const pack of packs) {
|
||||
assert.deepEqual(trapped, [], 'events whose every option is gated');
|
||||
});
|
||||
|
||||
test(`${where} no repayment can take more than is owed`, () => {
|
||||
// A fixed instalment against a smaller balance takes money for a debt that
|
||||
// does not exist; the Frontier's store did it to a real player. A balance
|
||||
// is any money-formatted stat other than money itself. An option that pays
|
||||
// one down with money must read the amount from the balance, or be gated
|
||||
// on the balance covering the whole instalment. See DECISIONS #38.
|
||||
const balances = Object.entries(pack.display ?? {})
|
||||
.filter(([path, rule]) => rule.format === 'money' && path !== 'money')
|
||||
.map(([path]) => path);
|
||||
const overcharging = [];
|
||||
for (const event of pack.events) {
|
||||
for (const option of event.options ?? []) {
|
||||
const effects = option.effects ?? [];
|
||||
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;
|
||||
for (const e of effects) {
|
||||
if (!balances.includes(e.path) || e.op !== 'add') continue;
|
||||
if (typeof e.value !== 'number' || e.value >= 0) continue;
|
||||
const covered = (option.requires ?? [])
|
||||
.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');
|
||||
assert.deepEqual(overcharging, [], 'options that can charge for more than is owed');
|
||||
});
|
||||
|
||||
test(`${where} every turn always offers something to do, however badly it is going`, () => {
|
||||
for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,37 @@ test('an unknown operation is a loud failure', () => {
|
||||
assert.throws(() => applyEffect(state, { path: 'money', op: 'multiply', value: 2 }), /unknown effect operation/);
|
||||
});
|
||||
|
||||
test('an amount can be read from state, capped and signed', () => {
|
||||
// The Frontier's store, turn 27 of a real session: four owed, twenty taken.
|
||||
const ledger = { owed: { initial: 0, min: 0, max: 1000 } };
|
||||
const pay = [
|
||||
{ path: 'money', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
{ path: 'owed', op: 'add', value: { from: 'owed', times: -1, cap: 20 } },
|
||||
];
|
||||
|
||||
const small = applyEffects({ money: 110, owed: 4 }, pay, ledger);
|
||||
assert.equal(small.state.money, 106, 'pays what is owed, not the instalment');
|
||||
assert.equal(small.state.owed, 0);
|
||||
assert.equal(small.changes[1].clamped, false, 'nothing is lost to a clamp');
|
||||
assert.equal(small.changes[0].value, -4, 'the change records the amount applied');
|
||||
assert.deepEqual(small.changes[0].source, { from: 'owed', times: -1, cap: 20 });
|
||||
|
||||
const large = applyEffects({ money: 110, owed: 50 }, pay, ledger);
|
||||
assert.equal(large.state.money, 90, 'the cap holds when more is owed');
|
||||
assert.equal(large.state.owed, 30);
|
||||
});
|
||||
|
||||
test('an amount read from a path that is not a number is zero', () => {
|
||||
const result = applyEffect({ money: 10 }, { path: 'money', op: 'add', value: { from: 'owed', times: -1 } }, {});
|
||||
assert.equal(result.state.money, 10);
|
||||
assert.equal(result.change.value === 0 || Object.is(result.change.value, -0), true);
|
||||
});
|
||||
|
||||
test('a literal amount records no source', () => {
|
||||
const result = applyEffect(state, { path: 'money', op: 'add', value: 5 }, schema);
|
||||
assert.equal('source' in result.change, false);
|
||||
});
|
||||
|
||||
test('an empty effect list is a no-op', () => {
|
||||
const result = applyEffects(state, [], schema);
|
||||
assert.deepEqual(result.changes, []);
|
||||
|
||||
Vendored
+13
-2
@@ -13,13 +13,24 @@
|
||||
import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js';
|
||||
import { createRng } from '../../src/engine/rng.js';
|
||||
|
||||
/**
|
||||
* What an effect is worth to a bot. An amount read from state (DECISIONS #38)
|
||||
* is scored at its cap: the most it could be, which is what a player weighing
|
||||
* the option sees on the label.
|
||||
*/
|
||||
const amountOf = (effect) => {
|
||||
if (typeof effect.value === 'number') return effect.value;
|
||||
if (typeof effect.value?.cap === 'number') return effect.value.cap * (effect.value.times ?? 1);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const moneyGain = (resolved) => (resolved.option.effects ?? [])
|
||||
.filter((e) => e.path === 'money' && e.op === 'add')
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
.reduce((sum, e) => sum + amountOf(e), 0);
|
||||
|
||||
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
|
||||
.filter((e) => e.path.startsWith('relationships.'))
|
||||
.reduce((sum, e) => sum + e.value, 0);
|
||||
.reduce((sum, e) => sum + amountOf(e), 0);
|
||||
|
||||
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
|
||||
|
||||
|
||||
+31
-1
@@ -11,6 +11,8 @@ import assert from 'node:assert/strict';
|
||||
import { frontier as pack } from '../content/frontier/index.js';
|
||||
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
|
||||
import { check } from '../src/engine/conditions.js';
|
||||
import { applyEffects } from '../src/engine/effects.js';
|
||||
import { resolveSchema } from '../src/engine/state.js';
|
||||
import { play, sweep, median } from './fixtures/strategies.js';
|
||||
|
||||
/**
|
||||
@@ -58,7 +60,7 @@ test('the settlement and the room land on the days the pack says', () => {
|
||||
const turn = currentTurn(pack, state);
|
||||
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
|
||||
for (const change of result.record.upkeep) {
|
||||
if (change.label === 'Settlement') settlement.push(state.turn);
|
||||
if (change.label === 'Wages from Vane') settlement.push(state.turn);
|
||||
if (change.label === 'Room at the Cordwood') room.push(state.turn);
|
||||
}
|
||||
state = result.state;
|
||||
@@ -250,6 +252,34 @@ test('a player who wants a skill does not wait long for the first door', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the horse and the room are each offered only while they can still be bought', () => {
|
||||
// A real session was shown "Buy the bay" locked after buying it, and offered
|
||||
// the room under a description about the horse. See DECISIONS #40.
|
||||
const offered = (lifestyle) => pack.events
|
||||
.filter((e) => ['the_sale_barn', 'the_ellis_room'].includes(e.id))
|
||||
.filter((e) => check({ stage: 'deputy', lifestyle }, e.requires).passed)
|
||||
.map((e) => e.id);
|
||||
assert.deepEqual(offered(0), ['the_sale_barn']);
|
||||
assert.deepEqual(offered(1), ['the_ellis_room']);
|
||||
assert.deepEqual(offered(2), []);
|
||||
});
|
||||
|
||||
test('paying the book down never takes more than is owed', () => {
|
||||
// Turn 27 of that session: four dollars owed, twenty taken. See DECISIONS #38.
|
||||
const schema = resolveSchema(pack);
|
||||
const payDown = pack.events.find((e) => e.id === 'the_store')
|
||||
.options.find((o) => o.id === 'pay_down');
|
||||
const base = startGame(pack, { seed: 'the-book' });
|
||||
|
||||
const small = applyEffects({ ...base, money: 110, owed: 4 }, payDown.effects, schema).state;
|
||||
assert.equal(small.money, 106, 'four owed costs four');
|
||||
assert.equal(small.owed, 0);
|
||||
|
||||
const large = applyEffects({ ...base, money: 110, owed: 50 }, payDown.effects, schema).state;
|
||||
assert.equal(large.money, 90, 'more than twenty owed costs twenty');
|
||||
assert.equal(large.owed, 30);
|
||||
});
|
||||
|
||||
test('a deliberate player can put Dutch in their debt', () => {
|
||||
// The deepest relationship gate in the pack: `send_for_dutch` in the
|
||||
// marshal's office needs a favour banked two rungs earlier.
|
||||
|
||||
@@ -141,6 +141,7 @@ test('the turn record carries the diff and the options the player could not take
|
||||
const locked = record.offered.find((o) => o.id === 'charm');
|
||||
assert.equal(locked.available, false);
|
||||
assert.equal(locked.failed[0].path, 'skills.talking');
|
||||
assert.equal(locked.hidden, false, 'the record says whether a locked option was on screen');
|
||||
});
|
||||
|
||||
test('history accumulates every turn and is what the retrospective reads', () => {
|
||||
|
||||
@@ -25,6 +25,23 @@ test('an effect writing an undeclared path is caught', () => {
|
||||
assert.match(messages(result), /skills\.wleding.*not declared/s);
|
||||
});
|
||||
|
||||
test('an amount read from state must read a declared path, with numeric scale and cap', () => {
|
||||
const unknown = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'skills.wleding', times: -1 } });
|
||||
});
|
||||
assert.match(messages(unknown), /reads its amount from "skills\.wleding".*not declared/s);
|
||||
|
||||
const badCap = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', cap: 'lots' } });
|
||||
});
|
||||
assert.match(messages(badCap), /non-numeric cap/);
|
||||
|
||||
const fine = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', times: -1, cap: 5 } });
|
||||
});
|
||||
assert.deepEqual(fine.errors, []);
|
||||
});
|
||||
|
||||
test('a relationship effect naming a character who does not exist is caught', () => {
|
||||
const result = broken((pack) => {
|
||||
pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 });
|
||||
|
||||
Reference in New Issue
Block a user