feat(bench): fit difficulty from a played round, and report when it cannot
refit-weights regresses the weights select-50 scores with against what agents actually experienced, so a round of play is a flag on the next selection rather than an edit. Ridge, because seven collinear features on fifty levels otherwise hand back large coefficients that cancel. Most of the work here is in refusing to produce a number. The response averages the difficulty rating with two continuous measures of the same thing, and a component with no spread is dropped by name instead of being silently averaged in as zeros — which is what the first version did, reporting R² = 0.75 for a fit that never saw a rating. When the rating is flat the script now says so and says what the remaining fit is really measuring, which is board size. legibility() asks what it costs to know what you may tap: how far the eye must follow the cheapest free piece, and how many pieces are held by somebody their line never touches. It was built to explain why ratings came back flat. It does not — it correlates -0.02 with them — so it stays a measurement, printed on the review sheet beside the longest route on the board, and gates nothing. prototype-d5 takes --seedFrom so a wide sweep shards across cores. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -223,6 +223,40 @@ export function contrastStats(level) {
|
||||
};
|
||||
}
|
||||
|
||||
// What it costs to know what you may tap. A player verifies a piece by
|
||||
// following its line until it leaves the board or meets somebody; length and
|
||||
// bends are what that costs the eye. This is the difficulty the design brief
|
||||
// asks for — 无法一下子判断 — and it is not board size: a wide board whose
|
||||
// nearest free piece is three cells away reads instantly, while a small one
|
||||
// where every free piece hides behind a thirty-cell switchback does not.
|
||||
//
|
||||
// Grazes are counted apart because they are not difficulty at all, they are a
|
||||
// lie. A piece can have an empty line and still be held, by a neighbour the
|
||||
// route never touches; following that line tells the player it is free, and
|
||||
// the tap does nothing. That is the "many actions, no result" the brief rules
|
||||
// out, so a level wants none of these at its opening.
|
||||
export function legibility(level) {
|
||||
const occupant = new Map(level.tiles.map(t => [`${t.q},${t.r}`, t.id]));
|
||||
const costs = [];
|
||||
let grazeTraps = 0;
|
||||
for (const tile of level.tiles) {
|
||||
const geo = trace(level, tile, [], true); // the drawn line, ignoring who sits on it
|
||||
const real = trace(level, tile, level.tiles); // where the piece would actually stop
|
||||
const onRoute = geo.steps.some(s => { const who = occupant.get(`${s.q},${s.r}`); return who && who !== tile.id; });
|
||||
if (real.status !== 'exit' && !onRoute) grazeTraps++;
|
||||
// Verification stops where the eye stops: at the blocker, or at the edge.
|
||||
const seen = real.status === 'exit' ? geo : real;
|
||||
costs.push({ id: tile.id, free: real.status === 'exit', cost: seen.steps.length + 2 * countBends(seen) });
|
||||
}
|
||||
const free = costs.filter(c => c.free).map(c => c.cost);
|
||||
const all = costs.map(c => c.cost).sort((a, b) => a - b);
|
||||
return {
|
||||
glanceCost: free.length ? Math.min(...free) : null, // the cheapest correct move to find
|
||||
medianVerify: all.length ? all[Math.floor(all.length / 2)] : 0,
|
||||
grazeTraps,
|
||||
};
|
||||
}
|
||||
|
||||
// What the player actually watches. Rotation costs no steps precisely so that
|
||||
// people try things; the payout is the animation of a piece threading a winding
|
||||
// route and leaving. So the show is measurable: how long each departure plays,
|
||||
|
||||
Reference in New Issue
Block a user