Compare commits
19 Commits
4cdb2fead8
...
a012225259
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a012225259 | ||
|
|
9498dfc1b9 | ||
|
|
8edf461286 | ||
|
|
3717ef0d54 | ||
|
|
5ae696040d | ||
|
|
d8466355e8 | ||
|
|
64fb25529f | ||
|
|
39701884e3 | ||
|
|
bb8451d12e | ||
|
|
3548b6edc1 | ||
|
|
44f019f31d | ||
|
|
4cfb227935 | ||
|
|
895539da7c | ||
|
|
99345f40b1 | ||
|
|
f029274940 | ||
|
|
bfc43c6da3 | ||
|
|
dd69a2cf6c | ||
|
|
6e0608a6fb | ||
|
|
a180fce8c3 |
1050
THREADS.md
1050
THREADS.md
File diff suppressed because it is too large
Load Diff
@ -906,6 +906,53 @@
|
|||||||
],
|
],
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"problems": []
|
"problems": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sail_post_corroded",
|
||||||
|
"dims": [
|
||||||
|
0.507,
|
||||||
|
0.52,
|
||||||
|
4.0516
|
||||||
|
],
|
||||||
|
"tris": 816,
|
||||||
|
"nodes": [
|
||||||
|
"footing",
|
||||||
|
"pad_eye",
|
||||||
|
"post",
|
||||||
|
"rake_pivot",
|
||||||
|
"rust",
|
||||||
|
"sail_post_corroded",
|
||||||
|
"top_anchor"
|
||||||
|
],
|
||||||
|
"anchors": [
|
||||||
|
{
|
||||||
|
"node": "top_anchor",
|
||||||
|
"anchor_type": "corroded_post",
|
||||||
|
"rating_hint": 0.55,
|
||||||
|
"collateral": "corroded_post"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "PASS",
|
||||||
|
"problems": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sail_post_corroded_wrecked",
|
||||||
|
"dims": [
|
||||||
|
0.7786,
|
||||||
|
4.0784,
|
||||||
|
0.7015
|
||||||
|
],
|
||||||
|
"tris": 836,
|
||||||
|
"nodes": [
|
||||||
|
"footing",
|
||||||
|
"pad_eye_down",
|
||||||
|
"post_down",
|
||||||
|
"post_stub",
|
||||||
|
"sail_post_corroded_wrecked"
|
||||||
|
],
|
||||||
|
"anchors": [],
|
||||||
|
"status": "PASS",
|
||||||
|
"problems": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"debris": []
|
"debris": []
|
||||||
|
|||||||
@ -137,6 +137,14 @@ PAL = {
|
|||||||
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
|
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
|
||||||
"bike_grip": "#3B4048", # grips and saddle
|
"bike_grip": "#3B4048", # grips and saddle
|
||||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
||||||
|
"steel_weathered": "#8D8A7E", # gal gone matte: the corroded post's shaft.
|
||||||
|
# Next to steel_gal (#B6BCC2) it reads DIRTY at
|
||||||
|
# 20 m before you can see a single bloom — the
|
||||||
|
# colour is the long-range half of the tell.
|
||||||
|
"rust_bloom": "#9C5B33", # active surface rust — orange enough to read
|
||||||
|
# against every steel/timber in the palette
|
||||||
|
"rust_deep": "#67351F", # the worst of it: fold lines, weld collars,
|
||||||
|
# streak stains — near-brown, high saturation
|
||||||
"alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence
|
"alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence
|
||||||
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
|
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
|
||||||
# a pool is the one water in the game that is
|
# a pool is the one water in the game that is
|
||||||
@ -2256,6 +2264,284 @@ def build_sail_post(name):
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# THE CORRODED TIER (SPRINT17 gate 3.1 — the pool yard's other half)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The pool kit is fourteen tie-offs that DON'T exist (every fence post an
|
||||||
|
# honest no). This is the tie-off that DOES exist and shouldn't be trusted:
|
||||||
|
# a real sail post, real concrete footing, real pad eye — gone rusty.
|
||||||
|
#
|
||||||
|
# WHY 0.55, argued on the ladder it joins (0.22 carport < 0.30 carport_post <
|
||||||
|
# 0.35 fascia < 0.45 swing_frame < [0.55] < 0.65 pergola < 1.00 post):
|
||||||
|
# · ABOVE the swing frame (0.45): the footing is still 300 mm of concrete
|
||||||
|
# and the load path still reaches the ground. A sound welded frame
|
||||||
|
# standing loose on grass drags; a rusty post in a real footing holds
|
||||||
|
# until its steel gives — corroded anchored beats sound unanchored.
|
||||||
|
# · BELOW the pergola (0.65): the pergola's members are SOUND and its
|
||||||
|
# weakness (deck flex) is visible and bounded. Corrosion's weakness is
|
||||||
|
# section loss you cannot see the bottom of — the bloom on the outside is
|
||||||
|
# pitting on the inside, so the visible rust is a FLOOR on the damage,
|
||||||
|
# not a ceiling. You de-rate below sound timber because you're rating
|
||||||
|
# what you can't inspect.
|
||||||
|
# · And it FILLS the 0.45→0.65 gap: rungs create decisions (D's S15
|
||||||
|
# endorsement logic, the reason the pergola exists). With this rung the
|
||||||
|
# ladder steps 0.45 / 0.55 / 0.65 — three different kinds of "not quite":
|
||||||
|
# unanchored steel, eaten steel, flexing timber.
|
||||||
|
CORRODED_POST_RATING = 0.55
|
||||||
|
|
||||||
|
# The bill when it snaps. Proposal — Lane A confirms (my S17 prompt says rule
|
||||||
|
# it and let A confirm): a post IS the client's property, so a snapped one
|
||||||
|
# bills — "it was already scrap" is a warranty argument, not a free pass. But
|
||||||
|
# the steel was condemned before you touched it, so what you're billing is
|
||||||
|
# the MAKE-SAFE, not the post: cutting the fold, core-drilling the stub out
|
||||||
|
# of the footing. That's under the gutter's $90 (a full trade's morning run)
|
||||||
|
# and above the gnome's $25. Cheap ON PURPOSE: the real price of trusting
|
||||||
|
# corroded steel is the corner you lose mid-storm — the trap costs you in
|
||||||
|
# sail HP and garden exposure, and the invoice line is honest small change
|
||||||
|
# beside it. If the bill were big, the lesson would read "don't touch rust";
|
||||||
|
# at $45 it reads "rust is a bet, and here's the stake".
|
||||||
|
CORRODED_POST_COLLATERAL = 45
|
||||||
|
|
||||||
|
|
||||||
|
def build_sail_post_corroded(name):
|
||||||
|
"""The corroded sail-post variant — same bones as build_sail_post (H, R,
|
||||||
|
footing, rake_pivot mechanism all identical, so it rakes and places like
|
||||||
|
any post), different truth.
|
||||||
|
|
||||||
|
THE TELL, designed to read at two ranges (the bike lesson: LOOK at it,
|
||||||
|
and look from where the player actually stands):
|
||||||
|
· 20 m / colour: the whole shaft is weathered matte (steel_weathered)
|
||||||
|
against the honest post's bright galvanised, the footing collar wears
|
||||||
|
a rust stain skirt, and the cap sits askew. You don't see rust from
|
||||||
|
the back fence — you see a DIRTY post next to clean ones.
|
||||||
|
· 3 m / detail: rust blooms where posts actually rust (the base band
|
||||||
|
where water pools on the collar, the head collar around the pad-eye
|
||||||
|
weld), deep-rust fold line just above the footing, streaks bleeding
|
||||||
|
down the shaft, and the pad eye SAGS — the weld ate through and the
|
||||||
|
eye drooped toward the yard. The anchor empty sits at the DROOPED
|
||||||
|
eye, so the sag is a measured fact, not set dressing: e.test derives
|
||||||
|
the droop ANGLE back out of the exported anchor position and pins it
|
||||||
|
to `padeye_droop_deg` below. (Pinning "the corroded anchor is lower
|
||||||
|
than the honest one" would NOT have worked — the eye hangs off a weld
|
||||||
|
line 160 mm down the post, so that test reads 0.06 m and passes at
|
||||||
|
zero droop. Measure the angle, not the altitude.)
|
||||||
|
|
||||||
|
Corrosion a player can't see is a trap with no tell, and this repo
|
||||||
|
doesn't ship those — the three tells are pinned as three SEPARATE tests
|
||||||
|
in e.test.js (rust area, shaft lightness, eye droop) so that one failure
|
||||||
|
can't mask the other two. Rebuild this in clean steel at the same 0.55
|
||||||
|
hint and all three go red; flatten only the eye and only the third does.
|
||||||
|
"""
|
||||||
|
root = add_empty(name)
|
||||||
|
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
|
||||||
|
metallic=0.55)
|
||||||
|
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
||||||
|
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
|
||||||
|
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
|
||||||
|
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||||
|
H, R = 4.0, 0.048 # same post the honest one is — that's the trap
|
||||||
|
DROOP_DEG = 38.0 # pad-eye sag off vertical. 38° is slumped
|
||||||
|
# past argument but still recognisably an eye
|
||||||
|
# you COULD clip to — which is the whole trap.
|
||||||
|
DROOP = math.radians(DROOP_DEG)
|
||||||
|
EYE_ARM = 0.08 # eye centre's distance from the weld line
|
||||||
|
WELD_Z = H - 0.16 # the slumped weld line the eye hinges at
|
||||||
|
|
||||||
|
# Footing identical to the honest post's, plus the stain skirt: rust
|
||||||
|
# bleeds onto the collar top, so even the GROUND LINE reads brown.
|
||||||
|
join_group([
|
||||||
|
add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14),
|
||||||
|
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14),
|
||||||
|
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
|
||||||
|
], "footing", root)
|
||||||
|
|
||||||
|
# Same rake mechanism as build_sail_post — rake is a runtime decision and
|
||||||
|
# a corroded post rakes like any other; the lean you might be tempted to
|
||||||
|
# bake here would double-apply the moment a player rakes it.
|
||||||
|
rake = add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25)
|
||||||
|
rake["rake_axis"] = "x/z — rake AWAY from the load (DESIGN.md)"
|
||||||
|
rake["rake_default_deg"] = 8
|
||||||
|
|
||||||
|
above = []
|
||||||
|
above.append(join_group([
|
||||||
|
add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12),
|
||||||
|
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
|
||||||
|
# The cap sits askew — rust let the friction fit go. Silhouette tell.
|
||||||
|
add_cyl(f"{name}_cap", R * 1.2, 0.02, (0, 0, H + 0.008), dark, verts=12,
|
||||||
|
rot=(math.radians(14), 0, 0)),
|
||||||
|
], "post"))
|
||||||
|
|
||||||
|
# The corrosion itself, its own node so the mutation control can kill it
|
||||||
|
# cleanly: blooms slightly proud of the shaft (rust swells), streaks
|
||||||
|
# bleeding DOWN from each bloom. Positions are hand-placed, not rng — the
|
||||||
|
# blooms sit where posts actually rust, and determinism costs nothing.
|
||||||
|
corrosion = [
|
||||||
|
add_cyl(f"{name}_rust_base", R + 0.004, 0.34, (0, 0, 0.34), rust, verts=12),
|
||||||
|
add_cyl(f"{name}_rust_line", R + 0.007, 0.11, (0, 0, 0.185), rust_d, verts=12),
|
||||||
|
add_cyl(f"{name}_rust_head", R + 0.004, 0.26, (0, 0, H - 0.16), rust, verts=12),
|
||||||
|
add_cyl(f"{name}_rust_weld", R + 0.006, 0.06, (0, 0, WELD_Z), rust_d, verts=12),
|
||||||
|
]
|
||||||
|
for i, (ang_deg, length, z_top) in enumerate([
|
||||||
|
(200, 1.45, H - 0.24), # off the weld collar, yard side (-y)
|
||||||
|
(335, 0.95, H - 0.30),
|
||||||
|
(25, 0.70, H - 0.35),
|
||||||
|
(160, 0.80, 0.95), # wicking up off the base band
|
||||||
|
(300, 0.60, 0.85)]):
|
||||||
|
a = math.radians(ang_deg)
|
||||||
|
corrosion.append(add_box(
|
||||||
|
f"{name}_streak_{i}", (0.016, 0.016, length),
|
||||||
|
(math.cos(a) * (R + 0.003), math.sin(a) * (R + 0.003),
|
||||||
|
z_top - length / 2), rust_d, rot=(0, 0, a)))
|
||||||
|
above.append(join_group(corrosion, "rust"))
|
||||||
|
|
||||||
|
# The sagging pad eye: plate and ring both pitched DROOP about the weld
|
||||||
|
# line, hanging toward -y (three.js +Z, the yard side — same face the
|
||||||
|
# pergola presents). Built in the tilted plane directly, no post-rotation.
|
||||||
|
ring_c = (0, -math.sin(DROOP) * EYE_ARM,
|
||||||
|
WELD_Z + math.cos(DROOP) * EYE_ARM)
|
||||||
|
eye_parts = [add_box(f"{name}_padeye", (0.012, 0.07, 0.09),
|
||||||
|
(0, -math.sin(DROOP) * EYE_ARM / 2,
|
||||||
|
WELD_Z + math.cos(DROOP) * EYE_ARM / 2), rust_d,
|
||||||
|
rot=(-DROOP, 0, 0))]
|
||||||
|
r_eye, segs = 0.026, 10
|
||||||
|
pts = []
|
||||||
|
for i in range(segs + 1):
|
||||||
|
t = math.tau * i / segs
|
||||||
|
pts.append((math.cos(t) * r_eye,
|
||||||
|
ring_c[1] - math.sin(t) * r_eye * math.sin(DROOP),
|
||||||
|
ring_c[2] + math.sin(t) * r_eye * math.cos(DROOP)))
|
||||||
|
for i in range(segs):
|
||||||
|
eye_parts.append(add_tube_between(f"{name}_eye_s{i}", pts[i], pts[i + 1],
|
||||||
|
0.008, rust, verts=8))
|
||||||
|
above.append(join_group(eye_parts, "pad_eye"))
|
||||||
|
|
||||||
|
# The anchor sits AT the drooped eye — the sag is where you'd clip on,
|
||||||
|
# so the sag is data the sim sees, not paint.
|
||||||
|
e = add_empty("top_anchor", ring_c, size=0.2)
|
||||||
|
e["anchor_type"] = "corroded_post"
|
||||||
|
e["rating_hint"] = CORRODED_POST_RATING
|
||||||
|
e["collateral"] = "corroded_post"
|
||||||
|
e["why"] = ("a real post in a real footing, eaten: above the swing frame "
|
||||||
|
"(0.45) because concrete beats loose-on-grass, below the "
|
||||||
|
"pergola (0.65) because the bloom outside is pitting inside — "
|
||||||
|
"you de-rate what you can't inspect. The rung fills 0.45→0.65; "
|
||||||
|
"rungs create decisions")
|
||||||
|
above.append(e)
|
||||||
|
for o in above:
|
||||||
|
parent_keep_transform(o, rake)
|
||||||
|
|
||||||
|
stamp(root, name, "structure")
|
||||||
|
root["post_height"] = H
|
||||||
|
root["rake_note"] = "rotate about rake_pivot; rake away from the load"
|
||||||
|
root["collateral_key"] = "corroded_post"
|
||||||
|
root["collateral_value"] = CORRODED_POST_COLLATERAL
|
||||||
|
root["collateral_label"] = "the corroded post"
|
||||||
|
root["breakable"] = True
|
||||||
|
root["corrosion_note"] = ("the tell is load-bearing: e.test pins rust "
|
||||||
|
"area, shaft dullness and the eye droop — a "
|
||||||
|
"clean-steel rebuild at the same hint goes red")
|
||||||
|
|
||||||
|
# THE AXIS TRAP (E's swing_set precedent, fourth application). The droop
|
||||||
|
# is an ORIENTATION CLAIM, and orientation claims in this repo are the
|
||||||
|
# ones that rot: the exporter maps Blender (x,y,z) → three.js (x,z,−y), so
|
||||||
|
# a docstring saying "-y" and a mesh leaning +Z agree only by luck. These
|
||||||
|
# extras are stated in THREE.JS coords and e.test recomputes them off the
|
||||||
|
# exported geometry — so note and mesh can only ever lie together.
|
||||||
|
# Blender ring_c (0, −sinD·ARM, WELD_Z+cosD·ARM)
|
||||||
|
# → three.js (0, WELD_Z+cosD·ARM, +sinD·ARM)
|
||||||
|
# i.e. the eye hangs toward +Z, the yard side, the face the pergola
|
||||||
|
# presents — so the sag is pointed at the player who has to judge it.
|
||||||
|
root["padeye_droop_deg"] = DROOP_DEG
|
||||||
|
root["padeye_arm_m"] = EYE_ARM
|
||||||
|
root["padeye_droop_dir_threejs"] = "+Z"
|
||||||
|
root["padeye_weld_y_threejs"] = WELD_Z # blender z → three.js y
|
||||||
|
root["padeye_droop_note"] = (
|
||||||
|
"top_anchor sits EYE_ARM from the weld line, pitched DROOP_DEG off "
|
||||||
|
"vertical toward three.js +Z (the yard). e.test derives the angle "
|
||||||
|
"back out of the exported anchor position and pins it to this number: "
|
||||||
|
"flatten the eye and the note goes red with it")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def build_sail_post_corroded_wrecked(name):
|
||||||
|
"""The corroded post after a loaded corner won: FOLDED at the deep-rust
|
||||||
|
line just above the footing (where the intact variant wears its worst
|
||||||
|
band — the wreck fails exactly where the tell said it would), stub
|
||||||
|
standing plumb with a torn rim, the rest of the post flat on the grass
|
||||||
|
reaching ~3.8 m into the yard (-y here, three.js +Z), pad eye still
|
||||||
|
drooped at the far end with nothing left to hold. Same footing, same
|
||||||
|
numbers, same price as its twin (the carport/gutter chain, application
|
||||||
|
six). No anchor empty survives — an anchor that outlives its structure
|
||||||
|
is the free-failure bug in a costume (fascia rule, fourth application)."""
|
||||||
|
root = add_empty(name)
|
||||||
|
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
|
||||||
|
metallic=0.55)
|
||||||
|
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
||||||
|
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
|
||||||
|
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
|
||||||
|
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||||
|
H, R = 4.0, 0.048
|
||||||
|
FOLD = 0.46 # it let go at the rust line, not the weld:
|
||||||
|
# the fold is where the section was thinnest
|
||||||
|
|
||||||
|
join_group([
|
||||||
|
add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14),
|
||||||
|
add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14),
|
||||||
|
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
|
||||||
|
], "footing", root)
|
||||||
|
|
||||||
|
# The stub: plumb (the footing held — that half of the 0.55 was honest),
|
||||||
|
# deep rust at the fold, torn rim leaning the way the post went.
|
||||||
|
join_group([
|
||||||
|
add_cyl(f"{name}_stub", R, FOLD, (0, 0, 0.14 + FOLD / 2), steel, verts=12),
|
||||||
|
add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12),
|
||||||
|
add_cyl(f"{name}_stub_rust", R + 0.007, 0.14, (0, 0, 0.14 + FOLD - 0.07),
|
||||||
|
rust_d, verts=12),
|
||||||
|
add_cone(f"{name}_tear", R + 0.004, 0.012, 0.09,
|
||||||
|
(0.005, -0.02, 0.14 + FOLD + 0.035), rust_d, verts=10,
|
||||||
|
rot=(math.radians(18), 0, 0)),
|
||||||
|
], "post_stub", root)
|
||||||
|
|
||||||
|
# The fallen length, resting on its radius, slightly yawed — nothing real
|
||||||
|
# falls on axis. Blooms ride along it where the intact bands were.
|
||||||
|
L = H - FOLD
|
||||||
|
yaw = math.radians(7)
|
||||||
|
rotv = (math.radians(90), 0, yaw)
|
||||||
|
|
||||||
|
def along(s): # a point s metres from the break end, down the fallen shaft
|
||||||
|
return (-math.sin(yaw) * (0.30 + s), -math.cos(yaw) * (0.30 + s), R)
|
||||||
|
|
||||||
|
down = [
|
||||||
|
add_cyl(f"{name}_shaft_down", R, L, along(L / 2), steel, verts=12, rot=rotv),
|
||||||
|
add_cyl(f"{name}_down_break", R + 0.007, 0.18, along(0.10), rust_d,
|
||||||
|
verts=12, rot=rotv),
|
||||||
|
add_cyl(f"{name}_down_mid", R + 0.004, 0.30, along(L * 0.55), rust,
|
||||||
|
verts=12, rot=rotv),
|
||||||
|
add_cyl(f"{name}_down_head", R + 0.004, 0.26, along(L - 0.35), rust,
|
||||||
|
verts=12, rot=rotv),
|
||||||
|
add_cyl(f"{name}_down_cap", R * 1.2, 0.02, along(L - 0.01), dark,
|
||||||
|
verts=12, rot=rotv),
|
||||||
|
]
|
||||||
|
join_group(down, "post_down", root)
|
||||||
|
|
||||||
|
# The pad eye at the far end, on the grass, still drooped, still rusty —
|
||||||
|
# the one part of this post that was never going to hold anything again.
|
||||||
|
ex, ey, _ = along(L - 0.10)
|
||||||
|
eye = [add_box(f"{name}_padeye_down", (0.012, 0.09, 0.07),
|
||||||
|
(ex + 0.09, ey, 0.035), rust_d,
|
||||||
|
rot=(0, math.radians(80), 0)),
|
||||||
|
add_arc_tube(f"{name}_eye_down", 0.026, 0.008, 0, math.tau, rust,
|
||||||
|
segs=10, center=(ex + 0.16, ey, 0.012), plane='XY')]
|
||||||
|
join_group(eye, "pad_eye_down", root)
|
||||||
|
|
||||||
|
stamp(root, name, "structure")
|
||||||
|
root["broken_variant_of"] = "sail_post_corroded"
|
||||||
|
root["collateral_key"] = "corroded_post"
|
||||||
|
root["collateral_value"] = CORRODED_POST_COLLATERAL
|
||||||
|
root["collateral_label"] = "the corroded post"
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
def build_ladder_01(name):
|
def build_ladder_01(name):
|
||||||
root = add_empty(name)
|
root = add_empty(name)
|
||||||
alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85)
|
alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85)
|
||||||
@ -3544,6 +3830,19 @@ ASSETS = [
|
|||||||
dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)),
|
dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)),
|
||||||
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
|
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
|
||||||
"fence_post_01", "fence_post_14"]),
|
"fence_post_01", "fence_post_14"]),
|
||||||
|
# SPRINT17 gate 3.1 — the corroded tier. The intact variant must stand
|
||||||
|
# POST height (its lie is "I'm a sail post", so it stands like one; only
|
||||||
|
# the colour and the drooped eye say otherwise). The wreck's z ceiling is
|
||||||
|
# under a metre because the post FOLDED at the rust line — a corroded
|
||||||
|
# wreck still standing tall is a wreck that never told the truth — and
|
||||||
|
# its y floor is past 3.5 because the fallen shaft is out in the yard.
|
||||||
|
dict(name="sail_post_corroded", fn=build_sail_post_corroded,
|
||||||
|
dims=((0.40, 0.60), (0.40, 0.65), (3.90, 4.15)),
|
||||||
|
nodes=["footing", "post", "rust", "pad_eye", "top_anchor",
|
||||||
|
"rake_pivot"]),
|
||||||
|
dict(name="sail_post_corroded_wrecked", fn=build_sail_post_corroded_wrecked,
|
||||||
|
dims=((0.40, 0.80), (3.50, 4.40), (0.45, 0.95)),
|
||||||
|
nodes=["footing", "post_stub", "post_down", "pad_eye_down"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 4.8 MiB |
@ -128,7 +128,12 @@ export async function buildGardenflyTests() {
|
|||||||
let sepStates = null, sepStatesErr = null;
|
let sepStates = null, sepStatesErr = null;
|
||||||
try {
|
try {
|
||||||
sepStates = [];
|
sepStates = [];
|
||||||
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
|
// SPRINT17 gate 3.2 [D]: site_04_pool_yard added to the judged walk in the
|
||||||
|
// same commit that ships it (B's file, flagged in THREADS, revert-and-tell-
|
||||||
|
// me standard) — a yard missing from this list is UNJUDGED silently, which
|
||||||
|
// is the exact silence the gate-2.3 rule forbids. It carries a refusal
|
||||||
|
// finding (bare 83.7 FULL under its southerly; receipts in the site file).
|
||||||
|
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn', 'site_04_pool_yard']) {
|
||||||
const sd = await loadSite(name);
|
const sd = await loadSite(name);
|
||||||
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
|
sepStates.push({ name, pinned: !!sd.separation, finding: !!sd._separation_finding });
|
||||||
}
|
}
|
||||||
@ -268,7 +273,7 @@ export async function buildGardenflyTests() {
|
|||||||
// but silence is not an option, and holding both means the finding went
|
// but silence is not an option, and holding both means the finding went
|
||||||
// stale the day the pin landed and must be deleted (its own text says so).
|
// stale the day the pin landed and must be deleted (its own text says so).
|
||||||
if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`);
|
if (sepStatesErr) throw new Error(`site read died: ${sepStatesErr}`);
|
||||||
assert(sepStates.length === 3, `expected 3 shipped yards, read ${sepStates.length}`);
|
assert(sepStates.length === 4, `expected 4 shipped yards, read ${sepStates.length}`);
|
||||||
for (const s of sepStates) {
|
for (const s of sepStates) {
|
||||||
assert(s.pinned || s.finding,
|
assert(s.pinned || s.finding,
|
||||||
`${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` +
|
`${s.name} has neither a separation block nor a _separation_finding — the yard is UNJUDGED; ` +
|
||||||
|
|||||||
237
web/world/data/sites/site_04_pool_yard.json
Normal file
237
web/world/data/sites/site_04_pool_yard.json
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
{
|
||||||
|
"id": "site_04_pool_yard",
|
||||||
|
"name": "The Pool Yard",
|
||||||
|
"blurb": "Fourteen perfect fence posts and not one of them is yours to use. The only steel standing where the shade wants a corner went in before the pool did.",
|
||||||
|
"_design": [
|
||||||
|
"THE YARD WHERE EVERYTHING SAYS NO EXCEPT THE THING THAT SHOULD NOT.",
|
||||||
|
"ROADMAP arc-2 thesis: fence compliance + corroded cheap hardware. The pool ring is",
|
||||||
|
"FOURTEEN honest noes (every post tie_off:false in the GLB — a certified pool barrier",
|
||||||
|
"is not an anchor, at any rating, ever); the corroded post c1 is the one thing that",
|
||||||
|
"DOES adopt on the east side, at 0.55, and it stands exactly where the bed's NE",
|
||||||
|
"corner wants steel. That is not an accident, it is the whole level:",
|
||||||
|
" - the bed is poolside; every 92%-cover quad in the band routes through c1.",
|
||||||
|
" Full shade in this yard has rust in it, or it does not exist (measured, SCORE IT:",
|
||||||
|
" the three 92% lines are t1,p1,p2,c1 / t1,t1b,p2,c1 / t1b,p1,p2,c1 — no others).",
|
||||||
|
" - the honest steel (gum t1/t1b, posts p1,p2,p3, the familiar 0.35 fascia) tops out",
|
||||||
|
" at 58% cover for $40 — the cheap safe rig leaves the bed's pool corner open.",
|
||||||
|
" - c1 is SHAPE-SENSITIVE, and that is the corroded tier working: the same drooped",
|
||||||
|
" eye is clean at $65 rated on t1,p1,p2,c1, and UNHOLDABLE AT ANY PRICE on the two",
|
||||||
|
" quads that pull it harder (t1,t1b,p2,c1 and t1b,p1,p2,c1). You cannot read which",
|
||||||
|
" off the object — E built the rung so you gamble rather than compute (their 0.55",
|
||||||
|
" reasoning, THREADS S17), and this yard is that gamble with a fence around it.",
|
||||||
|
"WIND: the venturi is the channel between the house's east end and the pool enclosure",
|
||||||
|
"— real geometry, gate-side, running N-S (axis 1.571, gain 1.4, r 5, throat (3.1,-3.5)).",
|
||||||
|
"It bites c1 and h3: the two lie-tiers are the two corners in the wind. The wreck",
|
||||||
|
"apron is PAID FOR (E's placement facts): c1 falls +Z ~3.9 m and the shaft lands in",
|
||||||
|
"the path between bed (x<=2.5) and ring (x>=3.7) — clear of both by ~0.3 m each side.",
|
||||||
|
"One corroded post ON PURPOSE: two would share the baked collateral key",
|
||||||
|
"(structFor/wreckStructure match the first entry; C's envelope dedupes keys), so the",
|
||||||
|
"second failure would bill and wreck the wrong post. Filed in THREADS [D] S17 —",
|
||||||
|
"a second rust rung in one yard needs its own key plumbing first."
|
||||||
|
],
|
||||||
|
"_why": [
|
||||||
|
"Authored COLD in the yard editor, SPRINT17 gate 3.2 — palette shapes, editor",
|
||||||
|
"placement, iterative SCORE IT (three passes: 2 quads -> tightened to band -> 13).",
|
||||||
|
"Every number below is the SCORE IT card, storm_03_southerly, FUNNEL ON, 9 dressed",
|
||||||
|
"anchors, export-clone world, 136.5 s flight. Confirmed by audit.html over the saved",
|
||||||
|
"file (same engine, second route) before shipping.",
|
||||||
|
" WINNABLE at $80 — 13 quads in band, 6 clean, 1 marginal.",
|
||||||
|
" cheapest honest line h1,t1b,p2,p3 — $40 hardware (+$15 spare), 45 m2, 58% cover,",
|
||||||
|
" garden 92.7 FULL, +9.0 HP over bare. No rust in it.",
|
||||||
|
" the rust gate full cover exists ONLY through c1 (92% x3, above); cheapest",
|
||||||
|
" clean rust line t1,p1,p2,c1 $65 — corrosion prices as +$25 of",
|
||||||
|
" hardware you must rate UP, plus a $45 stake on a 0.55 eye.",
|
||||||
|
" margin flags c1 4% headroom on h1,h2,p2,c1 (wrecks the corroded post $45);",
|
||||||
|
" p1 2% / t1b 2% / t1 4% — the 15% rule, marginal is not PASS.",
|
||||||
|
" collateral exposure gutter $90 (h1-h3) + corroded post $45 (c1) = $135 from",
|
||||||
|
" anchors; board exposure $160 with the gnome (exposureOf).",
|
||||||
|
" bare bed 83.7 FULL — the southerly cannot kill an open bed (its pea",
|
||||||
|
" hail takes 10.7 HP). The sail here is for the STEEL and the",
|
||||||
|
" collateral, per A's S17 gate-0.4 ruling — which is this",
|
||||||
|
" yard's thesis said as canon."
|
||||||
|
],
|
||||||
|
"yard": {
|
||||||
|
"width": 24,
|
||||||
|
"depth": 16
|
||||||
|
},
|
||||||
|
"sun": {
|
||||||
|
"elevationDeg": 55,
|
||||||
|
"azimuthDeg": -125
|
||||||
|
},
|
||||||
|
"gardenBed": {
|
||||||
|
"x": 0,
|
||||||
|
"z": 1.5,
|
||||||
|
"w": 5,
|
||||||
|
"d": 3.5
|
||||||
|
},
|
||||||
|
"house": {
|
||||||
|
"model": "house_yardside_v1",
|
||||||
|
"wreckedModel": "house_yardside_wrecked_v1",
|
||||||
|
"collateralKey": "gutter",
|
||||||
|
"collateralValue": 90,
|
||||||
|
"collateralLabel": "the gutter",
|
||||||
|
"x": -3,
|
||||||
|
"z": -6,
|
||||||
|
"anchors": [
|
||||||
|
{
|
||||||
|
"id": "h1",
|
||||||
|
"node": "fascia_anchor_01",
|
||||||
|
"type": "house",
|
||||||
|
"work": "bracket"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h2",
|
||||||
|
"node": "fascia_anchor_02",
|
||||||
|
"type": "house",
|
||||||
|
"work": "bracket"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "h3",
|
||||||
|
"node": "fascia_anchor_03",
|
||||||
|
"type": "house",
|
||||||
|
"work": "bracket"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"structures": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"model": "sail_post_corroded_v1",
|
||||||
|
"wreckedModel": "sail_post_corroded_wrecked_v1",
|
||||||
|
"x": 3.1,
|
||||||
|
"z": -2,
|
||||||
|
"rotYDeg": 0,
|
||||||
|
"solid": true,
|
||||||
|
"collateralKey": "corroded_post",
|
||||||
|
"collateralValue": 45,
|
||||||
|
"collateralLabel": "the corroded post",
|
||||||
|
"anchors": [
|
||||||
|
{
|
||||||
|
"id": "c1_a1",
|
||||||
|
"node": "top_anchor",
|
||||||
|
"type": "corroded_post",
|
||||||
|
"work": "cloth"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pool",
|
||||||
|
"model": "pool_kit_01_v1",
|
||||||
|
"x": 6.5,
|
||||||
|
"z": 1.5,
|
||||||
|
"rotYDeg": 0,
|
||||||
|
"solid": true,
|
||||||
|
"anchors": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trees": [
|
||||||
|
{
|
||||||
|
"id": "t1",
|
||||||
|
"model": "tree_gum_02_v1",
|
||||||
|
"x": -6.8,
|
||||||
|
"z": 2,
|
||||||
|
"phase": 1.7,
|
||||||
|
"trunkH": 3.8,
|
||||||
|
"anchorY": 3.1,
|
||||||
|
"anchors": [
|
||||||
|
{
|
||||||
|
"id": "t1",
|
||||||
|
"node": "branch_anchor_01",
|
||||||
|
"type": "tree",
|
||||||
|
"work": "cloth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t1b",
|
||||||
|
"node": "branch_anchor_02",
|
||||||
|
"type": "tree",
|
||||||
|
"work": "cloth"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"id": "p1",
|
||||||
|
"x": -3.4,
|
||||||
|
"z": 4.6,
|
||||||
|
"h": 4,
|
||||||
|
"type": "post",
|
||||||
|
"work": "cloth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p2",
|
||||||
|
"x": 3.4,
|
||||||
|
"z": 4.6,
|
||||||
|
"h": 4,
|
||||||
|
"type": "post",
|
||||||
|
"work": "cloth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p3",
|
||||||
|
"x": -5.8,
|
||||||
|
"z": -1.5,
|
||||||
|
"h": 4,
|
||||||
|
"type": "post",
|
||||||
|
"work": "cloth"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fence": {
|
||||||
|
"sides": [
|
||||||
|
"north",
|
||||||
|
"south",
|
||||||
|
"east",
|
||||||
|
"west"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"shed": {
|
||||||
|
"model": "shed_01_v1",
|
||||||
|
"x": -9.5,
|
||||||
|
"z": -5.5,
|
||||||
|
"rotYDeg": -90
|
||||||
|
},
|
||||||
|
"shedTable": {
|
||||||
|
"model": "shed_table_v1",
|
||||||
|
"x": -6.5,
|
||||||
|
"z": 7,
|
||||||
|
"rotYDeg": -90,
|
||||||
|
"pickupNode": "pickup_anchor"
|
||||||
|
},
|
||||||
|
"gnome": {
|
||||||
|
"model": "garden_gnome_01_v1",
|
||||||
|
"x": 4.6,
|
||||||
|
"z": 4.4,
|
||||||
|
"rotYDeg": 0,
|
||||||
|
"collateralValue": 25
|
||||||
|
},
|
||||||
|
"wind": {
|
||||||
|
"_venturi": [
|
||||||
|
"The pool-terrace channel: house east end to pool enclosure, gate side, running N-S.",
|
||||||
|
"Axis 1.571 is the channel's GEOMETRY (a line, mod pi — site_02's reconciled lesson),",
|
||||||
|
"not a storm heading. Gain 1.4 / r 5 puts c1 (1.4 m off-throat) and h3 (3.7 m) in the",
|
||||||
|
"wind: the corroded post is the venturi post, which is the temptation priced."
|
||||||
|
],
|
||||||
|
"venturi": [
|
||||||
|
{
|
||||||
|
"x": 3.1,
|
||||||
|
"z": -3.5,
|
||||||
|
"axis": 1.571,
|
||||||
|
"gain": 1.4,
|
||||||
|
"radius": 5,
|
||||||
|
"sharp": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"_separation_finding": [
|
||||||
|
"SPRINT17 gate 3.2 [D] — a separation pin was ATTEMPTED and honestly REFUSED, the",
|
||||||
|
"third refusal in the family (site_02, site_03, now here) and the first authored",
|
||||||
|
"AFTER A's gate-0.4 ruling made the pattern canon. Measured (SCORE IT, funnel ON,",
|
||||||
|
"storm_03_southerly, 2026-07-21): bare bed 83.7 FULL (hail 10.7 + rain 5.6 HP);",
|
||||||
|
"best flown 92.7 FULL (h1,t1b,p2,p3, $40); best gain +9.0 HP. bareMustLoseBelow",
|
||||||
|
"cannot be written at any threshold — bare WINS the night by 33.7 over the win",
|
||||||
|
"line. On this night the sail is for the steel and the collateral (A, S17 0.4,",
|
||||||
|
"ruled canon, taught by the board's exposure line): what c1 separates is not the",
|
||||||
|
"bed from the hail but $45 and your margin from your greed. When B's garden-BONUS",
|
||||||
|
"separation shape lands (A ruled FOR it, deferred to its own gate), re-measure —",
|
||||||
|
"this yard's +9.0 over bare at $40 is a bonus-stake candidate, not a win/lose one."
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -73,6 +73,13 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
|
|||||||
"carport_01/post_anchor_02"
|
"carport_01/post_anchor_02"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"corroded_post": {
|
||||||
|
"collateral": "corroded_post",
|
||||||
|
"rating_hint": 0.55,
|
||||||
|
"sources": [
|
||||||
|
"sail_post_corroded/top_anchor"
|
||||||
|
]
|
||||||
|
},
|
||||||
"house": {
|
"house": {
|
||||||
"collateral": "gutter",
|
"collateral": "gutter",
|
||||||
"rating_hint": 0.35,
|
"rating_hint": 0.35,
|
||||||
|
|||||||
380
web/world/js/board.js
Normal file
380
web/world/js/board.js
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
/**
|
||||||
|
* SHADES / HARD YARDS — THE JOB BOARD. Lane A owns this file.
|
||||||
|
*
|
||||||
|
* SPRINT17 gate 1, opening ROADMAP arc 2: *"mornings offer N callouts, you take
|
||||||
|
* one. Pay, risk, client and site visible before you commit — income becomes a
|
||||||
|
* choice."* Until now the week was a script: you were handed tonight and you
|
||||||
|
* rigged it. From here the week is a script you can step off.
|
||||||
|
*
|
||||||
|
* Zero imports beyond contracts + week, and no THREE — same charter as week.js,
|
||||||
|
* and for the same reason: an offer is pure data, so the board is testable at
|
||||||
|
* fixed cost in node with no browser and no WebGL context. The only thing that
|
||||||
|
* touches the screen is hud.js reading these objects.
|
||||||
|
*
|
||||||
|
* ── THE THREE RULES THIS FILE EXISTS TO KEEP ──────────────────────────────
|
||||||
|
*
|
||||||
|
* 1. **THE SCRIPTED SPINE SURVIVES.** Every morning offers tonight's authored
|
||||||
|
* night. It is both the campaign and the test fixture, and 474 asserts stand
|
||||||
|
* on it — so the board ADDS a second door, it never replaces the first. Take
|
||||||
|
* nothing and the week runs exactly as it ran before this file existed.
|
||||||
|
*
|
||||||
|
* 2. **DETERMINISTIC, FROM THE WEEK AND THE INDEX.** No Date.now, no
|
||||||
|
* Math.random — the repo's oldest rule (contracts.js §Determinism), and the
|
||||||
|
* selftest depends on it: an offer pool that draws from the clock is a suite
|
||||||
|
* that goes red on a Tuesday. Same week seed, same night, same two offers,
|
||||||
|
* forever. Seeded through `rng()` from contracts.js rather than a hash I'd
|
||||||
|
* have to justify — the house PRNG is already the thing every other seeded
|
||||||
|
* system in this repo reproduces against.
|
||||||
|
*
|
||||||
|
* 3. **THE CHOSEN JOB *IS* THE NIGHT — by construction, not by discipline.**
|
||||||
|
* An offer carries a WHOLE NIGHT ENTRY (`offer.night`), the same shape
|
||||||
|
* NIGHTS holds, and `week.take()` installs it at tonight's index. So the job
|
||||||
|
* sheet, the quote, the invoice, the ledger, warranty, rep and the end card
|
||||||
|
* all read a chosen alternative through the SAME `nightAt()`-shaped path
|
||||||
|
* they read a scripted night through — not one of them can tell the
|
||||||
|
* difference, and none of them needed a line changed. A board that bolted a
|
||||||
|
* parallel "callout" concept alongside the night would have had to teach
|
||||||
|
* seven surfaces about it, and one of them would have been missed. (a.test
|
||||||
|
* pins this from the paperwork end: a chosen alternative that skipped the
|
||||||
|
* ledger is the board lying.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { rng } from './contracts.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a client is allowed to demand. CHECKED, not documented — this repo's
|
||||||
|
* standing rule is that an unenforced enum is decoration (the carport shipped
|
||||||
|
* typed as a 'post' for a whole sprint behind a JSDoc comment that said it
|
||||||
|
* couldn't). `validateConstraint` below fails loud, and a.test flies a bogus
|
||||||
|
* kind through it to prove the check can fail.
|
||||||
|
*
|
||||||
|
* SPRINT17 gate 2 ships two. Both are DATA here and ENFORCEMENT in Lane B's
|
||||||
|
* rigging session — the seam agreed in THREADS:
|
||||||
|
*
|
||||||
|
* · `noAnchorFamily` — "nothing attached to the house". `family` names an
|
||||||
|
* ANCHOR_TYPE the session must refuse, with `says` in the ticker.
|
||||||
|
* · `budgetCap` — "cheapest option and I'll sue". `cap` is a hard ceiling on
|
||||||
|
* the RIG, below START_BUDGET. The client caps your spend, not your wallet:
|
||||||
|
* your bank is untouched, and what you don't spend you keep.
|
||||||
|
*/
|
||||||
|
export const CONSTRAINT_KIND = Object.freeze(['noAnchorFamily', 'budgetCap']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A constrained night pays a PREMIUM. ⚖️ RULED (A, SPRINT17 gate 2) —
|
||||||
|
* the number, and why it is this number:
|
||||||
|
*
|
||||||
|
* The premium is a FRACTION OF THE CALLOUT FEE, declared on the constraint
|
||||||
|
* itself rather than fixed in code, so a new constraint prices itself as data
|
||||||
|
* (the `pay` override pattern this file's neighbour already uses). It scales
|
||||||
|
* with the fee, which means a nasty night's constraint is worth more than a
|
||||||
|
* gentle one's — correct, because the same ban costs you more when the weather
|
||||||
|
* is actually trying.
|
||||||
|
*
|
||||||
|
* · **noAnchorFamily: +25%.** It removes a whole family of options — on
|
||||||
|
* backyard_01 the house side is three of thirteen anchors AND the cheap
|
||||||
|
* cover over the bed. You are being paid to solve a smaller puzzle with the
|
||||||
|
* same storm in it.
|
||||||
|
* · **budgetCap: +20%, deliberately the lower of the two.** A cap denies you
|
||||||
|
* money you were going to SPEND, and what you don't spend stays in the bank
|
||||||
|
* — so part of your compensation is already in your pocket and paying a
|
||||||
|
* full house-ban premium on top would be paying you twice for one squeeze.
|
||||||
|
*
|
||||||
|
* ⚠️ **UNMEASURED — same status, and the same standing instruction, as
|
||||||
|
* `noCollateralBonus` and `REP` in week.js.** Nobody has played a constrained
|
||||||
|
* night yet; these are sized by argument, not by a playtest. They are per-
|
||||||
|
* constraint data, cutting them costs nothing but a number on a card, and if
|
||||||
|
* gate 4's play says constrained jobs are free money, THESE are the levers —
|
||||||
|
* cut before anything in PAY, which carries measured evidence and long reasons.
|
||||||
|
* I did not fund a new feature by quietly trimming a constant somebody measured.
|
||||||
|
*/
|
||||||
|
export const CONSTRAINT_PREMIUM = Object.freeze({ noAnchorFamily: 0.25, budgetCap: 0.20 });
|
||||||
|
|
||||||
|
/** Fails loud on a malformed constraint — content bugs are not runtime surprises. */
|
||||||
|
export function validateConstraint(c, where = '?') {
|
||||||
|
const bad = [];
|
||||||
|
if (!c || typeof c !== 'object') bad.push('not an object');
|
||||||
|
else {
|
||||||
|
if (!CONSTRAINT_KIND.includes(c.kind)) {
|
||||||
|
bad.push(`kind ${JSON.stringify(c.kind)} is not one of ${CONSTRAINT_KIND.join('|')}`);
|
||||||
|
}
|
||||||
|
if (!c.says || String(c.says).length < 8) bad.push('needs `says` — the CLIENT\'S words, for the ticker and both papers');
|
||||||
|
if (!c.label) bad.push('needs `label` — the short form the offer card and the job sheet print');
|
||||||
|
if (c.kind === 'noAnchorFamily' && !c.family) bad.push('noAnchorFamily needs `family`');
|
||||||
|
if (c.kind === 'budgetCap' && !Number.isFinite(c.cap)) bad.push('budgetCap needs a numeric `cap`');
|
||||||
|
}
|
||||||
|
if (bad.length) throw new Error(`board: constraint on ${where} is invalid:\n ${bad.join('\n ')}`);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The premium multiplier a night's constraints add to its callout fee.
|
||||||
|
* Pure, and the ONLY place the premium is computed — week.js's quote() and
|
||||||
|
* settle() both route through calloutFee(), so the sheet and the invoice
|
||||||
|
* cannot price a constrained night differently. Same construction that keeps
|
||||||
|
* warranty honest.
|
||||||
|
*/
|
||||||
|
export function premiumFor(constraints) {
|
||||||
|
return (constraints ?? []).reduce(
|
||||||
|
(s, c) => s + (c.premium ?? CONSTRAINT_PREMIUM[c.kind] ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE ALTERNATIVE POOL — the board's own work, and it is DATA.
|
||||||
|
*
|
||||||
|
* SPRINT17 gate 1.5 sizes this honestly: "the two unused yard/storm pairings
|
||||||
|
* the one-variable law allows, plus repeat visits at different pay". Adding a
|
||||||
|
* yard to the board is an entry in this array and nothing else — which is the
|
||||||
|
* requirement D's pool yard (gate 3) lands against: their yard enters the game
|
||||||
|
* by being CHOSEN, the first one whose only route in is the board.
|
||||||
|
*
|
||||||
|
* ⚠️ **THE ADMISSION PRICE FOR A POOL ENTRY, and it is not negotiable:** a
|
||||||
|
* pairing here has been through the gauntlet. The board is allowed to offer a
|
||||||
|
* night the campaign never scripted; it is NOT allowed to offer a night nobody
|
||||||
|
* measured, because "is this site winnable, at what price" is a fact about
|
||||||
|
* GEOMETRY × STORM and the ladder's answer for one pairing is not an answer for
|
||||||
|
* another. Every entry below carries its audit receipt in `_why`. A new entry
|
||||||
|
* without one is a night the board is guessing about.
|
||||||
|
*
|
||||||
|
* What is deliberately NOT here, and why (the pairings that look free and
|
||||||
|
* aren't):
|
||||||
|
* · **soaker anywhere but site_02** — C MEASURED the pairing: the backyard's
|
||||||
|
* buyable geometry caps hail cover over the bed at ~31%, so the fabric bet
|
||||||
|
* has no win in it over there. An offer is a promise that the night can be
|
||||||
|
* worked.
|
||||||
|
* · **wildnight anywhere but backyard_01** — its separation is PINNED to that
|
||||||
|
* yard's site data. Fly it elsewhere and the pin describes a night nobody
|
||||||
|
* plays.
|
||||||
|
* · **icenight anywhere but backyard_01** — `gardenBeyondSaving` is a measured
|
||||||
|
* fact about that bed under that ice (A, S13), not a property of the storm.
|
||||||
|
* Offering it over another yard would carry the excuse without the evidence.
|
||||||
|
*/
|
||||||
|
export const POOL = [
|
||||||
|
{
|
||||||
|
id: 'swing_lawn_earlybuster',
|
||||||
|
storm: 'storm_03b_earlybuster', site: 'site_03_swing_lawn',
|
||||||
|
client: 'the Delaneys', addr: '31 Ferndale Ave — the swing lawn',
|
||||||
|
brief: 'Ruby\'s mum again — they got your number off the last job. Same lawn, except this '
|
||||||
|
+ 'change comes through early, before you\'ve got the second corner up. She wants the '
|
||||||
|
+ 'bed covered and she does not want the swing set touched more than it has to be.',
|
||||||
|
_why: 'AUDITED (A, S17 gate 1, audit.html, venturi (-6.22,-4.77) axis 1.571 gain 1.4 in the '
|
||||||
|
+ 'header): 12 holding lines / 17 marginal / 31 unholdable; cheapest hold $50 '
|
||||||
|
+ '(t2,t2b,p1,s1_f2 → garden 88.6 FULL), best flown 89.0 FULL. Winnable well inside '
|
||||||
|
+ 'the $80 start budget. The swing lawn under the EARLY change is the one-variable '
|
||||||
|
+ 'step off night 4 — same yard, the storm moved. $255 of exposure, the most in the '
|
||||||
|
+ 'game (gutter 90 + swing set 140 + gnome 25), which is what the offer card prints.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'corner_block_southerly',
|
||||||
|
storm: 'storm_03_southerly', site: 'site_02_corner_block',
|
||||||
|
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
|
||||||
|
brief: 'The corner block, and this time it\'s the plain Tuesday southerly — no early change, '
|
||||||
|
+ 'no surprises in the timing. They still reckon there\'s plenty to tie off to, which '
|
||||||
|
+ 'was true the last time somebody said it and cost a carport.',
|
||||||
|
_why: 'AUDITED (A, S17 gate 1, audit.html): 19 holding lines / 7 marginal / 38 unholdable; '
|
||||||
|
+ 'cheapest hold $40 (tr1,tr1b,q1,q3 → garden 87.6 FULL), best flown 90.4 FULL. '
|
||||||
|
+ 'site_02 under the storm night 2 already taught — one variable off night 3: the yard '
|
||||||
|
+ 'is the same, the change is not early. The carport is still the trap and still bills '
|
||||||
|
+ '$180; the brief re-sells it in the client\'s voice exactly as night 3 does, because '
|
||||||
|
+ 'the trap did not get easier.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A REPEAT VISIT AT DIFFERENT PAY — SPRINT17 gate 1.5's third kind, and the
|
||||||
|
// cheapest honest variety in the game: the same yard, the same storm the
|
||||||
|
// tutorial flies, offered as a small job. `pay.garden` is the override
|
||||||
|
// week.js has always read (settle() and quote() both take it from the same
|
||||||
|
// place), so a smaller bed's worth of money needs no code.
|
||||||
|
id: 'backyard_retainer_gentle',
|
||||||
|
storm: 'storm_01_gentle', site: 'backyard_01',
|
||||||
|
client: 'the Hendersons', addr: '14 Kurrajong St — the backyard',
|
||||||
|
brief: 'A retainer top-up, and they\'re honest about it: nothing in the forecast can hurt '
|
||||||
|
+ 'anything. Half the bed is already picked, so there\'s less riding on it — they '
|
||||||
|
+ 'just want the sail checked and stood up properly while somebody\'s there.',
|
||||||
|
pay: { garden: 25 },
|
||||||
|
_why: 'The gentle storm over the yard it was authored for — the one pairing in the game '
|
||||||
|
+ 'that needs no audit, because nothing in it can hurt the bed (week.js night 1). '
|
||||||
|
+ 'Priced DOWN (garden 25 vs 45): a night with no teeth should not pay like one that '
|
||||||
|
+ 'has them, and the board\'s whole point is that the safe job pays less.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// ⚖️ A CONSTRAINED JOB — SPRINT17 gate 2's data half, on the board so that
|
||||||
|
// taking it is a CHOICE (the spec's word). Both papers repeat it and B's
|
||||||
|
// session enforces it; the premium is priced in board.js's ruling above.
|
||||||
|
id: 'corner_block_no_house',
|
||||||
|
storm: 'storm_03_southerly', site: 'site_03_swing_lawn',
|
||||||
|
client: 'the Delaneys', addr: '31 Ferndale Ave — the swing lawn',
|
||||||
|
brief: 'They\'ve had a bracket pull out of the weatherboard before and they are still '
|
||||||
|
+ 'cross about it. Tuesday\'s southerly, the same lawn — and the house is off limits, '
|
||||||
|
+ 'which they will tell you twice. They pay over the odds for the inconvenience.',
|
||||||
|
constraints: [{
|
||||||
|
kind: 'noAnchorFamily', family: 'house',
|
||||||
|
label: 'nothing attached to the house',
|
||||||
|
says: 'Nothing goes on the house. Not a bracket, not a screw — we\'ve done that once.',
|
||||||
|
}],
|
||||||
|
_why: 'Same audited pairing as night 4 (southerly × swing lawn, D\'s S16 authoring and '
|
||||||
|
+ 'B\'s S16 table), MINUS the house family. ⚖ THE BAN IS A PUZZLE, NOT A SOFT-LOCK, '
|
||||||
|
+ 'AND THAT WAS MEASURED, NOT ASSUMED (A, S17): the swing-lawn audit\'s holding lines '
|
||||||
|
+ 'include several with NO h* anchor at all — t2,t2b,p1,s1_f2 holds at $50 and '
|
||||||
|
+ 't1b,t1c,p1,s1_f1 at $65, both garden FULL. A constraint that left no line standing '
|
||||||
|
+ 'would be a night the board offers and nobody can work; this one costs you the cheap '
|
||||||
|
+ 'house-side cover and leaves the posts, the trees and the swing frame. B enforces at '
|
||||||
|
+ 'pick time with the client\'s words in the ticker.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'corner_block_cheapest',
|
||||||
|
storm: 'storm_03b_earlybuster', site: 'site_02_corner_block',
|
||||||
|
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
|
||||||
|
brief: 'Short notice and a short temper. They want it covered, they want it cheap, and '
|
||||||
|
+ 'they made a point of telling you what the last mob charged. Read the number on '
|
||||||
|
+ 'the sheet before you agree to it — it is not your budget, it is theirs.',
|
||||||
|
constraints: [{
|
||||||
|
kind: 'budgetCap', cap: 45,
|
||||||
|
label: 'client caps the rig at $45',
|
||||||
|
says: 'Cheapest option that does the job. Anything over forty-five and I\'ll be '
|
||||||
|
+ 'querying the invoice.',
|
||||||
|
}],
|
||||||
|
_why: 'Night 3\'s audited pairing under a $45 ceiling. ⚖ THE CAP IS A SQUEEZE, NOT A '
|
||||||
|
+ 'SOFT-LOCK, MEASURED (A, S17): site_02 × earlybuster has holding lines at $40 '
|
||||||
|
+ '(tr1,tr1b,q1,q3 → garden 86.4 FULL; tr1,tr1b,q2,q4 → 84.5 FULL), so $45 leaves '
|
||||||
|
+ 'exactly two of the yard\'s cheapest holds affordable and prices out every $55+ '
|
||||||
|
+ 'line the audit rates better. That is the intended shape: the cap does not stop you '
|
||||||
|
+ 'working, it stops you buying your way out of choosing. The carport at $180 is the '
|
||||||
|
+ 'tension — the rig this cap pushes you toward is the one with the least margin over it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// SPRINT17 gate 3.2 [D] — THE POOL YARD. The first yard whose ONLY route
|
||||||
|
// into the game is being chosen: it is in no NIGHTS slot, so declining it
|
||||||
|
// forever is a complete campaign. That is the arc-2 promise made literal,
|
||||||
|
// and it is why the entry lives here and not in week.js.
|
||||||
|
id: 'pool_yard_southerly',
|
||||||
|
storm: 'storm_03_southerly', site: 'site_04_pool_yard',
|
||||||
|
client: 'the Karalis place', addr: '5 Clearwater Ct — the pool yard',
|
||||||
|
brief: 'New pool, new fence, certified in March — and the certifier is back Thursday, so '
|
||||||
|
+ 'nothing touches that fence. Not a rope, not a clamp. The old sail post from before '
|
||||||
|
+ 'the pool went in is still standing, they say, and they say it like a selling point. '
|
||||||
|
+ 'It has been breathing pool air for years. Tuesday\'s southerly, the bed\'s by the water.',
|
||||||
|
_why: 'AUTHORED COLD + AUDITED (D, S17 gate 3.2 — SCORE IT card, funnel ON, confirmed by '
|
||||||
|
+ 'audit.html over the shipped file): 13 quads in band, 6 clean, 1 marginal; cheapest '
|
||||||
|
+ 'hold $40 (h1,t1b,p2,p3 → garden 92.7 FULL, 58% cover, no rust in it). The trap is '
|
||||||
|
+ 'priced, not padded: every 92%-cover line routes through the corroded post c1 (0.55, '
|
||||||
|
+ '$45 collateral, IN the venturi) — clean at $65 rated on t1,p1,p2,c1, UNHOLDABLE on '
|
||||||
|
+ 'the two shapes that pull the eye harder. Southerly = the storm nights 2 and 4 teach; '
|
||||||
|
+ 'the yard is the only new variable (one-variable law). Separation REFUSED with '
|
||||||
|
+ 'receipts in the site file (bare 83.7 FULL — mild-night canon per A\'s 0.4 ruling); '
|
||||||
|
+ 'exposure $160 (gutter 90 + corroded post 45 + gnome 25), the fence\'s fourteen '
|
||||||
|
+ 'posts deliberately absent from that number because none of them can be tied to.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deterministic draw. `rng` is contracts.js's mulberry32 — the same PRNG the
|
||||||
|
* storm system reproduces against, so there is exactly one seeded-randomness
|
||||||
|
* story in this repo rather than two.
|
||||||
|
*
|
||||||
|
* The seed mixes the WEEK and the NIGHT INDEX (SPRINT17 gate 1.1's words), with
|
||||||
|
* odd multipliers so that week+1 and night+1 don't collide onto the same draw —
|
||||||
|
* `seed*1 + index*1` would offer week 2 night 1 and week 1 night 2 the same job,
|
||||||
|
* which looks like a bug to a player and is one.
|
||||||
|
*/
|
||||||
|
export function seedFor(weekSeed, nightIndex) {
|
||||||
|
return ((weekSeed | 0) * 0x9E37 + (nightIndex | 0) * 0x85EB + 0x27D4) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this pool entry a sane alternative to tonight's scripted job?
|
||||||
|
*
|
||||||
|
* The one rule: an alternative must be a DIFFERENT JOB. Offering the same
|
||||||
|
* yard under the same storm as "the other option" is the board lying about
|
||||||
|
* having offered a choice — and it is exactly what a naive index-into-the-pool
|
||||||
|
* produces on the nights whose pairing is in the pool (nights 3 and 4 both are).
|
||||||
|
*/
|
||||||
|
export function eligibleAlternatives(scripted, pool = POOL) {
|
||||||
|
return pool.filter((p) => !(p.storm === scripted.storm && p.site === scripted.site));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonight's alternative — one entry, drawn deterministically.
|
||||||
|
*
|
||||||
|
* @param {object} scripted tonight's authored night (nightAt(index))
|
||||||
|
* @param {number} weekSeed
|
||||||
|
* @param {number} nightIndex
|
||||||
|
*/
|
||||||
|
export function alternativeFor(scripted, weekSeed, nightIndex, pool = POOL) {
|
||||||
|
const eligible = eligibleAlternatives(scripted, pool);
|
||||||
|
if (!eligible.length) return null;
|
||||||
|
const r = rng(seedFor(weekSeed, nightIndex));
|
||||||
|
return eligible[Math.floor(r() * eligible.length)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COLLATERAL EXPOSURE — the risk surface the offer card is required to show.
|
||||||
|
*
|
||||||
|
* SPRINT17 gate 1.2: the offer shows "the risk surface (collateral exposure at
|
||||||
|
* minimum)". This is that number: every priced piece of the client's property
|
||||||
|
* in that yard which YOUR failure can bill you for. Not a score, not a
|
||||||
|
* prediction — an exposure, the way a tradie reads a site before quoting it.
|
||||||
|
*
|
||||||
|
* ⚠️ **THE SECOND-HARNESS SEAM, DECLARED (C's gate 1.2 job).** world.js prices
|
||||||
|
* collateral at FAILURE time by resolving a collateral KEY off the anchor that
|
||||||
|
* let go; this walks the SITE JSON up front and sums what is priced there. Two
|
||||||
|
* routes to one set of dollars, which is precisely the shape this repo has been
|
||||||
|
* bitten by (`collateralFor` resolved the carport for five sprints only because
|
||||||
|
* site_02 happens to id its structure "carport"). They must agree per item or
|
||||||
|
* one of them is wrong: a.test pins the totals against the shipped sites, and
|
||||||
|
* C is crossing it against their envelope on at least one offer. If they ever
|
||||||
|
* disagree, find the variable before tuning either — it will be the harness.
|
||||||
|
*
|
||||||
|
* Reads the same fields world.js's collateralFor prefers (`spec.collateralValue`
|
||||||
|
* first, the GLB extra as fallback and proposal), so the two cannot drift on
|
||||||
|
* price even though they differ on question.
|
||||||
|
*/
|
||||||
|
export function exposureOf(site) {
|
||||||
|
const items = [];
|
||||||
|
const push = (what, cost) => {
|
||||||
|
if (Number.isFinite(cost) && cost > 0) items.push({ what, cost });
|
||||||
|
};
|
||||||
|
if (site?.house) push(site.house.collateralLabel ?? 'the house', site.house.collateralValue);
|
||||||
|
for (const s of site?.structures ?? []) push(s.collateralLabel ?? s.id, s.collateralValue);
|
||||||
|
// Named props, explicitly — there is deliberately no generic `props: []` in a
|
||||||
|
// site (docs/MANUAL.md: every prop is a named top-level key, wired on
|
||||||
|
// purpose), so this list grows by hand when a priced prop is added. That is
|
||||||
|
// the intended friction: a prop that quietly joined an exposure total without
|
||||||
|
// anyone deciding it should is the "free failure" bug wearing a new hat.
|
||||||
|
if (site?.gnome) push(site.gnome.collateralLabel ?? 'garden gnome', site.gnome.collateralValue);
|
||||||
|
const total = items.reduce((s, i) => s + i.cost, 0);
|
||||||
|
return { total, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BUILD THE MORNING'S BOARD — two offers, tonight's scripted night first.
|
||||||
|
*
|
||||||
|
* Order is not cosmetic and is not seeded: the scripted job is ALWAYS offer 0.
|
||||||
|
* The campaign is the thing the player is in the middle of, and a board that
|
||||||
|
* shuffled which door was which would make "take the job you were going to do"
|
||||||
|
* a reading exercise every morning.
|
||||||
|
*
|
||||||
|
* @param {object} o
|
||||||
|
* @param {object} o.scripted tonight's authored night
|
||||||
|
* @param {number} o.weekSeed
|
||||||
|
* @param {number} o.nightIndex 0-based
|
||||||
|
* @param {(night:object) => object} [o.priced] per-offer money/forecast/exposure,
|
||||||
|
* injected by the caller because it needs storm defs and site JSON that this
|
||||||
|
* pure module deliberately does not import. Shape is documented on the offer
|
||||||
|
* typedef in THREADS' seam contract; anything it returns is merged onto the
|
||||||
|
* offer. Absent ⇒ the board still builds (client, site, brief, constraints),
|
||||||
|
* which is what keeps this testable with no server.
|
||||||
|
*/
|
||||||
|
export function offersFor({ scripted, weekSeed, nightIndex, pool = POOL, priced = null }) {
|
||||||
|
const alt = alternativeFor(scripted, weekSeed, nightIndex, pool);
|
||||||
|
const mk = (night, kind, id) => {
|
||||||
|
for (const c of night.constraints ?? []) validateConstraint(c, id);
|
||||||
|
const offer = {
|
||||||
|
id, kind, night,
|
||||||
|
constraints: night.constraints ?? [],
|
||||||
|
premium: premiumFor(night.constraints),
|
||||||
|
};
|
||||||
|
return priced ? Object.assign(offer, priced(night)) : offer;
|
||||||
|
};
|
||||||
|
const offers = [mk(scripted, 'scripted', 'scripted')];
|
||||||
|
if (alt) offers.push(mk(alt, 'callout', `alt:${alt.id}`));
|
||||||
|
return offers;
|
||||||
|
}
|
||||||
@ -89,9 +89,21 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
|
|||||||
* loose-on-grass (0.45), but the deck flexes and the timber cracks before
|
* loose-on-grass (0.45), but the deck flexes and the timber cracks before
|
||||||
* concrete would notice (1.00). One rating for the whole type, per the
|
* concrete would notice (1.00). One rating for the whole type, per the
|
||||||
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
|
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
|
||||||
|
*
|
||||||
|
* `corroded_post` (SPRINT17 gate 3.1, E): a sail post gone rusty — the pool
|
||||||
|
* yard's "corroded cheap hardware" thesis as an anchor word. NOT a `post`,
|
||||||
|
* and this time the word isn't about the footing (the footing is fine —
|
||||||
|
* concrete, same as ever): it's about the STEEL. "post" promises sound
|
||||||
|
* galvanised tube; this is eaten tube whose visible bloom is a floor on the
|
||||||
|
* damage, not a ceiling. rating_hint 0.55 — above the swing frame (footed
|
||||||
|
* beats loose), below the pergola (sound flexing timber beats steel you
|
||||||
|
* can't inspect the inside of). One rating for the whole type, per the
|
||||||
|
* manifest rule. The GLB wears the tell (rust, a sagging pad eye); the type
|
||||||
|
* string is the player's pre-rig read of the same fact.
|
||||||
*/
|
*/
|
||||||
export const ANCHOR_TYPE = Object.freeze([
|
export const ANCHOR_TYPE = Object.freeze([
|
||||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
|
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
|
||||||
|
'corroded_post',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -336,6 +336,33 @@ export const PALETTE = [
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// SPRINT17 gate 3.1, E. The corroded tier — the pool yard's other half.
|
||||||
|
// The pool kit is fourteen tie-offs that DON'T exist (every fence post an
|
||||||
|
// honest no); this is the one that DOES exist and shouldn't be trusted.
|
||||||
|
// 0.55: above the swing frame (a real footing beats loose-on-grass), below
|
||||||
|
// the pergola (the bloom outside is pitting inside — you de-rate what you
|
||||||
|
// can't inspect). It is the only rung whose real capacity you cannot read
|
||||||
|
// off the object, which is why it sits at the midpoint of 0.45→0.65.
|
||||||
|
// ⚠️ `_v1` is LOAD-BEARING and its absence is SILENT: adoptAnchor does
|
||||||
|
// `rating_hint ?? 1`, so a missing model does not fail — it becomes the
|
||||||
|
// BEST STEEL IN THE GAME. A typo here rates the corroded post 1.00 and the
|
||||||
|
// trap inverts into the safest anchor in the yard. (S14, D's cold pass.)
|
||||||
|
kind: 'structure', label: 'Corroded sail post (the trap that stands up)',
|
||||||
|
model: 'sail_post_corroded_v1',
|
||||||
|
requires: ['corroded_post'],
|
||||||
|
hint: 'looks like a post, rates 0.55 — rust at the base and head, and the pad eye has sagged',
|
||||||
|
make: (id, x, z) => ({
|
||||||
|
id, model: 'sail_post_corroded_v1',
|
||||||
|
wreckedModel: 'sail_post_corroded_wrecked_v1',
|
||||||
|
x, z, rotYDeg: 0, solid: true,
|
||||||
|
collateralKey: 'corroded_post',
|
||||||
|
collateralValue: 45, collateralLabel: 'the corroded post',
|
||||||
|
anchors: [
|
||||||
|
{ id: `${id}_a1`, node: 'top_anchor', type: 'corroded_post', work: 'cloth' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
|
// The collateral apex. NO anchors, on purpose — nothing about a glasshouse
|
||||||
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is
|
// may adopt (the ridge carries tie_off:false in the GLB). collateralKey is
|
||||||
|
|||||||
@ -305,6 +305,49 @@ const CSS = `
|
|||||||
}
|
}
|
||||||
/* ---- end of E's kit ----------------------------------------------------- */
|
/* ---- end of E's kit ----------------------------------------------------- */
|
||||||
|
|
||||||
|
/* --- SPRINT17 gate 1: THE JOB BOARD ------------------------------------
|
||||||
|
A new card in the paper chain, and it has to meet the invoice's standard —
|
||||||
|
so it is built out of the kit rather than beside it: same letterhead, same
|
||||||
|
.row/.cond rhythm in the offer body, same terms line at the foot.
|
||||||
|
|
||||||
|
The one NEW idea it needs is that an offer is a THING YOU PICK, so each one
|
||||||
|
is a button (the .storm affordance the old storm-select card used, widened
|
||||||
|
into a panel). Two offers, stacked, tonight's scripted job first — see
|
||||||
|
board.js offersFor() for why that order is fixed and not seeded.
|
||||||
|
|
||||||
|
The constrained offer gets a visible mark, because gate 2's whole point is
|
||||||
|
that taking a constrained job is a CHOICE: an amber left border and the
|
||||||
|
client's own words in italic. A player must not be able to take one without
|
||||||
|
having been told, and "it was in the fee" is not being told. */
|
||||||
|
#hud-card .offer { display:block; width:100%; text-align:left; margin:9px 0;
|
||||||
|
padding:13px 15px; background:#121c23; border:1px solid #2f3f4a; border-radius:7px;
|
||||||
|
color:#dde5ea; cursor:pointer; font:inherit;
|
||||||
|
transition:border-color .15s, background .15s; }
|
||||||
|
#hud-card .offer:hover { border-color:#7ee0ff; background:#16242c; }
|
||||||
|
#hud-card .offer .who { font-weight:700; color:#fff; letter-spacing:.04em; }
|
||||||
|
#hud-card .offer .addr { font-size:11px; color:#6d818e; margin-bottom:7px; }
|
||||||
|
#hud-card .offer .tag { float:right; font-size:10px; letter-spacing:.14em;
|
||||||
|
color:#55707f; text-transform:uppercase; }
|
||||||
|
#hud-card .offer .line { display:flex; justify-content:space-between; gap:14px;
|
||||||
|
font-size:12px; color:#8ba0ad; padding:2px 0; }
|
||||||
|
#hud-card .offer .line b { color:#dde5ea; font-weight:700; }
|
||||||
|
/* SPRINT17 [C]: the weather band. The hail line is lit brighter than the rest
|
||||||
|
because the soaker's whole trap is that its wind reads gentle — the one line
|
||||||
|
a player must not skim past. */
|
||||||
|
#hud-card .offer .line.wx { color:#8ba0ad; }
|
||||||
|
#hud-card .offer .line.hail span { color:#cbd8e0; }
|
||||||
|
/* The two numbers a tradie decides on. The fee is the reason to say yes and
|
||||||
|
the exposure is the reason to say no, so they are weighted against each
|
||||||
|
other rather than both being grey: money in, money at risk. */
|
||||||
|
#hud-card .offer .fee b { color:#ffd9a3; }
|
||||||
|
#hud-card .offer .risk b { color:#ff8f86; }
|
||||||
|
#hud-card .offer.constrained { border-left:3px solid #ffc24a; }
|
||||||
|
#hud-card .offer .says { margin-top:8px; padding-left:9px; border-left:2px solid #3f5561;
|
||||||
|
font-size:11px; font-style:italic; color:#c8b78a; line-height:1.5; }
|
||||||
|
#hud-card .offer .cons { font-size:10px; letter-spacing:.12em; text-transform:uppercase;
|
||||||
|
color:#ffc24a; margin-top:7px; }
|
||||||
|
#hud-card .board-note { margin-top:12px; font-size:11px; color:#6d8494; }
|
||||||
|
|
||||||
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers.
|
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers.
|
||||||
(Mine, not E's — the kit predates the tomorrow line and doesn't restyle it.) */
|
(Mine, not E's — the kit predates the tomorrow line and doesn't restyle it.) */
|
||||||
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
|
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
|
||||||
@ -734,6 +777,102 @@ export function createHud(d) {
|
|||||||
|
|
||||||
// --- cards ------------------------------------------------------------
|
// --- cards ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPRINT17 gate 1 — THE JOB BOARD. The morning, before the job sheet.
|
||||||
|
*
|
||||||
|
* ROADMAP arc 2's opening move: *"mornings offer N callouts, you take one.
|
||||||
|
* Pay, risk, client and site visible before you commit."* Two offers,
|
||||||
|
* tonight's scripted night first, and taking either one makes it the night.
|
||||||
|
*
|
||||||
|
* **What an offer card shows, and why it is exactly this list** — SPRINT17
|
||||||
|
* gate 1.2 asks for "what a tradie would demand before saying yes", which
|
||||||
|
* is not a feature list, it's a job interview:
|
||||||
|
* · WHO and WHERE — the client and the address, off the same night data
|
||||||
|
* the letterhead bills. A job you can't name is a job you don't take.
|
||||||
|
* · WHAT THE WEATHER IS DOING — C's honest forecast band at lead 0, the
|
||||||
|
* same call the job sheet makes, so the board cannot be more confident
|
||||||
|
* than the sheet it leads to.
|
||||||
|
* · THE FEE — what you're paid, premium included, priced through the
|
||||||
|
* SAME calloutFee() the sheet quotes and the invoice pays.
|
||||||
|
* · THE RISK — collateral exposure: what of theirs your failure can
|
||||||
|
* bill you for. This is the number that makes the choice a choice; a
|
||||||
|
* board that showed only the fee would be advertising, not quoting.
|
||||||
|
* · THE CLIENT'S CONSTRAINTS, in the client's own words, marked — so
|
||||||
|
* taking a constrained job is a decision and never a surprise.
|
||||||
|
*
|
||||||
|
* The pricing is injected (main.js owns the storm defs and site JSON);
|
||||||
|
* anything absent simply doesn't render, which is the same degradation rule
|
||||||
|
* the job sheet's forecast lines follow. The card works with nothing but
|
||||||
|
* client/site/brief, and that is what lets it be tested without a server.
|
||||||
|
*
|
||||||
|
* @param {object[]} offers board.js offersFor(), already priced
|
||||||
|
* @param {object} wk {night, nights, bank, log, rep}
|
||||||
|
* @param {(offer:object) => void} onTake
|
||||||
|
*/
|
||||||
|
showBoard(offers, wk, onTake) {
|
||||||
|
const pips = Array.from({ length: wk.nights }, (_, i) => {
|
||||||
|
const done = i < wk.night - 1;
|
||||||
|
const now = i === wk.night - 1;
|
||||||
|
const held = done && wk.log?.[i]?.won;
|
||||||
|
return `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
|
||||||
|
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const offerHtml = (o, i) => {
|
||||||
|
const n = o.night;
|
||||||
|
const cons = o.constraints ?? [];
|
||||||
|
// SPRINT17 [C], applied by the integrator: the offer maps C's ORDERED
|
||||||
|
// band (wind · hail · rain · change · confidence) instead of picking
|
||||||
|
// two forecast fields, so an offer card can never hide a night's hail.
|
||||||
|
// The .hail line is lit brighter because it is the one a player must
|
||||||
|
// not skim past — the soaker's whole trap is a gentle-reading wind.
|
||||||
|
return `
|
||||||
|
<button class="offer${cons.length ? ' constrained' : ''}" data-offer="${i}">
|
||||||
|
<span class="tag">${o.kind === 'scripted' ? 'tonight, as booked' : 'callout'}</span>
|
||||||
|
<div class="who">${n.client ?? 'no client'}</div>
|
||||||
|
<div class="addr">${n.addr ?? ''}</div>
|
||||||
|
${(o.band ?? []).map((l) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).join('')}
|
||||||
|
${o.fee != null ? `<div class="line fee"><span>the job pays${
|
||||||
|
o.premium ? ` · +${Math.round(o.premium * 100)}% on their terms` : ''
|
||||||
|
}</span><b>$${o.fee}</b></div>` : ''}
|
||||||
|
${o.exposure != null ? `<div class="line risk"><span>their property at risk${
|
||||||
|
o.exposureItems?.length ? ` — ${o.exposureItems.map((x) => x.what).join(', ')}` : ''
|
||||||
|
}</span><b>$${o.exposure}</b></div>` : ''}
|
||||||
|
${cons.length ? `<div class="cons">⚖ ${cons.map((c) => c.label).join(' · ')}</div>` : ''}
|
||||||
|
${cons.map((c) => `<div class="says">“${c.says}”</div>`).join('')}
|
||||||
|
</button>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
card.innerHTML = `<div class="card jobcard">
|
||||||
|
<div class="letterhead">
|
||||||
|
<div>
|
||||||
|
<div class="mark">${BUSINESS.mark}</div>
|
||||||
|
<div class="trade">${BUSINESS.trade}</div>
|
||||||
|
</div>
|
||||||
|
<div class="docket">
|
||||||
|
<div class="kind">THE BOARD</div>
|
||||||
|
<div class="no">MORNING · NIGHT ${wk.night} OF ${wk.nights}</div>
|
||||||
|
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1>TWO JOBS ON OFFER</h1>
|
||||||
|
<div class="pips">${pips}</div>
|
||||||
|
<h2>Take one. The other goes to somebody else.</h2>
|
||||||
|
${offers.map(offerHtml).join('')}
|
||||||
|
<div class="row" style="margin-top:12px"><span>in the bank</span><b>$${wk.bank}</b></div>
|
||||||
|
<div class="board-note">Both are tonight. You cannot work two yards in one storm.</div>
|
||||||
|
<div class="terms">Fees quoted on the forecast at time of callout. Weather is not a variation.</div>
|
||||||
|
</div>`;
|
||||||
|
card.classList.add('on');
|
||||||
|
hud.setVisible(false);
|
||||||
|
for (const el of card.querySelectorAll('.offer')) {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
hud.hideCard();
|
||||||
|
onTake(offers[Number(el.dataset.offer)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The forecast. Its job is to sell the dread and, incidentally, to be the
|
* The forecast. Its job is to sell the dread and, incidentally, to be the
|
||||||
* difficulty select for free — three authored storms already bracket the
|
* difficulty select for free — three authored storms already bracket the
|
||||||
@ -797,6 +936,23 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
|||||||
const brief = job.brief
|
const brief = job.brief
|
||||||
? `<div class="brief">${job.brief}</div>` : '';
|
? `<div class="brief">${job.brief}</div>` : '';
|
||||||
|
|
||||||
|
// SPRINT17 gate 2 — THE CLIENT'S TERMS, on the sheet, in their words.
|
||||||
|
// Under the brief and above the weather on purpose: the constraint is
|
||||||
|
// part of what they're asking for, not a footnote to it, and the player
|
||||||
|
// has to have read it BEFORE the schedule tells them what it pays. The
|
||||||
|
// invoice repeats this block verbatim off the settlement (a constraint
|
||||||
|
// the invoice forgot would be the paper losing the terms it was paid
|
||||||
|
// under, which is gate 2's specific requirement).
|
||||||
|
const cons = job.constraints ?? [];
|
||||||
|
const constraintBlock = cons.length ? `
|
||||||
|
<div class="sect">THE CLIENT'S TERMS</div>
|
||||||
|
<div class="sched">
|
||||||
|
${cons.map((c) => `
|
||||||
|
<div class="row"><span>${c.label}</span><b>${
|
||||||
|
c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}</b></div>
|
||||||
|
<div class="cond">“${c.says}”</div>`).join('')}
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
// BUDGET Y — and this is the actual feature, not the flavour. The fee has
|
// BUDGET Y — and this is the actual feature, not the flavour. The fee has
|
||||||
// existed since Sprint 8 and the player has never seen it until the money
|
// existed since Sprint 8 and the player has never seen it until the money
|
||||||
// was already spent: you decided what to rig without knowing what the job
|
// was already spent: you decided what to rig without knowing what the job
|
||||||
@ -872,6 +1028,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
|||||||
card.innerHTML = `<div class="card jobcard">
|
card.innerHTML = `<div class="card jobcard">
|
||||||
${letterhead}
|
${letterhead}
|
||||||
${brief}
|
${brief}
|
||||||
|
${constraintBlock}
|
||||||
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
|
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
|
||||||
<div class="pips">${pips}</div>
|
<div class="pips">${pips}</div>
|
||||||
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
|
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
|
||||||
@ -1033,7 +1190,13 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
|||||||
const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : '';
|
const invFeeTag = w && w.feeMult && w.feeMult !== 1 ? ` · standing ×${w.feeMult.toFixed(2)}` : '';
|
||||||
const money = w ? `
|
const money = w ? `
|
||||||
<div class="ledger">
|
<div class="ledger">
|
||||||
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}</span><b>+$${w.fee}</b></div>
|
<div class="row"><span>${w.won ? 'callout fee' : `callout fee — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}${invFeeTag}${
|
||||||
|
// SPRINT17 gate 2 — the fee row names the premium it was paid at.
|
||||||
|
// The constraint moved this number, so the row that shows the
|
||||||
|
// number is where it has to be said; a premium that only appeared
|
||||||
|
// in the total would be money with no reason printed next to it.
|
||||||
|
w.premium ? ` · +${Math.round(w.premium * 100)}% on their terms` : ''
|
||||||
|
}</span><b>+$${w.fee}</b></div>
|
||||||
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed${
|
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed${
|
||||||
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
|
// SPRINT14 — B's pool item, the invoice half. On a beyond-saving
|
||||||
// night this row is always small, and next to "callout fee — night
|
// night this row is always small, and next to "callout fee — night
|
||||||
@ -1083,6 +1246,21 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
|||||||
<h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1>
|
<h1>MORNING${w ? ` · NIGHT ${w.night} OF ${w.nights ?? NIGHT_COUNT}` : ''}</h1>
|
||||||
<h2>${r.subtitle}</h2>
|
<h2>${r.subtitle}</h2>
|
||||||
${rows}
|
${rows}
|
||||||
|
${/* SPRINT17 gate 2 — THE INVOICE REPEATS THE CONSTRAINT IT WAS PAID
|
||||||
|
UNDER. The spec's own requirement, and it earns its place: the
|
||||||
|
invoice is the record, and a record of a constrained job that
|
||||||
|
doesn't say it was constrained has lost the one term that shaped
|
||||||
|
every decision on it. Off the settlement (week.js carries
|
||||||
|
s.constraints), so it stays true when you re-read night 3's
|
||||||
|
invoice on night 5's morning. */''}
|
||||||
|
${w?.constraints?.length ? `
|
||||||
|
<div class="sect">PAID UNDER THESE TERMS</div>
|
||||||
|
<div class="sched">
|
||||||
|
${w.constraints.map((c) => `
|
||||||
|
<div class="row"><span>${c.label}</span><b>${
|
||||||
|
c.kind === 'budgetCap' ? `$${c.cap} cap` : 'refused'}</b></div>
|
||||||
|
<div class="cond">“${c.says}”</div>`).join('')}
|
||||||
|
</div>` : ''}
|
||||||
${money}
|
${money}
|
||||||
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
|
<div class="verdict ${r.win ? 'win' : 'lose'}">${r.verdict}</div>
|
||||||
${/* SPRINT16 — the verdict speaks rep: the movement AND its reason, so
|
${/* SPRINT16 — the verdict speaks rep: the movement AND its reason, so
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import * as THREE from '../vendor/three.module.js';
|
|||||||
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
|
import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './contracts.js';
|
||||||
import { createWorld, loadSite } from './world.js';
|
import { createWorld, loadSite } from './world.js';
|
||||||
import { createCameraRig, spawnYawFor } from './camera.js';
|
import { createCameraRig, spawnYawFor } from './camera.js';
|
||||||
import { loadStorm, createWind } from './weather.js';
|
import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';
|
||||||
import { SailRig, createSailView } from './sail.js';
|
import { SailRig, createSailView } from './sail.js';
|
||||||
import { createPlayer } from './player.js';
|
import { createPlayer } from './player.js';
|
||||||
import { Interact, wireYardActions } from './interact.js';
|
import { Interact, wireYardActions } from './interact.js';
|
||||||
@ -25,7 +25,8 @@ import { createDebris } from './debris.js';
|
|||||||
import { createSkyFx } from './skyfx.js';
|
import { createSkyFx } from './skyfx.js';
|
||||||
import { createRiggingUI, fabricNoteFor } from './rigging.js';
|
import { createRiggingUI, fabricNoteFor } from './rigging.js';
|
||||||
import { createHud } from './hud.js';
|
import { createHud } from './hud.js';
|
||||||
import { createWeek, NIGHTS, nightAt } from './week.js';
|
import { calloutFee, createWeek, NIGHTS, nightAt } from './week.js';
|
||||||
|
import { POOL, exposureOf } from './board.js';
|
||||||
import { createGarden } from './garden.js';
|
import { createGarden } from './garden.js';
|
||||||
|
|
||||||
/** The calm day the forecast and prep phases run under. */
|
/** The calm day the forecast and prep phases run under. */
|
||||||
@ -52,10 +53,32 @@ export const CALM_STORM = 'storm_01_gentle';
|
|||||||
* there — night one is gentle today, so the bug hides. That assert would be
|
* there — night one is gentle today, so the bug hides. That assert would be
|
||||||
* decoration, which is the exact failure mode this repo keeps naming.
|
* decoration, which is the exact failure mode this repo keeps naming.
|
||||||
*
|
*
|
||||||
* @param {string[]} [nights] storm keys, defaults to the shipped ladder
|
* ⚠️ **SPRINT17 — THE POOL JOINS THE LADDER HERE, and this is the same landmine
|
||||||
|
* in a new costume.** The board can put ANY pool night on tonight, so a pool
|
||||||
|
* storm that isn't preloaded is `defs[week.stormKey] === undefined` the moment
|
||||||
|
* a player takes that offer — which is D's calm-day bug exactly: an invariant
|
||||||
|
* ("every storm the game can run is loaded") that used to hold by coincidence,
|
||||||
|
* because NIGHTS was the only source of nights. It isn't anymore. Both sources
|
||||||
|
* are named here, deduped, and a.test pins that a pool-only storm survives the
|
||||||
|
* list.
|
||||||
|
*
|
||||||
|
* ⚠️ **TWO PARAMETERS, NOT ONE CONCATENATED DEFAULT — and this assert had to be
|
||||||
|
* rescued from being decoration, by mutation-checking rather than by reading
|
||||||
|
* it.** The first cut took a single `nights` list defaulting to NIGHTS+POOL,
|
||||||
|
* and a.test asserted "every pool storm is preloaded". That assert passed with
|
||||||
|
* the POOL source deleted outright, because every storm the pool flies today
|
||||||
|
* also appears in NIGHTS — the same coincidence, in the same function, that hid
|
||||||
|
* the calm-day bug for a sprint. Split, the composition is testable against a
|
||||||
|
* pool storm that is NOT in the ladder — the case that would actually break.
|
||||||
|
*
|
||||||
|
* @param {string[]} [nights] storm keys from the ladder
|
||||||
|
* @param {string[]} [pool] storm keys the BOARD can put on tonight
|
||||||
*/
|
*/
|
||||||
export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm)) {
|
export function stormsToPreload(
|
||||||
return [...new Set([CALM_STORM, ...nights])];
|
nights = NIGHTS.map((_, i) => nightAt(i).storm),
|
||||||
|
pool = POOL.map((p) => p.storm),
|
||||||
|
) {
|
||||||
|
return [...new Set([CALM_STORM, ...nights, ...pool])];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORMS = stormsToPreload();
|
const STORMS = stormsToPreload();
|
||||||
@ -979,6 +1002,78 @@ export async function boot(opts = {}) {
|
|||||||
* anything. Lane B's file, flagged in THREADS — main.js therefore tracks
|
* anything. Lane B's file, flagged in THREADS — main.js therefore tracks
|
||||||
* `spentThisNight` itself off the bank rather than trusting `.spent`.
|
* `spentThisNight` itself off the bank rather than trusting `.spent`.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* SPRINT17 gate 1 — THE MORNING: the board, then the job sheet.
|
||||||
|
*
|
||||||
|
* Site JSON is needed for the exposure number on an offer whose yard we have
|
||||||
|
* NOT loaded (and must not load — loading it would rebuild the world behind
|
||||||
|
* the card for a job the player might not take). `loadSite` is a small JSON
|
||||||
|
* fetch with no GLBs, cached here so a week of boards costs three requests.
|
||||||
|
*
|
||||||
|
* The cache is keyed on site name and never invalidated on purpose: a site
|
||||||
|
* JSON cannot change inside a run, and a stale-cache bug on a number the
|
||||||
|
* player makes money decisions from would be worth more than the three
|
||||||
|
* requests it saves.
|
||||||
|
* @type {Map<string, object>}
|
||||||
|
*/
|
||||||
|
const siteJson = new Map();
|
||||||
|
async function siteJsonFor(name) {
|
||||||
|
if (!siteJson.has(name)) siteJson.set(name, await loadSite(name));
|
||||||
|
return siteJson.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price every offer on this morning's board.
|
||||||
|
*
|
||||||
|
* The fee comes from `week.quote()` on a WEEK VIEW OF THAT NIGHT, not from a
|
||||||
|
* formula retyped here — so the number on the offer card is the number the
|
||||||
|
* job sheet quotes and the invoice pays, by construction rather than by two
|
||||||
|
* developers agreeing. (The board showing $68 and the sheet then saying $57
|
||||||
|
* would be the quote-vs-settle lie moved one card earlier, which is exactly
|
||||||
|
* the class of bug the ledger's derivation was built to make impossible.)
|
||||||
|
*/
|
||||||
|
async function pricedOffers() {
|
||||||
|
for (const o of week.offers()) await siteJsonFor(o.night.site);
|
||||||
|
return week.offers((night) => {
|
||||||
|
const def = defs[night.storm];
|
||||||
|
const exp = exposureOf(siteJson.get(night.site));
|
||||||
|
return {
|
||||||
|
// C's honest lines at lead 0 — tonight, exact. The same call the job
|
||||||
|
// sheet makes, so the board can never be more confident than the sheet.
|
||||||
|
forecast: def ? forecastLines(def, 0) : null,
|
||||||
|
// SPRINT17 [C], applied by the integrator: the ORDERED band the offer
|
||||||
|
// card maps, so a night's hail can never be dropped by the card picking
|
||||||
|
// two fields. Night 7 read softer than the buster on wind+stones alone
|
||||||
|
// (55 vs 76 km/h) while carrying 6× the hail — the board recommending
|
||||||
|
// its own trap. offerBand carries every fact the storm has.
|
||||||
|
band: def ? offerBand(def, 0) : null,
|
||||||
|
fee: def ? calloutFee(def, week.rep, night.constraints) : null,
|
||||||
|
exposure: exp.total,
|
||||||
|
exposureItems: exp.items,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The morning, in order: the board offers two jobs, the player takes one, and
|
||||||
|
* ONLY THEN does the world load — because which yard to build is the thing
|
||||||
|
* they just decided. Everything downstream (`showTonight`) is untouched: it
|
||||||
|
* reads `week.site` / `week.job` / `week.quote()` exactly as it did when the
|
||||||
|
* week was a script, which is the whole point of installing the taken job as
|
||||||
|
* the night rather than bolting a parallel concept beside it.
|
||||||
|
*/
|
||||||
|
function showMorning() {
|
||||||
|
return pricedOffers().then((offers) => new Promise((resolve) => {
|
||||||
|
hud.showBoard(offers, {
|
||||||
|
night: week.night, nights: week.nights, bank: week.bank,
|
||||||
|
log: week.log, rep: week.rep,
|
||||||
|
}, (offer) => {
|
||||||
|
week.take(offer);
|
||||||
|
showTonight().then(resolve);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function showTonight() {
|
async function showTonight() {
|
||||||
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
|
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
|
||||||
// (the four backyard nights) and rebuilds + re-points everything holding the
|
// (the four backyard nights) and rebuilds + re-points everything holding the
|
||||||
@ -990,6 +1085,21 @@ export async function boot(opts = {}) {
|
|||||||
// main.js's one private touch into their session (`_startBudget` + reset(),
|
// main.js's one private touch into their session (`_startBudget` + reset(),
|
||||||
// asked in THREADS, landed, fake deleted — same route reset() took).
|
// asked in THREADS, landed, fake deleted — same route reset() took).
|
||||||
rigging.session.setBudget(week.bank);
|
rigging.session.setBudget(week.bank);
|
||||||
|
// SPRINT17 gate 2 — B's filed wiring line, landed at the boundary B named
|
||||||
|
// (the setBudget pattern: the night's terms arrive with the night's bank).
|
||||||
|
// Runs AFTER week.take(), so these are the CHOSEN job's constraints — a
|
||||||
|
// callout's client speaks here, not the spine's. The `?? []` is
|
||||||
|
// load-bearing and B's entry says why: setConstraints survives the
|
||||||
|
// session's reset() (so "play again" replays the same client), which makes
|
||||||
|
// the empty array the ONLY thing that clears the previous client's terms —
|
||||||
|
// drop it and the Vasilaros cap follows you to the Hendersons'.
|
||||||
|
// `?.` because lane/b owns the method and it lands at integration; until
|
||||||
|
// then this is a documented no-op (the D lesson: a `?.()` LOOKS like a
|
||||||
|
// call) and constraints stay paper-only, priced but unenforced — the seam
|
||||||
|
// contract's own interim state. Integrator: after merge, take a
|
||||||
|
// constrained offer and watch the session refuse in the ticker; if it
|
||||||
|
// doesn't, this line is the first suspect.
|
||||||
|
rigging.session.setConstraints?.(week.job?.constraints ?? []);
|
||||||
spentThisNight = 0;
|
spentThisNight = 0;
|
||||||
// SPRINT11 — the job sheet reads three new things off the week: tonight's
|
// SPRINT11 — the job sheet reads three new things off the week: tonight's
|
||||||
// JOB (client + brief), the QUOTE (what it pays, before you rig it, which is
|
// JOB (client + brief), the QUOTE (what it pays, before you rig it, which is
|
||||||
@ -1073,7 +1183,11 @@ export async function boot(opts = {}) {
|
|||||||
// on the new world/player, not the old one mid-swap. This is the only
|
// on the new world/player, not the old one mid-swap. This is the only
|
||||||
// phase transition that can rebuild the scene, so it's the only one that
|
// phase transition that can rebuild the scene, so it's the only one that
|
||||||
// has to sequence like this.
|
// has to sequence like this.
|
||||||
showTonight().then(() => {
|
// SPRINT17: the board comes first, and the world rebuild rides the PICK
|
||||||
|
// rather than the phase change — the yard to build is not known until
|
||||||
|
// the player has chosen a job. The resets still land after the rebuild
|
||||||
|
// (same .then sequencing, one card earlier in the chain).
|
||||||
|
showMorning().then(() => {
|
||||||
garden.reset();
|
garden.reset();
|
||||||
resetRig();
|
resetRig();
|
||||||
player.sim.carrying = null;
|
player.sim.carrying = null;
|
||||||
@ -1176,13 +1290,28 @@ export async function boot(opts = {}) {
|
|||||||
// `opts.splash === false` skips it — the selftest, the dev benches and D's
|
// `opts.splash === false` skips it — the selftest, the dev benches and D's
|
||||||
// playtest harness all boot straight into a night, and none of them should
|
// playtest harness all boot straight into a night, and none of them should
|
||||||
// have to click through a door.
|
// have to click through a door.
|
||||||
|
//
|
||||||
|
// ⚠️ SPRINT17 — THE FIRST MORNING GETS A BOARD TOO, and for an hour it did
|
||||||
|
// not. The board hangs off the forecast PHASE CHANGE, and night one never
|
||||||
|
// has one: the game boots already IN 'forecast', so this line is the only
|
||||||
|
// route into the first morning. Going through showTonight() made night 1 the
|
||||||
|
// one night of the week with no choice on it. Found by LOOKING at the running
|
||||||
|
// game, not by a test — every board assert operates on week.offers() and all
|
||||||
|
// seven mornings were correct; the CARD CHAIN was wrong at the single join no
|
||||||
|
// assert was watching. (Both card bugs in this repo's history were invisible
|
||||||
|
// to a green suite. This is the third, and it says the same thing.)
|
||||||
|
//
|
||||||
|
// `splash:false` still goes straight to the scripted night, and that is the
|
||||||
|
// documented harness door rather than an oversight: a bench that asked for a
|
||||||
|
// night wants the night, not a card waiting to be clicked. D — your playtest
|
||||||
|
// harness is unchanged; the REAL path is the one with the board on it.
|
||||||
hud.setAudioMuteAvailable(typeof sky?.setMute === 'function');
|
hud.setAudioMuteAvailable(typeof sky?.setMute === 'function');
|
||||||
if (!canPlayHere()) {
|
if (!canPlayHere()) {
|
||||||
hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice
|
hud.showTouchNotice(); // no way through, on purpose: see hud.showTouchNotice
|
||||||
} else if (opts.splash === false) {
|
} else if (opts.splash === false) {
|
||||||
showTonight();
|
showTonight();
|
||||||
} else {
|
} else {
|
||||||
hud.showSplash(() => showTonight());
|
hud.showSplash(() => showMorning());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- resize -------------------------------------------------------------
|
// --- resize -------------------------------------------------------------
|
||||||
|
|||||||
@ -20,7 +20,12 @@ import { RiggingSession } from '../rigging.js';
|
|||||||
import { createSkyFx } from '../skyfx.js';
|
import { createSkyFx } from '../skyfx.js';
|
||||||
import { SailRig, orderRing } from '../sail.js';
|
import { SailRig, orderRing } from '../sail.js';
|
||||||
import { loadStorm, createWind } from '../weather.js';
|
import { loadStorm, createWind } from '../weather.js';
|
||||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY, REP } from '../week.js';
|
import {
|
||||||
|
BROKE_BELOW, PAY, REP, calloutFee, createWeek, gradeFor, nightAt, NIGHTS,
|
||||||
|
} from '../week.js';
|
||||||
|
// SPRINT17 gate 1/2 — the board and the constraint data are mine; these are
|
||||||
|
// the seam this suite pins (offer shape, pool, exposure, constraint enum).
|
||||||
|
import { CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint } from '../board.js';
|
||||||
import { createHud } from '../hud.js';
|
import { createHud } from '../hud.js';
|
||||||
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
|
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
|
||||||
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||||
@ -291,11 +296,15 @@ export default async function run(t) {
|
|||||||
// ladder WITHOUT the gentle storm — the case that actually broke. Checked
|
// ladder WITHOUT the gentle storm — the case that actually broke. Checked
|
||||||
// against the shipped NIGHTS it would pass either way (night one is gentle
|
// against the shipped NIGHTS it would pass either way (night one is gentle
|
||||||
// today), which is how the bug hid in the first place.
|
// today), which is how the bug hid in the first place.
|
||||||
|
// SPRINT17: stormsToPreload takes the LADDER and the POOL separately now
|
||||||
|
// (see its docstring — the single-list version made the pool assert
|
||||||
|
// decoration). These pass an empty pool so this pin keeps testing exactly
|
||||||
|
// what it was written to test: the calm day's survival, ladder-only.
|
||||||
assert(stormsToPreload().includes(CALM_STORM),
|
assert(stormsToPreload().includes(CALM_STORM),
|
||||||
'the calm day is preloaded on the shipped ladder');
|
'the calm day is preloaded on the shipped ladder');
|
||||||
assert(stormsToPreload(['storm_02_wildnight']).includes(CALM_STORM),
|
assert(stormsToPreload(['storm_02_wildnight'], []).includes(CALM_STORM),
|
||||||
"...and on a ladder that doesn't contain it anywhere — the case that broke");
|
"...and on a ladder that doesn't contain it anywhere — the case that broke");
|
||||||
assertEq(stormsToPreload(['storm_01_gentle']).length, 1,
|
assertEq(stormsToPreload(['storm_01_gentle'], []).length, 1,
|
||||||
'and it is still deduped, not loaded twice');
|
'and it is still deduped, not loaded twice');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -424,6 +433,271 @@ export default async function run(t) {
|
|||||||
'the second-to-last night is not the final night — going broke there ends the run');
|
'the second-to-last night is not the final night — going broke there ends the run');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- SPRINT17 gate 1: THE JOB BOARD -------------------------------------
|
||||||
|
// Loaded up front — Suite.test() cannot await, and every board assert wants a
|
||||||
|
// real storm def to price a fee against.
|
||||||
|
const boardDefs = {};
|
||||||
|
for (const k of ['storm_01_gentle', 'storm_03_southerly', 'storm_03b_earlybuster']) {
|
||||||
|
try { boardDefs[k] = await loadStorm(k); } catch { /* no server: asserts below degrade */ }
|
||||||
|
}
|
||||||
|
// The raw site JSON, for the exposure pins — deliberately NOT a built world:
|
||||||
|
// exposureOf() answers "what is priced in this yard" off data a board can read
|
||||||
|
// without building anything, which is the whole reason it can price an offer
|
||||||
|
// for a yard the player has not chosen yet.
|
||||||
|
const siteJsonForTest = {};
|
||||||
|
for (const k of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn']) {
|
||||||
|
try { siteJsonForTest[k] = await loadSite(k); } catch { /* degrades to SKIPPED */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
t.test('SPRINT17: every morning offers TWO jobs — tonight, plus one alternative', () => {
|
||||||
|
const w = createWeek();
|
||||||
|
for (let i = 0; i < NIGHTS.length; i++) {
|
||||||
|
const offers = w.offers();
|
||||||
|
assertEq(offers.length, 2, `night ${i + 1} offers two jobs`);
|
||||||
|
// The scripted spine SURVIVES, and it is always offer 0 — the campaign is
|
||||||
|
// the thing the player is in the middle of, and a board that shuffled
|
||||||
|
// which door was which would make "take the job you were going to do" a
|
||||||
|
// reading exercise every morning.
|
||||||
|
assertEq(offers[0].kind, 'scripted');
|
||||||
|
assertEq(offers[0].night.storm, nightAt(i).storm, `night ${i + 1}'s scripted offer IS the ladder's night`);
|
||||||
|
assertEq(offers[0].night.site, nightAt(i).site);
|
||||||
|
assertEq(offers[1].kind, 'callout', 'the second is drawn from the pool');
|
||||||
|
// An alternative that is the same yard under the same storm is not an
|
||||||
|
// alternative — it is the board lying about having offered a choice.
|
||||||
|
assert(!(offers[1].night.storm === offers[0].night.storm
|
||||||
|
&& offers[1].night.site === offers[0].night.site),
|
||||||
|
`night ${i + 1}'s alternative must be a DIFFERENT job, got ${offers[1].night.site} × ${offers[1].night.storm}`);
|
||||||
|
w.advance();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('SPRINT17: the board is DETERMINISTIC — same week, same offers, always', () => {
|
||||||
|
// The repo's oldest rule (contracts.js §Determinism) and the selftest
|
||||||
|
// depends on it: a pool that drew from Date.now or Math.random is a suite
|
||||||
|
// that goes red on a Tuesday. Three independent weeks, walked in full.
|
||||||
|
const walk = (seed) => {
|
||||||
|
const w = createWeek(seed == null ? {} : { seed });
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < NIGHTS.length; i++) { out.push(w.offers().map((o) => o.id).join('|')); w.advance(); }
|
||||||
|
return out.join(' ; ');
|
||||||
|
};
|
||||||
|
const a = walk(), b = walk(), c = walk();
|
||||||
|
assertEq(a, b, 'two fresh weeks must offer the same board');
|
||||||
|
assertEq(b, c, '...and a third');
|
||||||
|
// And the seed is REAL — a different week is a different board, or the
|
||||||
|
// "deterministic" pin above is passing on a constant and proving nothing.
|
||||||
|
// (This is the negative control for the assert above it: without it, an
|
||||||
|
// offersFor() that ignored the seed entirely and always returned POOL[0]
|
||||||
|
// would sail through the equality checks.)
|
||||||
|
const other = walk(7);
|
||||||
|
assert(other !== a, 'a different seed must draw a different week of offers');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('SPRINT17: TAKE ONE — the chosen job IS the night, all the way to the invoice', () => {
|
||||||
|
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
|
||||||
|
const w = createWeek();
|
||||||
|
const offers = w.offers();
|
||||||
|
const alt = offers[1];
|
||||||
|
// Precondition: the alternative really is a different job from the spine,
|
||||||
|
// or this test proves nothing about taking one.
|
||||||
|
assert(alt.night.site !== nightAt(0).site || alt.night.storm !== nightAt(0).storm,
|
||||||
|
'precondition: the alternative differs from night 1');
|
||||||
|
|
||||||
|
w.take(alt);
|
||||||
|
assertEq(w.site, alt.night.site, 'week.site follows the taken job');
|
||||||
|
assertEq(w.stormKey, alt.night.storm, 'and so does the storm');
|
||||||
|
assertEq(w.job.client, alt.night.client, 'and the client the letterhead bills');
|
||||||
|
assertEq(w.takenOffer, alt.id, 'the week records WHICH offer was taken');
|
||||||
|
|
||||||
|
// THE ASSERT GATE 1 ASKS FOR IN SO MANY WORDS: "a chosen alternative that
|
||||||
|
// skips the ledger is the board lying." Every per-night surface must work
|
||||||
|
// UNCHANGED on a chosen night — so run the whole paper chain over one.
|
||||||
|
const def = boardDefs[alt.night.storm];
|
||||||
|
const q = w.quote(def);
|
||||||
|
assert(q.base > 0, 'a taken job quotes a fee');
|
||||||
|
assertEq(q.total, q.base + q.garden + q.clean - q.warrantyTotal, 'the quote still sums');
|
||||||
|
const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 40, rigRecord: [] }, def, 40);
|
||||||
|
assertEq(s.client, alt.night.client, 'the invoice bills the client you chose');
|
||||||
|
assertEq(s.site, alt.night.site, 'the ledger records the yard you chose');
|
||||||
|
assertEq(s.taken, alt.id, 'and that it came off the board');
|
||||||
|
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
|
||||||
|
'the ledger adds up on a chosen night exactly as it does on a scripted one');
|
||||||
|
assertEq(q.base, s.fullFee, 'quote==settle holds on a taken job');
|
||||||
|
assert(s.rep != null && s.grade != null, 'rep and grade are computed for a chosen night');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('SPRINT17: a taken night is INDISTINGUISHABLE in shape from an authored one', () => {
|
||||||
|
// The bug this pins was real and the board probe found it before a player
|
||||||
|
// could: take() stored the raw pool entry, and a pool entry with no `pay`
|
||||||
|
// block threw "Cannot read properties of undefined (reading 'garden')" out
|
||||||
|
// of quote(). Both doors normalise through normaliseNight() now — this
|
||||||
|
// asserts the SHAPES match rather than trusting that they do.
|
||||||
|
const w = createWeek();
|
||||||
|
const scripted = nightAt(0);
|
||||||
|
w.take({ id: 'alt:probe', night: { storm: 'storm_01_gentle', site: 'backyard_01' } });
|
||||||
|
const takenKeys = Object.keys(w.job).sort().join(',');
|
||||||
|
assertEq(takenKeys, Object.keys(scripted).sort().join(','),
|
||||||
|
'a taken night carries exactly the fields an authored night carries');
|
||||||
|
// And the defaults really defaulted, rather than being present-but-undefined.
|
||||||
|
assertEq(typeof w.job.pay, 'object', 'pay defaults to a block, not undefined');
|
||||||
|
assertEq(w.job.constraints.length, 0, 'constraints default to an empty array');
|
||||||
|
assertEq(w.job.gardenBeyondSaving, false, 'a new night cannot inherit the icenight\'s excuse');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('SPRINT17: take is a COMMITMENT — the other offer vanishes and there is no untake', () => {
|
||||||
|
const w = createWeek();
|
||||||
|
const [scripted, alt] = w.offers();
|
||||||
|
w.take(alt);
|
||||||
|
assertEq(w.takenOffer, alt.id);
|
||||||
|
// Taking again on the same morning is the SAME take, not a re-pick. A
|
||||||
|
// player who could re-open the board after reading the job sheet would be
|
||||||
|
// shopping, not choosing — and the fee is already quoted against the job.
|
||||||
|
w.take(scripted);
|
||||||
|
assertEq(w.takenOffer, alt.id, 'the morning\'s choice is final');
|
||||||
|
assertEq(w.site, alt.night.site, 'and the yard did not change under it');
|
||||||
|
// A fresh morning is a fresh choice, though.
|
||||||
|
w.advance();
|
||||||
|
assertEq(w.takenOffer, null, 'tomorrow is unchosen');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('SPRINT17: the SPINE runs untouched when nothing is taken — the campaign is not the board', () => {
|
||||||
|
// 474 asserts stand on the scripted week. The board must be a second door,
|
||||||
|
// not a replacement: take nothing and every per-night reading is the
|
||||||
|
// authored ladder, byte for byte.
|
||||||
|
const w = createWeek();
|
||||||
|
for (let i = 0; i < NIGHTS.length; i++) {
|
||||||
|
w.offers(); // building a board must not MOVE anything
|
||||||
|
assertEq(w.stormKey, nightAt(i).storm, `night ${i + 1} storm untouched by the board`);
|
||||||
|
assertEq(w.site, nightAt(i).site, `night ${i + 1} site untouched`);
|
||||||
|
assertEq(w.job.client, nightAt(i).client, `night ${i + 1} client untouched`);
|
||||||
|
assertEq(w.takenOffer, null, 'and nothing is marked taken');
|
||||||
|
w.advance();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('SPRINT17: every pool night is a JOB, and its storm is one the game preloads', () => {
|
||||||
|
for (const p of POOL) {
|
||||||
|
assert(p.id, 'a pool entry needs an id');
|
||||||
|
assert(p.client, `${p.id} has a client`);
|
||||||
|
assert(p.addr, `${p.id} has an address — a letterhead bills somebody somewhere`);
|
||||||
|
assert(p.brief && p.brief.length > 20, `${p.id} has a brief worth reading`);
|
||||||
|
assert(p.site, `${p.id} names a yard`);
|
||||||
|
assert(p._why, `${p.id} carries its audit receipt — an unmeasured pairing is a night the board is guessing about`);
|
||||||
|
// ⚠️ D's calm-day landmine, in its SPRINT17 costume: the board can put any
|
||||||
|
// pool night on tonight, so a pool storm the loader never fetched is
|
||||||
|
// `defs[week.stormKey] === undefined` the moment somebody takes it.
|
||||||
|
assert(stormsToPreload().includes(p.storm),
|
||||||
|
`${p.id} flies ${p.storm}, which nothing preloads — taking this offer would boot into an undefined storm`);
|
||||||
|
}
|
||||||
|
// ⚠️ THE LINE ABOVE IS NOT ENOUGH, AND A MUTATION PROVED IT (A, S17): every
|
||||||
|
// storm the pool flies TODAY also appears in NIGHTS, so that assert passes
|
||||||
|
// with the whole POOL source deleted from stormsToPreload — decoration, and
|
||||||
|
// the identical coincidence that hid the calm-day bug for a sprint. THIS is
|
||||||
|
// the assert with teeth: a pool storm that is NOT in the ladder, which is
|
||||||
|
// the case that actually breaks, and the case D's pool yard will be the
|
||||||
|
// moment it brings a storm of its own.
|
||||||
|
assert(stormsToPreload(['storm_01_gentle'], ['storm_99_fixture']).includes('storm_99_fixture'),
|
||||||
|
'a POOL-ONLY storm must be preloaded — the board can put it on tonight and nothing else would load it');
|
||||||
|
assert(stormsToPreload(['storm_01_gentle'], []).includes(CALM_STORM),
|
||||||
|
'the calm day still survives a ladder without it (S11 pin, still true)');
|
||||||
|
// ...and it is still deduped, so the loader does not fetch a storm twice
|
||||||
|
// just because the pool and the ladder agree about it.
|
||||||
|
assertEq(stormsToPreload(['storm_01_gentle'], ['storm_01_gentle']).length, 1,
|
||||||
|
'a storm in BOTH the ladder and the pool is loaded once');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('SPRINT17: collateral exposure is the yard\'s priced property, and it matches the site JSON', () => {
|
||||||
|
if (!siteJsonForTest.backyard_01) return 'SKIPPED — no server for site JSON';
|
||||||
|
// Two routes to one set of dollars (board.js walks the site JSON up front;
|
||||||
|
// world.js resolves a collateral KEY at failure time), which is exactly the
|
||||||
|
// shape this repo has been bitten by. Pin the totals against the data.
|
||||||
|
const b = exposureOf(siteJsonForTest.backyard_01);
|
||||||
|
assertEq(b.total, 115, 'the backyard: gutter 90 + gnome 25');
|
||||||
|
const c = exposureOf(siteJsonForTest.site_02_corner_block);
|
||||||
|
assertEq(c.total, 205, 'the corner block: carport 180 + gnome 25 — and no house');
|
||||||
|
const s = exposureOf(siteJsonForTest.site_03_swing_lawn);
|
||||||
|
assertEq(s.total, 255, 'the swing lawn: gutter 90 + swing set 140 + gnome 25 — the worst in the game');
|
||||||
|
// Every item priced must be a real number a player can actually be billed,
|
||||||
|
// and the total must BE the items — an exposure that didn't sum to its own
|
||||||
|
// list would be the invoice bug one card earlier.
|
||||||
|
for (const { what, cost } of [...b.items, ...c.items, ...s.items]) {
|
||||||
|
assert(what && typeof what === 'string', 'every exposure item names itself');
|
||||||
|
assert(Number.isFinite(cost) && cost > 0, `${what} is priced`);
|
||||||
|
}
|
||||||
|
assertEq(s.total, s.items.reduce((n, i) => n + i.cost, 0), 'the total IS the items');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- SPRINT17 gate 2: CLIENT CONSTRAINTS (A's data half) -----------------
|
||||||
|
|
||||||
|
t.test('SPRINT17: a constrained night pays a premium, on BOTH papers, from ONE formula', () => {
|
||||||
|
if (!boardDefs.storm_03b_earlybuster) return 'SKIPPED — no server for storm defs';
|
||||||
|
const capped = POOL.find((p) => p.constraints?.some((c) => c.kind === 'budgetCap'));
|
||||||
|
assert(capped, 'the pool ships a budget-capped job');
|
||||||
|
const def = boardDefs[capped.storm];
|
||||||
|
|
||||||
|
const plain = calloutFee(def, REP.START, []);
|
||||||
|
const withCap = calloutFee(def, REP.START, capped.constraints);
|
||||||
|
assert(withCap > plain, `a constraint must PAY — got ${withCap} vs ${plain}`);
|
||||||
|
assertEq(withCap, Math.round(plain * (1 + CONSTRAINT_PREMIUM.budgetCap)),
|
||||||
|
'and by exactly the premium the constraint declares');
|
||||||
|
|
||||||
|
// The sheet and the invoice must read the same number. quote() and settle()
|
||||||
|
// route through the same calloutFee() — this is the pin that keeps them
|
||||||
|
// there when somebody adds a fourth term.
|
||||||
|
const w = createWeek();
|
||||||
|
w.take({ id: `alt:${capped.id}`, night: capped });
|
||||||
|
const q = w.quote(def);
|
||||||
|
const s = w.settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 45);
|
||||||
|
assertEq(q.base, withCap, 'the job sheet quotes the premium');
|
||||||
|
assertEq(s.fullFee, withCap, 'and the invoice pays it');
|
||||||
|
assertEq(q.premium, s.premium, 'both papers state the same premium');
|
||||||
|
// The invoice repeats the TERMS it was paid under — gate 2's requirement.
|
||||||
|
assertEq(s.constraints.length, capped.constraints.length,
|
||||||
|
'the invoice repeats the constraint it was paid under');
|
||||||
|
assertEq(s.constraints[0].label, capped.constraints[0].label);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('SPRINT17: the constraint enum is CHECKED, so a bad one cannot ship', () => {
|
||||||
|
// An unenforced enum is decoration — this repo's standing rule, learned
|
||||||
|
// when the carport shipped typed as a 'post' behind a JSDoc that said it
|
||||||
|
// couldn't. Every shipped constraint validates...
|
||||||
|
for (const p of POOL) {
|
||||||
|
for (const c of p.constraints ?? []) validateConstraint(c, p.id);
|
||||||
|
}
|
||||||
|
// ...and a bogus one throws, with the kind named in the message.
|
||||||
|
let threw = '';
|
||||||
|
try { validateConstraint({ kind: 'pay_me_double', says: 'give me money', label: 'nope' }, 'fixture'); }
|
||||||
|
catch (err) { threw = err.message; }
|
||||||
|
assert(threw.includes('pay_me_double'), `a bad kind must fail loud and name itself, got: ${threw || '(no throw)'}`);
|
||||||
|
// The fields the papers PRINT are required too — a constraint with no
|
||||||
|
// client words would render an empty quote block on two cards.
|
||||||
|
let threw2 = '';
|
||||||
|
try { validateConstraint({ kind: 'budgetCap', cap: 45, label: 'capped' }, 'fixture'); }
|
||||||
|
catch (err) { threw2 = err.message; }
|
||||||
|
assert(threw2.includes('says'), `a constraint with no client words must fail, got: ${threw2 || '(no throw)'}`);
|
||||||
|
// ...and a budgetCap with no number is not a cap.
|
||||||
|
let threw3 = '';
|
||||||
|
try { validateConstraint({ kind: 'budgetCap', says: 'keep it cheap please', label: 'capped' }, 'fixture'); }
|
||||||
|
catch (err) { threw3 = err.message; }
|
||||||
|
assert(threw3.includes('cap'), `a capless budgetCap must fail, got: ${threw3 || '(no throw)'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('SPRINT17: a budget cap is BELOW the start budget and ABOVE the four-carabiner floor', () => {
|
||||||
|
// The ruling, pinned: the client caps your SPEND, not your wallet. A cap at
|
||||||
|
// or above START_BUDGET is not a constraint (it changes nothing and the
|
||||||
|
// premium is free money); a cap under BROKE_BELOW is a soft-lock, because
|
||||||
|
// four carabiners is the cheapest legal rig and the game refuses to leave
|
||||||
|
// prep without four corners.
|
||||||
|
for (const p of POOL) {
|
||||||
|
for (const c of p.constraints ?? []) {
|
||||||
|
if (c.kind !== 'budgetCap') continue;
|
||||||
|
assertLess(c.cap, START_BUDGET, `${p.id}: a cap at or above the start budget constrains nothing`);
|
||||||
|
assert(c.cap >= BROKE_BELOW,
|
||||||
|
`${p.id}: cap $${c.cap} is under the $${BROKE_BELOW} four-carabiner floor — that is a soft-lock, not a squeeze`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Gate 2 acceptance: both sites load from data, and the corner block is not
|
// Gate 2 acceptance: both sites load from data, and the corner block is not
|
||||||
// the backyard with the furniture moved. Built up front (Suite.test() can't
|
// the backyard with the furniture moved. Built up front (Suite.test() can't
|
||||||
// await — the guard I added last sprint enforces it, and just caught me).
|
// await — the guard I added last sprint enforces it, and just caught me).
|
||||||
|
|||||||
@ -14,10 +14,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as THREE from '../../vendor/three.module.js';
|
import * as THREE from '../../vendor/three.module.js';
|
||||||
import { assert, assertClose, fixedLoop } from '../testkit.js';
|
import { assert, assertClose, assertEq, fixedLoop } from '../testkit.js';
|
||||||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
|
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS, createStubWind } from '../contracts.js';
|
||||||
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor } from '../weather.js';
|
import {
|
||||||
|
loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor, hailBlockFor,
|
||||||
|
forecastHonest, stormStats, STONE_WORD_CEILING,
|
||||||
|
// SPRINT17, the second half of gate 1 (see weather.js's divider): what the
|
||||||
|
// card PRINTS, and which yard a storm may be printed over.
|
||||||
|
offerBand, PAIRING_LAW, pairingRefusal, pairingRefusals,
|
||||||
|
} from '../weather.js';
|
||||||
import { loadSite, createWorld } from '../world.js';
|
import { loadSite, createWorld } from '../world.js';
|
||||||
|
// SPRINT17 gate 1 — the board's weather side. NIGHTS ∪ POOL is every storm an
|
||||||
|
// offer card can print; both are data, so the honesty walk below tracks them
|
||||||
|
// without a test edit the day D's pool yard lands.
|
||||||
|
import { NIGHTS, nightAt } from '../week.js';
|
||||||
|
import { POOL, exposureOf } from '../board.js';
|
||||||
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
||||||
// that copied main.js's two lines would agree with a copy of the game forever,
|
// that copied main.js's two lines would agree with a copy of the game forever,
|
||||||
// including on the day the game itself changed. "Two harnesses, one number"
|
// including on the day the game itself changed. "Two harnesses, one number"
|
||||||
@ -1284,4 +1295,306 @@ export default async function run(t) {
|
|||||||
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
|
`membrane on carabiners (${mem20.hp} hp, ${mem20.cornersLost} lost) should read WORSE than cloth `
|
||||||
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
|
+ `(${cloth20.hp} hp, ${cloth20.cornersLost} lost) — the doubled load has stopped costing anything`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
// SPRINT17 gate 1 — THE BOARD'S WEATHER SIDE (C).
|
||||||
|
//
|
||||||
|
// A's seam contract (THREADS 2026-07-21): the offer card prints my
|
||||||
|
// forecastLines(def, 0), and "a storm the forecast can't describe honestly
|
||||||
|
// doesn't get offered" is MY rule to enforce. Enforced here, over the union
|
||||||
|
// of both night sources, so a new pool entry meets the gate at integration
|
||||||
|
// without anyone remembering to ask.
|
||||||
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Every storm the board can put on a card, from BOTH sources, by
|
||||||
|
// construction. A's stormsToPreload mutation finding is the warning label:
|
||||||
|
// today every POOL storm also flies in NIGHTS, so a walk of NIGHTS alone
|
||||||
|
// would be green by coincidence and rot the day D's pool yard brings a storm
|
||||||
|
// of its own. The union is taken from the shipped data, so that day changes
|
||||||
|
// this walk without anyone editing a test.
|
||||||
|
const offerStormNames = [...new Set([
|
||||||
|
...NIGHTS.map((n) => (typeof n === 'string' ? n : n.storm)),
|
||||||
|
...POOL.map((p) => p.storm),
|
||||||
|
])];
|
||||||
|
const offerDefs = {};
|
||||||
|
const offerLoadErrors = [];
|
||||||
|
for (const nm of offerStormNames) {
|
||||||
|
try { offerDefs[nm] = storms[nm] ?? await loadStorm(nm); }
|
||||||
|
catch (err) { offerLoadErrors.push(`${nm}: ${err.message}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): a storm the forecast cannot describe honestly is not offered', () => {
|
||||||
|
assert(offerLoadErrors.length === 0,
|
||||||
|
`offerable storms failed to load/validate:\n ${offerLoadErrors.join('\n ')}`);
|
||||||
|
// Vacuity guard: the union walk must actually be walking the shipped week.
|
||||||
|
assert(offerStormNames.length >= 6,
|
||||||
|
`only ${offerStormNames.length} offerable storms found — the NIGHTS ∪ POOL walk is broken`);
|
||||||
|
const failures = [];
|
||||||
|
for (const nm of offerStormNames) {
|
||||||
|
const h = forecastHonest(offerDefs[nm], nm);
|
||||||
|
if (!h.ok) failures.push(`${nm}:\n ${h.errors.join('\n ')}`);
|
||||||
|
}
|
||||||
|
assert(failures.length === 0,
|
||||||
|
'the board offers storms the forecast cannot describe honestly — pull them from POOL/NIGHTS '
|
||||||
|
+ `or fix the def (A's contract: C vetoes, A pulls):\n ${failures.join('\n ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): the honesty gate can fail — a lying change and an unspeakable stone are refused', () => {
|
||||||
|
// Negative controls through the SAME predicate the gate walk uses — the
|
||||||
|
// composition a pool-only storm exercises the day one exists. (The band-
|
||||||
|
// containment loops inside forecastHonest are structural — band() cannot
|
||||||
|
// exclude the truth by construction — and are disclosed as regression
|
||||||
|
// armour in its header, not counted as coverage. THESE two are the teeth.)
|
||||||
|
const lying = structuredClone(storms.storm_03_southerly);
|
||||||
|
lying.dirCurve = lying.dirCurve.map(([tt]) => [tt, lying.dirCurve[0][1]]); // promises a change, never turns
|
||||||
|
const l = forecastHonest(lying, 'lying_southerly');
|
||||||
|
assert(!l.ok, 'a windchange the dirCurve never delivers passed the honesty gate');
|
||||||
|
assert(l.errors.some((e) => e.includes('windchange')),
|
||||||
|
`the refusal should name the lie, got:\n ${l.errors.join('\n ')}`);
|
||||||
|
|
||||||
|
const unspeakable = structuredClone(storms.storm_02b_icenight);
|
||||||
|
unspeakable.hail.size = STONE_WORD_CEILING + 0.6;
|
||||||
|
const u = forecastHonest(unspeakable, 'cricket_ball_night');
|
||||||
|
assert(!u.ok, `hail.size ${unspeakable.hail.size} passed — the vocabulary ceiling is decoration`);
|
||||||
|
assert(u.errors.some((e) => e.includes('vocabulary') || e.includes('ceiling')),
|
||||||
|
`the refusal should name the vocabulary, got:\n ${u.errors.join('\n ')}`);
|
||||||
|
// and the shipped icenight sits under the ceiling with honest headroom
|
||||||
|
assert(stormStats(storms.storm_02b_icenight).hailSize <= STONE_WORD_CEILING,
|
||||||
|
'the shipped icenight is over the vocabulary ceiling — the gate would pull a shipped night');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): the offer card\'s lead-0 lines carry the measured truth', () => {
|
||||||
|
// The board prints forecastLines(def, 0) (A's pricedOffers — "the same
|
||||||
|
// call the job sheet makes"). Cross the card TEXT against stormStats'
|
||||||
|
// measured numbers, so the wording cannot drift off the measurement while
|
||||||
|
// both stay green alone. Walks the POOL: these are the cards a player
|
||||||
|
// reads about a night they have never flown.
|
||||||
|
for (const p of POOL) {
|
||||||
|
const def = offerDefs[p.storm];
|
||||||
|
const f = forecastLines(def, 0);
|
||||||
|
assert(f.confidence === '',
|
||||||
|
`${p.id}: a lead-0 offer hedges ("${f.confidence}") — tonight is exact and the card must not waffle`);
|
||||||
|
const g = f.wind.match(/gusts to ~(\d+) km\/h/);
|
||||||
|
assert(g, `${p.id}: the wind line lost its gust figure: "${f.wind}"`);
|
||||||
|
assertEq(g[1], (stormStats(def).gustPeak * 3.6).toFixed(0),
|
||||||
|
`${p.id}: the card's gust number is not the measured storm`);
|
||||||
|
const su = f.wind.match(/^sustained to (\d+)/);
|
||||||
|
assert(su, `${p.id}: the wind line lost its sustained figure: "${f.wind}"`);
|
||||||
|
assertEq(su[1], stormStats(def).sustained.toFixed(0),
|
||||||
|
`${p.id}: the card's sustained number is not the measured storm`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): the offer band carries every fact the storm delivers, and none it does not', () => {
|
||||||
|
// ⚠️ THE FINDING THIS CASE EXISTS FOR. A's board prints `f.wind` and
|
||||||
|
// `f.stones` — a reasonable pair — and under that pair NIGHT 7 LIES:
|
||||||
|
// soaker (scripted) gusts to ~55 km/h · fine pea stones
|
||||||
|
// early buster (alt) gusts to ~76 km/h · pea stones
|
||||||
|
// On both printed lines the soaker is the softer job, while delivering
|
||||||
|
// 14.9s of hail against 2.4s and 44 mm/hr against 16.5. `offerBand` returns
|
||||||
|
// the ORDERED LIST instead, so the forecast decides which facts a night
|
||||||
|
// carries and a card cannot drop the next one somebody adds.
|
||||||
|
for (const nm of offerStormNames) {
|
||||||
|
const def = offerDefs[nm];
|
||||||
|
const st = stormStats(def);
|
||||||
|
const lines = offerBand(def, 0);
|
||||||
|
const keys = lines.map((l) => l.key);
|
||||||
|
const text = (k) => lines.find((l) => l.key === k)?.text ?? '';
|
||||||
|
|
||||||
|
assert(keys.includes('wind'), `${nm}: no wind line on the offer band`);
|
||||||
|
assert(keys.includes('rain'), `${nm}: no rain-rate line on the offer band`);
|
||||||
|
assert(keys.includes('change'), `${nm}: no change line on the offer band`);
|
||||||
|
assert(!keys.includes('confidence'),
|
||||||
|
`${nm}: tonight's band hedged its own confidence — lead 0 is exact`);
|
||||||
|
|
||||||
|
// HAIL, BOTH DIRECTIONS — the assert night 7 needed. Present iff it hails:
|
||||||
|
// a missing line recommends the trap, an invented one teaches that the
|
||||||
|
// absence of a hail line means somebody checked.
|
||||||
|
assert(keys.includes('hail') === (st.hailSeconds > 0),
|
||||||
|
`${nm}: hails for ${st.hailSeconds.toFixed(1)}s but the band's hail line is `
|
||||||
|
+ `${keys.includes('hail') ? 'present' : 'MISSING'}`);
|
||||||
|
if (st.hailSeconds > 0) {
|
||||||
|
assert(/fine pea|pea|marble|golf ball/.test(text('hail')),
|
||||||
|
`${nm}: hail line has no stone word — "${text('hail')}"`);
|
||||||
|
assert(/\ds of it/.test(text('hail')),
|
||||||
|
`${nm}: hail line does not say how long it hails — "${text('hail')}"`);
|
||||||
|
}
|
||||||
|
// The rate is the OTHER half of the soaker's trap, and the unit ponding
|
||||||
|
// reads — its absence is what made the two-field card lie.
|
||||||
|
assert(new RegExp(`${Math.round(st.rainPeakMmPerHour)} mm/hr`).test(text('rain')),
|
||||||
|
`${nm}: peaks at ${st.rainPeakMmPerHour.toFixed(1)} mm/hr, band says "${text('rain')}"`);
|
||||||
|
if (st.changeAt != null) {
|
||||||
|
assert(text('change').includes(`${Math.round(st.changeAt)}s`),
|
||||||
|
`${nm}: changes at ${st.changeAt}s, band says "${text('change')}"`);
|
||||||
|
} else {
|
||||||
|
assert(!/\d/.test(text('change')), `${nm}: never changes, band says "${text('change')}"`);
|
||||||
|
}
|
||||||
|
for (const l of lines) {
|
||||||
|
assert(!/NaN|Infinity|undefined/.test(l.text),
|
||||||
|
`${nm}: the ${l.key} line does not word its own numbers — "${l.text}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): the two buster variants are TELLABLE APART on a board card', () => {
|
||||||
|
// storm_03 and storm_03b are IDENTICAL in every number the band prints
|
||||||
|
// (sustained 13.0, gustPeak 21.4/21.2, rain 16.5 mm/hr, hail 2.4s at size
|
||||||
|
// 0.70) and differ ONLY in when the change lands, 30s against 18s. The
|
||||||
|
// board offers exactly that pair — the buster is the drawn alternative on
|
||||||
|
// nights 5 and 7. Drop the change line and the board offers two jobs whose
|
||||||
|
// weather it has just claimed to describe, described identically, and calls
|
||||||
|
// it a choice. That is why the band is a list and not two named fields.
|
||||||
|
const a = offerBand(storms.storm_03_southerly, 0).map((l) => l.text);
|
||||||
|
const b = offerBand(storms.storm_03b_earlybuster, 0).map((l) => l.text);
|
||||||
|
const differing = a.filter((line, ix) => line !== b[ix]);
|
||||||
|
assert(differing.length > 0,
|
||||||
|
'storm_03 and storm_03b word IDENTICAL offer bands — the board would offer two '
|
||||||
|
+ 'indistinguishable jobs and call it a choice');
|
||||||
|
assert(differing.some((l) => /change/.test(l)),
|
||||||
|
`the two busters differ on ${differing.join(' / ')} — expected the CHANGE TIME, since `
|
||||||
|
+ 'that is the only thing that differs in the storms');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The PAIRING half of gate 1. `forecastHonest` above asks whether the
|
||||||
|
// forecast can describe a STORM; a band can be perfectly honest about a storm
|
||||||
|
// and still be an invitation to a night nobody can work. NIGHTS ∪ POOL again,
|
||||||
|
// this time carrying the SITE — the dimension the storm-side walk cannot see.
|
||||||
|
const offerPairings = [
|
||||||
|
...NIGHTS.map((_, ix) => {
|
||||||
|
const n = nightAt(ix);
|
||||||
|
return { id: `night ${ix + 1}`, storm: n.storm, site: n.site };
|
||||||
|
}),
|
||||||
|
...POOL.map((p) => ({ id: `pool:${p.id}`, storm: p.storm, site: p.site })),
|
||||||
|
];
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): every offerable PAIRING respects the measured yard/storm law', () => {
|
||||||
|
assert(offerPairings.length >= 12,
|
||||||
|
`only ${offerPairings.length} pairings found — the NIGHTS ∪ POOL walk is broken`);
|
||||||
|
const refused = pairingRefusals(offerPairings);
|
||||||
|
assert(refused.length === 0,
|
||||||
|
`the board would offer ${refused.length} pairing(s) refused by measurement:\n `
|
||||||
|
+ refused.map((r) => `${r.id}: ${r.why}`).join('\n '));
|
||||||
|
// Vacuity guard, on this exact input: without it the assert above passes on
|
||||||
|
// a law that returns [] unconditionally — the failure mode I shipped in S13
|
||||||
|
// and named. Move ONE night's yard and exactly one refusal must appear.
|
||||||
|
const moved = offerPairings.map((p) =>
|
||||||
|
(p.storm === 'storm_06_soaker' ? { ...p, site: 'backyard_01' } : p));
|
||||||
|
assert(pairingRefusals(moved).length >= 1,
|
||||||
|
'moving the soaker onto backyard_01 did not trip the law — the assert above is '
|
||||||
|
+ 'passing on a gate that cannot say no');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GATE 1 (S17): the pairing law refuses what it was measured to refuse, and nothing else', () => {
|
||||||
|
// MINE, with the S16 receipt in the message: the soaker over backyard_01
|
||||||
|
// has no win in it (hail cover capped at ~31%, both fabrics pond-tearing).
|
||||||
|
assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null,
|
||||||
|
'the soaker over the backyard is offerable — the ~31% hail-cover measurement has '
|
||||||
|
+ 'stopped being enforced');
|
||||||
|
assert(/31%/.test(pairingRefusal('storm_06_soaker', 'backyard_01')),
|
||||||
|
'the refusal does not carry the measurement that justifies it');
|
||||||
|
assert(pairingRefusal('storm_06_soaker', 'site_03_swing_lawn') !== null,
|
||||||
|
'the soaker over the swing lawn is offerable, and nobody has ever flown it');
|
||||||
|
assert(pairingRefusal('storm_06_soaker', 'site_02_corner_block') === null,
|
||||||
|
'the soaker over site_02 is REFUSED — that is the pairing it shipped for');
|
||||||
|
// A's two, enforced the same way and credited to A in the data.
|
||||||
|
assert(pairingRefusal('storm_02_wildnight', 'site_02_corner_block') !== null,
|
||||||
|
'the wild night is offerable off backyard_01, where its separation pin lives');
|
||||||
|
assert(pairingRefusal('storm_02b_icenight', 'site_03_swing_lawn') !== null,
|
||||||
|
'the ice night is offerable off backyard_01, where gardenBeyondSaving was measured');
|
||||||
|
assert(PAIRING_LAW.storm_02_wildnight.owner === 'A' && PAIRING_LAW.storm_06_soaker.owner === 'C',
|
||||||
|
'the law has lost track of who ruled which entry — the owner field is how a reader '
|
||||||
|
+ 'knows whose measurement to go and read');
|
||||||
|
// And it must NOT over-refuse: absence of a measurement is not a refusal.
|
||||||
|
assert(pairingRefusal('storm_03_southerly', 'site_03_swing_lawn') === null,
|
||||||
|
'the southerly over the swing lawn is refused — that is night 4, the shipped ladder');
|
||||||
|
assert(pairingRefusal('storm_99_unmeasured', 'anywhere') === null,
|
||||||
|
'a storm with no entry in the law was refused — inventing a refusal for a pairing '
|
||||||
|
+ 'nobody flew is the same offence in the other direction');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GATE 1.2 (S17): THE EXPOSURE CROSS — A's exposureOf vs the failure
|
||||||
|
// route, the S14 pin pattern (two harnesses, one number, by construction).
|
||||||
|
// Prep up front (Suite.test can't await); the tests skip honestly offline.
|
||||||
|
const crossWorlds = {};
|
||||||
|
for (const nm of ['site_02_corner_block', 'backyard_01', 'site_03_swing_lawn']) {
|
||||||
|
try {
|
||||||
|
const sj = await loadSite(nm);
|
||||||
|
const w = createWorld(new THREE.Scene(), { wind: createStubWind({ calm: true }), site: sj });
|
||||||
|
let dressed = true;
|
||||||
|
try { await w.dress(); } catch { dressed = false; } // graybox anchors carry no GLB collateral keys
|
||||||
|
crossWorlds[nm] = { sj, w, dressed };
|
||||||
|
} catch (err) {
|
||||||
|
crossWorlds[nm] = { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MY envelope: what a failure can actually bill, gathered the way the game
|
||||||
|
* bills it (main.js scoreRun) — every collateral KEY reachable from a BUILT
|
||||||
|
* anchor, priced once per structure through world.collateralFor (the
|
||||||
|
* resolver the invoice uses), plus the gnome (billed on a two-corner
|
||||||
|
* collapse, priced off world.gnome). A's exposureOf walks the site JSON up
|
||||||
|
* front and never sees an anchor; this walks the built world and never sees
|
||||||
|
* the JSON's structure list. Two routes to one set of dollars — when they
|
||||||
|
* disagree, find the variable before either is tuned; it will be the
|
||||||
|
* harness (the S14 rule, verbatim).
|
||||||
|
*/
|
||||||
|
const failureEnvelope = (w) => {
|
||||||
|
const keys = [...new Set(w.anchors.map((a) => a.collateral).filter(Boolean))];
|
||||||
|
const items = [];
|
||||||
|
const unpriced = [];
|
||||||
|
for (const k of keys) {
|
||||||
|
const p = w.collateralFor(k);
|
||||||
|
if (p) items.push({ what: p.label, cost: p.cost });
|
||||||
|
else unpriced.push(k);
|
||||||
|
}
|
||||||
|
if (Number.isFinite(w.gnome?.collateralValue)) {
|
||||||
|
items.push({ what: 'garden gnome', cost: w.gnome.collateralValue });
|
||||||
|
}
|
||||||
|
return { items, unpriced, total: items.reduce((s, i) => s + i.cost, 0) };
|
||||||
|
};
|
||||||
|
|
||||||
|
t.test('GATE 1.2 (S17): collateral exposure — two harnesses, one set of dollars', () => {
|
||||||
|
// The explicit dollar pins are the vacuity guard: two empty walks agreeing
|
||||||
|
// on $0 cannot pass here. 205 = carport 180 + gnome 25; 115 = gutter 90 +
|
||||||
|
// gnome 25; 255 = gutter 90 + swing set 140 + gnome 25 (the numbers A
|
||||||
|
// verified live on the board, and a.test pins per-structure). Every yard
|
||||||
|
// the board can currently offer is crossed — the gate asked for one.
|
||||||
|
const pins = { site_02_corner_block: 205, backyard_01: 115, site_03_swing_lawn: 255 };
|
||||||
|
for (const [nm, want] of Object.entries(pins)) {
|
||||||
|
const c = crossWorlds[nm];
|
||||||
|
if (c?.error) return `SKIPPED — no server for ${nm}: ${c.error}`;
|
||||||
|
if (!c.dressed) return `SKIPPED — ${nm} GLBs unavailable, graybox anchors carry no collateral keys`;
|
||||||
|
const mine = failureEnvelope(c.w);
|
||||||
|
const board = exposureOf(c.sj);
|
||||||
|
assertEq(mine.unpriced.length, 0,
|
||||||
|
`${nm}: anchors reach unpriced collateral (${mine.unpriced.join(', ')}) — exposure the card `
|
||||||
|
+ 'cannot show, and "not scored" must never become "free"');
|
||||||
|
assertEq(mine.total, board.total, `${nm}: the two exposure harnesses disagree — find the variable`);
|
||||||
|
assertEq(mine.total, want, `${nm}: both harnesses agree on the WRONG number`);
|
||||||
|
// Totals agreeing is necessary, not sufficient — item-level agreement is
|
||||||
|
// what rules out two errors cancelling.
|
||||||
|
const key = (xs) => xs.map((i) => `${i.what}=$${i.cost}`).sort().join(' · ');
|
||||||
|
assertEq(key(mine.items), key(board.items),
|
||||||
|
`${nm}: totals agree but the itemisation differs — the agreement is a coincidence`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GATE 1.2 (S17): the cross has teeth — strip the beam\'s key and the harnesses disagree', () => {
|
||||||
|
const c = crossWorlds.site_02_corner_block;
|
||||||
|
if (!c || c.error || !c.dressed) return 'SKIPPED — no dressed corner block (see the cross above)';
|
||||||
|
// The drift class this cross exists to catch: an anchor that loses its
|
||||||
|
// collateral key is $180 the offer card advertises and the failure path
|
||||||
|
// can never bill (the free-carport bug, S11–S14, wearing the board's
|
||||||
|
// clothes). Mutate the LIVE world — this is its last use in the suite —
|
||||||
|
// and the equality above must break, or it never could have.
|
||||||
|
const stripped = c.w.anchors.filter((a) => a.collateral === 'carport');
|
||||||
|
assert(stripped.length >= 2,
|
||||||
|
`only ${stripped.length} anchor(s) carry the carport key — the trap has lost its reach`);
|
||||||
|
for (const a of stripped) a.collateral = null;
|
||||||
|
const mine = failureEnvelope(c.w);
|
||||||
|
assertEq(mine.total, 25, `the stripped envelope should read gnome-only $25, got $${mine.total}`);
|
||||||
|
assert(mine.total !== exposureOf(c.sj).total,
|
||||||
|
'the harnesses still agree with the carport unreachable — the cross cannot fail');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,13 @@ import { loadStorm, createWind } from '../weather.js';
|
|||||||
// file read the ladder. week.js is contracts-only underneath, so this stays
|
// file read the ladder. week.js is contracts-only underneath, so this stays
|
||||||
// headless-safe.
|
// headless-safe.
|
||||||
import { NIGHTS, nightAt } from '../week.js';
|
import { NIGHTS, nightAt } from '../week.js';
|
||||||
|
// SPRINT17 gate 3.2: the pool-yard pins at the bottom read the board's POOL
|
||||||
|
// (pure data, zero THREE) and loadSite (fetch + validateSite — the teeth; a
|
||||||
|
// site file this suite merely fetch()ed raw could rot invalid and stay green).
|
||||||
|
// loadSite pulls world.js which pulls THREE — browser-only, same as the GLB
|
||||||
|
// facts everywhere else in this repo; the node runner never imports this file.
|
||||||
|
import { POOL, exposureOf } from '../board.js';
|
||||||
|
import { loadSite } from '../world.js';
|
||||||
|
|
||||||
const DT = FIXED_DT;
|
const DT = FIXED_DT;
|
||||||
|
|
||||||
@ -1229,4 +1236,96 @@ export default async function run(t) {
|
|||||||
"the wildnight sits directly before the icenight — the icenight's brief says 'less wind than "
|
"the wildnight sits directly before the icenight — the icenight's brief says 'less wind than "
|
||||||
+ "last night', and the paper never lies: re-order these and re-word that brief in the same commit");
|
+ "last night', and the paper never lies: re-order these and re-word that brief in the same commit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── SPRINT17 gate 3.2: THE POOL YARD — authored cold, entered through the ──
|
||||||
|
// board, pinned by its author. These pins carry the yard's three claims:
|
||||||
|
// it is CHOSEN or absent (never scripted), its fence is fourteen honest noes
|
||||||
|
// and its rust is one priced yes, and its refusal + wreck apron are facts
|
||||||
|
// with numbers, not vibes. The site file itself goes through loadSite here,
|
||||||
|
// so a hand-edit that breaks validateSite goes red in THIS suite too, not
|
||||||
|
// only at boot.
|
||||||
|
let poolSite = null, poolSiteErr = null;
|
||||||
|
try { poolSite = await loadSite('site_04_pool_yard'); }
|
||||||
|
catch (err) { poolSiteErr = String((err && err.stack) || err); }
|
||||||
|
|
||||||
|
t.test('pool yard (S17 3.2): in the game ONLY through the board — chosen, never scripted', () => {
|
||||||
|
const entry = POOL.find((p) => p.site === 'site_04_pool_yard');
|
||||||
|
assert(entry, 'the pool yard has a POOL entry — its one door into the game');
|
||||||
|
assertEq(entry.storm, 'storm_03_southerly',
|
||||||
|
'it flies the southerly — the storm nights 2 and 4 teach, so the yard is the only new variable; '
|
||||||
|
+ 'any other pairing needs its own gauntlet run FIRST (the _why receipt describes this one)');
|
||||||
|
assert(entry._why && entry._why.includes('audit'),
|
||||||
|
'the entry carries its audit receipt — an unmeasured pairing is a night the board is guessing about');
|
||||||
|
const scripted = NIGHTS.map((_, i) => nightAt(i));
|
||||||
|
assert(!scripted.some((n) => n.site === 'site_04_pool_yard'),
|
||||||
|
'NO scripted night flies the pool yard — "the first yard whose only route in is being chosen" '
|
||||||
|
+ 'is the arc-2 promise, and a week.js slot would quietly break it');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('pool yard (S17 3.2): the ring says no fourteen times, the rust says yes once', () => {
|
||||||
|
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
|
||||||
|
const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1');
|
||||||
|
assert(ring, 'the pool ring is in the yard — it IS the thesis');
|
||||||
|
assertEq((ring.anchors ?? []).length, 0,
|
||||||
|
'the ring adopts NOTHING in site data — its fourteen noes are baked tie_off:false in the GLB, '
|
||||||
|
+ 'and a site-level anchor here would override a certified fence into steel');
|
||||||
|
assert(ring.collateralValue == null,
|
||||||
|
'the ring is unpriced BY RULING (the bike rule) — nothing can bend a pool fence yet');
|
||||||
|
const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1');
|
||||||
|
assert(rust, 'the corroded post stands');
|
||||||
|
assertEq((rust.anchors ?? []).length, 1, 'exactly one adoptable eye');
|
||||||
|
assertEq(rust.anchors[0].type, 'corroded_post', 'typed to E\'s tier so it rates 0.55, not 1.00');
|
||||||
|
assertEq(rust.collateralValue, 45, 'the make-safe bill, E\'s number, adopted with its reasoning');
|
||||||
|
assert(rust.wreckedModel === 'sail_post_corroded_wrecked_v1',
|
||||||
|
'the wreck variant is wired — a trap that vanishes instead of falling is a free failure');
|
||||||
|
const rusts = poolSite.structures.filter((s) => s.model === 'sail_post_corroded_v1');
|
||||||
|
assertEq(rusts.length, 1,
|
||||||
|
'ONE corroded post on purpose: two would share the baked collateral key and the second failure '
|
||||||
|
+ 'would bill/wreck the wrong one (structFor matches first; filed in THREADS [D] S17)');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('pool yard (S17 3.2): separation-JUDGED — refused with receipts, XOR the pin', () => {
|
||||||
|
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
|
||||||
|
const pinned = !!poolSite.separation, finding = !!poolSite._separation_finding;
|
||||||
|
assert(pinned || finding, 'the yard is JUDGED — silence is not an option (S16 gate 2.3)');
|
||||||
|
assert(!(pinned && finding), 'both at once means the finding went stale in the pinning commit');
|
||||||
|
assert(finding, 'today it is a REFUSAL: bare wins the southerly (mild-night canon, A\'s 0.4 ruling)');
|
||||||
|
const text = poolSite._separation_finding.join(' ');
|
||||||
|
assert(text.includes('83.7') && text.includes('92.7'),
|
||||||
|
'the refusal carries its measured receipts (bare 83.7 / best flown 92.7) — a finding without '
|
||||||
|
+ 'numbers is a shrug wearing a ruling\'s clothes');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('pool yard (S17 3.2): the wreck apron threads the needle between bed and ring', () => {
|
||||||
|
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
|
||||||
|
// E's placement facts (THREADS S17 gate 3.1): the corroded post falls +Z
|
||||||
|
// ~3.9 m, shaft half-width ~0.26 m. The fallen shaft must land in the path
|
||||||
|
// — clear of the bed's east edge AND the ring's west run — or the wreck
|
||||||
|
// interpenetrates client property the moment the trap fires.
|
||||||
|
const rust = poolSite.structures.find((s) => s.model === 'sail_post_corroded_v1');
|
||||||
|
const ring = poolSite.structures.find((s) => s.model === 'pool_kit_01_v1');
|
||||||
|
const bedEast = poolSite.gardenBed.x + poolSite.gardenBed.w / 2;
|
||||||
|
const shaftWest = rust.x - 0.26, shaftEast = rust.x + 0.26;
|
||||||
|
const ringWest = ring.x - 2.8; // ring proper 5.6 wide (E's measured bbox)
|
||||||
|
assert(bedEast <= shaftWest,
|
||||||
|
`bed east edge ${bedEast} overlaps the fallen shaft (west ${shaftWest.toFixed(2)}) — move c1 or the bed`);
|
||||||
|
assert(shaftEast <= ringWest,
|
||||||
|
`fallen shaft east ${shaftEast.toFixed(2)} reaches the ring (west ${ringWest.toFixed(2)}) — move c1 or the pool`);
|
||||||
|
const apronTip = rust.z + 3.9;
|
||||||
|
assert(apronTip <= poolSite.yard.depth / 2,
|
||||||
|
`the shaft falls past the south fence (tip z ${apronTip}) — it must land IN the yard, that is the point`);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('pool yard (S17 3.2): the offer card prices the rust and never the fence', () => {
|
||||||
|
if (poolSiteErr) throw new Error(`site load died: ${poolSiteErr}`);
|
||||||
|
const { total, items } = exposureOf(poolSite);
|
||||||
|
assertEq(total, 160, 'exposure $160 = gutter 90 + corroded post 45 + gnome 25');
|
||||||
|
const labels = items.map((i) => i.what).sort().join(' · ');
|
||||||
|
assertEq(labels, 'garden gnome · the corroded post · the gutter',
|
||||||
|
'three priced items exactly — the fourteen fence posts are deliberately NOT exposure: '
|
||||||
|
+ 'nothing can bend a pool fence yet (the bike rule), and a $0 line item would teach "free"');
|
||||||
|
const rustItem = items.find((i) => i.what === 'the corroded post');
|
||||||
|
assertEq(rustItem.cost, 45,
|
||||||
|
'the rust is the cheapest structure you can break — the stake E sized the gamble in');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,6 +116,16 @@ const ASSETS = [
|
|||||||
{ name: 'pool_kit_01', h: [1.12, 1.32],
|
{ name: 'pool_kit_01', h: [1.12, 1.32],
|
||||||
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
|
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
|
||||||
'fence_post_01', 'fence_post_14'] },
|
'fence_post_01', 'fence_post_14'] },
|
||||||
|
// SPRINT17 gate 3.1 — the corroded tier. The intact variant stands POST
|
||||||
|
// height on purpose: its lie is "I'm a sail post", so it must stand like
|
||||||
|
// one, and only the colour and the drooped eye say otherwise. The wreck's
|
||||||
|
// ceiling is under a metre because the post FOLDED at the rust line — a
|
||||||
|
// corroded wreck still standing 4 m tall is a wreck that never told the
|
||||||
|
// truth about where it was weakest.
|
||||||
|
{ name: 'sail_post_corroded', h: [3.90, 4.15],
|
||||||
|
nodes: ['footing', 'post', 'rust', 'pad_eye', 'top_anchor', 'rake_pivot'] },
|
||||||
|
{ name: 'sail_post_corroded_wrecked', h: [0.45, 0.95],
|
||||||
|
nodes: ['footing', 'post_stub', 'post_down', 'pad_eye_down'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
function sizeOf(gltf) {
|
function sizeOf(gltf) {
|
||||||
@ -1050,6 +1060,273 @@ export default async function run(t) {
|
|||||||
+ 'lie the invoice exists to kill; when debris can take a panel out, price it THEN');
|
+ 'lie the invoice exists to kill; when debris can take a panel out, price it THEN');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// SPRINT17 gate 3.1 — THE CORRODED TIER. The pool kit is fourteen tie-offs
|
||||||
|
// that don't exist; this is the one that exists and shouldn't be trusted.
|
||||||
|
// The design claim is "corrosion a player can't see is a trap with no tell,
|
||||||
|
// and this repo doesn't ship those" — so the TELL itself is pinned as
|
||||||
|
// numbers (area, lightness, sag), not adjectives. An untelled corroded post
|
||||||
|
// (same 0.55 hint, clean steel) passes the factory, passes RULE 4a/4b, and
|
||||||
|
// passes every dims/nodes check — this test is the ONLY thing that goes red
|
||||||
|
// on it, which is exactly why it exists. (Proven red in the S17 negative
|
||||||
|
// controls; receipts in THREADS.)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Total triangle area of every mesh under a GLB scene, split by predicate
|
||||||
|
* on the mesh's material. World transforms applied; area is exact. */
|
||||||
|
const areaBy = (scene, pred) => {
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
let hit = 0, total = 0;
|
||||||
|
const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
|
||||||
|
scene.traverse((o) => {
|
||||||
|
if (!o.isMesh) return;
|
||||||
|
const pos = o.geometry.attributes.position;
|
||||||
|
const idx = o.geometry.index;
|
||||||
|
const n = idx ? idx.count : pos.count;
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < n; i += 3) {
|
||||||
|
const ia = idx ? idx.getX(i) : i, ib = idx ? idx.getX(i + 1) : i + 1,
|
||||||
|
ic = idx ? idx.getX(i + 2) : i + 2;
|
||||||
|
a.fromBufferAttribute(pos, ia).applyMatrix4(o.matrixWorld);
|
||||||
|
b.fromBufferAttribute(pos, ib).applyMatrix4(o.matrixWorld);
|
||||||
|
c.fromBufferAttribute(pos, ic).applyMatrix4(o.matrixWorld);
|
||||||
|
b.sub(a); c.sub(a);
|
||||||
|
area += b.cross(c).length() / 2;
|
||||||
|
}
|
||||||
|
total += area;
|
||||||
|
if (pred(o.material)) hit += area;
|
||||||
|
});
|
||||||
|
return { hit, total };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rust, as a colour band: strongly saturated orange-brown, darker than
|
||||||
|
* mid. Nothing else in this palette is saturated in that hue (weathered
|
||||||
|
* steel and concrete are near-grey, s < 0.15; timber is lighter). Colours
|
||||||
|
* are read in the loader's working space — thresholds chosen with margin
|
||||||
|
* on both sides (rust s ≈ 0.8, everything else ≤ 0.15). */
|
||||||
|
const isRust = (m) => {
|
||||||
|
const hsl = { h: 0, s: 0, l: 0 };
|
||||||
|
(m.color ?? new THREE.Color(0xffffff)).getHSL(hsl);
|
||||||
|
return hsl.h <= 0.12 && hsl.s >= 0.35 && hsl.l <= 0.45;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The three tells are three SEPARATE tests on purpose. Shipped as one
|
||||||
|
// block, the first failing assert masked the other two — the S17 M1
|
||||||
|
// negative control (clean steel, flat eye) went red on rust area alone and
|
||||||
|
// never reached the lightness or droop checks, so their strength was
|
||||||
|
// unproven. Split, each tell is independently red-checkable, which is the
|
||||||
|
// only way "mutation-checked" means anything.
|
||||||
|
t.test('the corroded post TELLS (1/3): rust reads as AREA, and the honest post stays clean', () => {
|
||||||
|
const g = loaded.get('sail_post_corroded');
|
||||||
|
const honest = loaded.get('sail_post');
|
||||||
|
assert(g && honest, 'both post variants must load — the tell is a comparison');
|
||||||
|
|
||||||
|
// (a) The at-a-glance read, as a number: rust-coloured surface area. 5%
|
||||||
|
// of a 4 m post is roughly the base band + head bloom + streaks — under
|
||||||
|
// that and the corrosion is a caption, not a tell. (Shipped value ~19%.)
|
||||||
|
const { hit, total } = areaBy(g.scene, isRust);
|
||||||
|
assert(total > 0, 'no measurable surface at all');
|
||||||
|
const frac = hit / total;
|
||||||
|
assert(frac >= 0.05,
|
||||||
|
`rust covers ${(frac * 100).toFixed(1)}% of the corroded post's surface — under 5% ` +
|
||||||
|
'is corrosion nobody can read from the lawn, a trap with no tell');
|
||||||
|
// And the honest post must NOT read rusty — the tell only works if the
|
||||||
|
// clean one is clean. (Also guards the colour thresholds themselves.)
|
||||||
|
const h = areaBy(honest.scene, isRust);
|
||||||
|
assert(h.hit === 0,
|
||||||
|
`the HONEST sail post carries ${(h.hit / h.total * 100).toFixed(1)}% rust-coloured area — ` +
|
||||||
|
'either the post rusted or the rust predicate is reading galvanised steel as rust');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('the corroded post TELLS (2/3): the shaft is NOT galvanised — base colour and PBR both moved', () => {
|
||||||
|
const g = loaded.get('sail_post_corroded');
|
||||||
|
const honest = loaded.get('sail_post');
|
||||||
|
assert(g && honest, 'both post variants must load — the tell is a comparison');
|
||||||
|
|
||||||
|
// (b) The 20 m read. This assert shipped in the S17 WIP saying the
|
||||||
|
// corroded shaft "reads darker at yard distance"; I measured the actual
|
||||||
|
// render before endorsing it and the SIGN IS BACKWARDS. Sampled off
|
||||||
|
// look.html's canvas at cam z=20 m, 1280×720, over the mid-shaft band:
|
||||||
|
//
|
||||||
|
// honest rgb(49,51,49) lum 50 sat 0.031 R−B 0
|
||||||
|
// corroded rgb(98,94,86) lum 94 sat 0.126 R−B +12
|
||||||
|
//
|
||||||
|
// The corroded shaft renders TWICE AS BRIGHT, not darker. Base colour is
|
||||||
|
// genuinely darker (0.524 vs 0.737) but it does not drive the pixels:
|
||||||
|
// galvanised is metalness 0.90 / roughness 0.35 and, with no environment
|
||||||
|
// map in this renderer, a near-mirror metal has almost nothing to mirror
|
||||||
|
// and goes near-black. The weathered shaft at 0.55 / 0.60 is far more
|
||||||
|
// diffuse, so it actually catches the sun. The separation is real and
|
||||||
|
// large — it is just made of METALNESS, not lightness.
|
||||||
|
//
|
||||||
|
// So the test now pins both halves, and the comment states which one the
|
||||||
|
// player is actually seeing. An assert whose stated reason is the reverse
|
||||||
|
// of the mechanism is a docstring waiting to be "fixed" in the wrong
|
||||||
|
// direction by whoever tunes this next.
|
||||||
|
const shaftOf = (gl) => {
|
||||||
|
let best = null;
|
||||||
|
gl.scene.getObjectByName('post').traverse((o) => {
|
||||||
|
if (!o.isMesh || isRust(o.material)) return;
|
||||||
|
const hsl = { h: 0, s: 0, l: 0 };
|
||||||
|
o.material.color.getHSL(hsl);
|
||||||
|
if (!best || hsl.l > best.l) best = { l: hsl.l, m: o.material };
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
const sHonest = shaftOf(honest), sCorroded = shaftOf(g);
|
||||||
|
assert(sHonest && sCorroded, 'could not find shaft materials on the post nodes');
|
||||||
|
|
||||||
|
assert(sCorroded.l < sHonest.l - 0.08,
|
||||||
|
`corroded shaft base lightness ${sCorroded.l.toFixed(3)} vs galvanised ${sHonest.l.toFixed(3)} — ` +
|
||||||
|
'the corroded shaft is wearing galvanised paint, so nothing distinguishes them but the rust');
|
||||||
|
|
||||||
|
// The half that actually reaches the screen. Weathered steel is rougher
|
||||||
|
// and less metallic than gal; if a future edit "tidies" these back to the
|
||||||
|
// galvanised values the post keeps its rust and loses its 20 m silhouette.
|
||||||
|
assert(sCorroded.m.roughness > sHonest.m.roughness + 0.15,
|
||||||
|
`corroded shaft roughness ${sCorroded.m.roughness} vs galvanised ${sHonest.m.roughness} — ` +
|
||||||
|
'weathered steel that is as polished as gal loses the long-range read (this is the ' +
|
||||||
|
'property that actually drove lum 94 vs 50 at 20 m, measured)');
|
||||||
|
assert(sCorroded.m.metalness < sHonest.m.metalness - 0.15,
|
||||||
|
`corroded shaft metalness ${sCorroded.m.metalness} vs galvanised ${sHonest.m.metalness} — ` +
|
||||||
|
'corroded steel must be the more diffuse of the two or the yard-distance contrast inverts');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('the corroded post TELLS (3/3): the pad eye DROOPS — angle derived from geometry, pinned to the baked note', () => {
|
||||||
|
const g = loaded.get('sail_post_corroded');
|
||||||
|
const honest = loaded.get('sail_post');
|
||||||
|
assert(g && honest, 'both post variants must load — the tell is a comparison');
|
||||||
|
g.scene.updateMatrixWorld(true);
|
||||||
|
honest.scene.updateMatrixWorld(true);
|
||||||
|
|
||||||
|
const root = g.scene.getObjectByName('sail_post_corroded');
|
||||||
|
const anchor = g.scene.getObjectByName('top_anchor');
|
||||||
|
assert(root && anchor, 'root and top_anchor must both survive the export');
|
||||||
|
const u = root.userData;
|
||||||
|
|
||||||
|
// THE AXIS TRAP (E's swing_set precedent). The builder writes the droop
|
||||||
|
// as a claim in THREE.JS coords; this derives the same numbers back out
|
||||||
|
// of the exported anchor position. Note and mesh can only lie together.
|
||||||
|
//
|
||||||
|
// Why not "the corroded anchor sits lower than the honest one" — the
|
||||||
|
// assert this REPLACES? Because the eye hangs off a weld line 160 mm
|
||||||
|
// down the post, so that comparison reads 0.06 m at ZERO droop and sails
|
||||||
|
// past a `> 0.05` threshold. It measured the weld offset, not the sag:
|
||||||
|
// an assert that cannot fail on the thing it names is decoration. This
|
||||||
|
// one drives the derived angle to 0° when the eye is flat.
|
||||||
|
const baked = u.padeye_droop_deg;
|
||||||
|
const arm = u.padeye_arm_m;
|
||||||
|
const weldY = u.padeye_weld_y_threejs;
|
||||||
|
assert(typeof baked === 'number' && typeof arm === 'number' && typeof weldY === 'number',
|
||||||
|
'the droop must be BAKED as numbers on the root (deg, arm, weld y in three.js) — ' +
|
||||||
|
'an orientation claim that lives only in a docstring is the axis trap waiting to happen');
|
||||||
|
assert(baked > 0, `padeye_droop_deg is ${baked} — a corroded eye that does not droop has no tell`);
|
||||||
|
|
||||||
|
const p = anchor.getWorldPosition(new THREE.Vector3());
|
||||||
|
// The post axis is x=z=0; the eye hangs off the weld line at (0, weldY, 0).
|
||||||
|
const horiz = Math.hypot(p.x, p.z); // out from the axis
|
||||||
|
const vert = p.y - weldY; // up from the weld
|
||||||
|
const armMeasured = Math.hypot(horiz, vert);
|
||||||
|
assert(Math.abs(armMeasured - arm) < 0.004,
|
||||||
|
`the eye sits ${armMeasured.toFixed(4)} m off the weld line but the note says ${arm} m — ` +
|
||||||
|
'the baked arm and the exported geometry disagree');
|
||||||
|
|
||||||
|
const derivedDeg = Math.atan2(horiz, vert) * 180 / Math.PI;
|
||||||
|
assert(Math.abs(derivedDeg - baked) < 1.5,
|
||||||
|
`the exported pad eye droops ${derivedDeg.toFixed(1)}° but the root claims ${baked}° — ` +
|
||||||
|
'the mesh and its own note disagree about the sag (flatten the eye and this is what you see)');
|
||||||
|
|
||||||
|
// DIRECTION, measured — not asserted by reading the string. Blender −y
|
||||||
|
// maps to three.js +Z, so the eye must hang toward the YARD, the face the
|
||||||
|
// player judges it from. A sag pointing into the fence is a tell nobody sees.
|
||||||
|
assert(u.padeye_droop_dir_threejs === '+Z',
|
||||||
|
`the baked droop direction is ${u.padeye_droop_dir_threejs} — this test only knows how to check +Z`);
|
||||||
|
assert(p.z > 0.5 * horiz,
|
||||||
|
`the eye hangs toward (x=${p.x.toFixed(3)}, z=${p.z.toFixed(3)}) — the note says +Z (the yard side), ` +
|
||||||
|
'so either the exporter axis mapping moved or the note is wrong');
|
||||||
|
assert(Math.abs(p.x) < 0.006,
|
||||||
|
`the eye is displaced ${p.x.toFixed(3)} m on X — the droop is meant to be purely +Z`);
|
||||||
|
|
||||||
|
// And the weaker claim, kept as a sanity check now that it is no longer
|
||||||
|
// carrying the argument: the drooped anchor does sit below the honest one.
|
||||||
|
const sag = honest.scene.getObjectByName('top_anchor')
|
||||||
|
.getWorldPosition(new THREE.Vector3()).y - p.y;
|
||||||
|
assert(sag > 0.05,
|
||||||
|
`the corroded top_anchor sits only ${sag.toFixed(3)} m below the honest one`);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('corroded steel is a RUNG: above the swing frame, below sound timber, resolvable node-less', () => {
|
||||||
|
const T = FACTORY_ANCHOR_RATINGS.types;
|
||||||
|
const entry = T.corroded_post;
|
||||||
|
assert(entry, 'corroded_post is not in the ratings manifest — the type cannot resolve node-less');
|
||||||
|
assert(!FACTORY_ANCHOR_RATINGS.ambiguous.corroded_post,
|
||||||
|
'corroded_post is manifest-AMBIGUOUS — the one-rating-per-type rule (THREADS [E] S15) broke');
|
||||||
|
// The ladder claims, pinned against the MANIFEST, never against literals
|
||||||
|
// (a literal pin would stay green while the whole ladder reshuffled):
|
||||||
|
assert(T.swing_frame.rating_hint < entry.rating_hint,
|
||||||
|
`corroded post (${entry.rating_hint}) must out-rate the swing frame (${T.swing_frame.rating_hint}) — ` +
|
||||||
|
'a real concrete footing beats sound steel standing loose on grass');
|
||||||
|
assert(entry.rating_hint < T.pergola.rating_hint,
|
||||||
|
`corroded post (${entry.rating_hint}) must rate under the pergola (${T.pergola.rating_hint}) — ` +
|
||||||
|
'sound flexing timber beats steel whose bloom outside means pitting inside');
|
||||||
|
assert(entry.rating_hint < T.post.rating_hint,
|
||||||
|
'a corroded post rating at or above the honest post erases the tier entirely');
|
||||||
|
// It fails through the client's property: collateral named, priced, and
|
||||||
|
// priced UNDER the gutter — the bill is the make-safe, not the steel; the
|
||||||
|
// real price of trusting rust is the corner you lose mid-storm.
|
||||||
|
assert(entry.collateral === 'corroded_post',
|
||||||
|
'the manifest must carry the collateral so node-less anchors bill on failure');
|
||||||
|
const root = g('sail_post_corroded');
|
||||||
|
const gutter = g('house_yardside');
|
||||||
|
assert(typeof root.userData.collateral_value === 'number' && root.userData.collateral_value > 0,
|
||||||
|
'the corroded post names collateral nothing prices — free failure, the gutter bug');
|
||||||
|
assert(root.userData.collateral_value < gutter.userData.collateral_value,
|
||||||
|
`a snapped corroded post ($${root.userData.collateral_value}) must bill under the gutter ` +
|
||||||
|
`($${gutter.userData.collateral_value}) — condemned steel is a make-safe charge, not a rebuild`);
|
||||||
|
|
||||||
|
function g(name) {
|
||||||
|
const glb = loaded.get(name);
|
||||||
|
assert(glb, `${name} did not load`);
|
||||||
|
return glb.scene.getObjectByName(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('the corroded wreck folded at the rust line: stub stands, shaft in the yard, nothing left to tie to', () => {
|
||||||
|
const w = loaded.get('sail_post_corroded_wrecked');
|
||||||
|
assert(w, 'sail_post_corroded_wrecked did not load');
|
||||||
|
const box = new THREE.Box3().setFromObject(w.scene);
|
||||||
|
// It FELL: nothing stands past the stub, and the fallen shaft reaches
|
||||||
|
// into the yard on +Z (the same face the pergola presents — placement
|
||||||
|
// facts in THREADS). Both directions measured, not asserted by name.
|
||||||
|
assert(box.max.y < 1.0,
|
||||||
|
`the wreck stands ${box.max.y.toFixed(2)} m — a corroded post that still stands never folded`);
|
||||||
|
assert(box.max.z > 3.2,
|
||||||
|
`the fallen shaft reaches z=${box.max.z.toFixed(2)} — it is meant to be ~3.5 m out in the yard (+Z)`);
|
||||||
|
// No anchor survives, nothing rates, nothing enrols — an anchor that
|
||||||
|
// outlives its structure is the free-failure bug in a costume (the
|
||||||
|
// fascia rule; fourth application).
|
||||||
|
assert(!w.scene.getObjectByName('top_anchor'),
|
||||||
|
'top_anchor survives the wreck — you cannot clip to a post that is lying on the grass');
|
||||||
|
const offers = [];
|
||||||
|
w.scene.traverse((o) => {
|
||||||
|
if (typeof o.userData?.rating_hint === 'number' || o.userData?.anchor_type !== undefined) {
|
||||||
|
offers.push(o.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert(offers.length === 0,
|
||||||
|
`the wreck offers [${offers.join(', ')}] — a wreck must offer nothing`);
|
||||||
|
// Twin-priced, and it names its twin — the carport/gutter chain, sixth
|
||||||
|
// application, compared between the GLBs and never against a literal.
|
||||||
|
const wr = w.scene.getObjectByName('sail_post_corroded_wrecked');
|
||||||
|
const ir = loaded.get('sail_post_corroded')?.scene.getObjectByName('sail_post_corroded');
|
||||||
|
assert(wr?.userData?.broken_variant_of === 'sail_post_corroded',
|
||||||
|
'the wreck must name its intact twin so A can pair the swap');
|
||||||
|
assert(wr?.userData?.collateral_value === ir?.userData?.collateral_value,
|
||||||
|
'the wrecked post must be priced the same as the one it used to be');
|
||||||
|
assert(wr?.userData?.collateral_key === 'corroded_post',
|
||||||
|
'the wreck must keep the collateral key or the swap changes what the bill is about');
|
||||||
|
});
|
||||||
|
|
||||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||||
// rather than reloading, so all three have to be present at once.
|
// rather than reloading, so all three have to be present at once.
|
||||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||||
|
|||||||
@ -23,6 +23,30 @@ export {
|
|||||||
const kmh = (ms) => ms * 3.6;
|
const kmh = (ms) => ms * 3.6;
|
||||||
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}–${fmt(b.hi)}`);
|
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}–${fmt(b.hi)}`);
|
||||||
|
|
||||||
|
// SPRINT16 gate 3.3's word map, lifted to module scope in S17 so the honesty
|
||||||
|
// gate below audits THE map the card prints from — a predicate checking a copy
|
||||||
|
// of the vocabulary would agree with the copy forever, which is the exact drift
|
||||||
|
// this repo keeps getting bitten by. Words map hail.size units (1.0 ≈ a 1.5 cm
|
||||||
|
// stone): storm_03's 0.7 reads "pea" (its own comment's word), the soaker's
|
||||||
|
// 0.45 "fine pea", the wild night's 1.3 and the ice night's 1.4 read "golf
|
||||||
|
// balls" — the sizes cloth stops dead.
|
||||||
|
const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the stone vocabulary stops being honest. "golf ball" is the map's
|
||||||
|
* biggest word and it is open-ended upward — a storm whose stones measure 2.6
|
||||||
|
* would still print "golf ball stones", underselling ice the way the numbers
|
||||||
|
* are forbidden to. The numeric bands stay truthful at any size (band() can't
|
||||||
|
* lie), but the card leads with the WORD, and a word that rules out what
|
||||||
|
* actually falls is the same ambush as a band that does. 2.0 is where the line
|
||||||
|
* sits: at 1.0 ≈ 1.5 cm, 2.0 ≈ 3 cm is real golf-ball territory, so the word
|
||||||
|
* holds all the way up to it; past it we are in cricket-ball country the map
|
||||||
|
* has no word for. Shipped max is the ice night's 1.4 — real headroom, hard
|
||||||
|
* ceiling. A storm above this is unofferable until the vocabulary grows a word
|
||||||
|
* for it, and that is a wording sprint, not a data tweak.
|
||||||
|
*/
|
||||||
|
export const STONE_WORD_CEILING = 2.0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How vague a night reads when it is `nightsOut` nights away — the week IS the
|
* How vague a night reads when it is `nightsOut` nights away — the week IS the
|
||||||
* forecast horizon, so tonight is exact (0) and the far end of the week is as
|
* forecast horizon, so tonight is exact (0) and the far end of the week is as
|
||||||
@ -88,11 +112,8 @@ export function forecastLines(def, lead = 0) {
|
|||||||
// them; this owns the wording, same split as every other line here). The
|
// them; this owns the wording, same split as every other line here). The
|
||||||
// stone is the fabric bet's whole argument: pea hail rattles through a
|
// stone is the fabric bet's whole argument: pea hail rattles through a
|
||||||
// 2 mm weave, anything bigger cannot — so the size word is what tells a
|
// 2 mm weave, anything bigger cannot — so the size word is what tells a
|
||||||
// player which fabric tonight wants, before a dollar is spent. Words map
|
// player which fabric tonight wants, before a dollar is spent. The word map
|
||||||
// hail.size units (1.0 ≈ a 1.5 cm stone): storm_03's 0.7 reads "pea" (its
|
// itself is module-scope now (S17) — the offer-honesty gate audits it.
|
||||||
// own comment's word), the soaker's 0.45 "fine pea", the wild night's 1.3
|
|
||||||
// and the ice night's 1.4 read "golf balls" — the sizes cloth stops dead.
|
|
||||||
const stoneWord = (v) => (v < 0.6 ? 'fine pea' : v <= 0.9 ? 'pea' : v <= 1.2 ? 'marble' : 'golf ball');
|
|
||||||
const sz = f.hail.size;
|
const sz = f.hail.size;
|
||||||
const stones = f.hail.chance === 'none' ? '' : (
|
const stones = f.hail.chance === 'none' ? '' : (
|
||||||
stoneWord(sz.lo) === stoneWord(sz.hi)
|
stoneWord(sz.lo) === stoneWord(sz.hi)
|
||||||
@ -117,6 +138,272 @@ export function forecastLines(def, lead = 0) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPRINT17 gate 1 — CAN THE FORECAST DESCRIBE THIS STORM HONESTLY?
|
||||||
|
*
|
||||||
|
* The board's rule, stated in A's seam contract and enforced here: *a storm the
|
||||||
|
* forecast can't describe honestly doesn't get offered.* An offer card leads
|
||||||
|
* with my lines — if those lines can't tell the truth about a def, the def has
|
||||||
|
* no business on the board, however winnable the pairing is. c.test walks
|
||||||
|
* NIGHTS ∪ POOL through this, so a new pool entry (D's yard, arc-4 seeds)
|
||||||
|
* meets the gate at integration without anyone remembering to ask.
|
||||||
|
*
|
||||||
|
* Same contract shape as validateStorm: {ok, errors}, errors in English,
|
||||||
|
* recomputed FROM THE DEF — nothing here trusts the caller's provenance, so a
|
||||||
|
* hand-edited def fails on its own merits (the checkEnvelope rule).
|
||||||
|
*
|
||||||
|
* What has TEETH here, disclosed plainly (README: an assert that cannot fail
|
||||||
|
* is decoration):
|
||||||
|
* · validateStorm — structural lies (a windchange the dirCurve never
|
||||||
|
* delivers, overlapping gusts, a flatlining tail) really do fail.
|
||||||
|
* · the stone vocabulary ceiling — a def with stones past STONE_WORD_CEILING
|
||||||
|
* really does fail (the icenight at hail.size 2.6 is the negative control).
|
||||||
|
* · finiteness of the measured stats — a curve that produces NaN fails.
|
||||||
|
* What is STRUCTURAL and rides along as regression armour, not as a live gate:
|
||||||
|
* band-contains-truth and lead-0 exactness are guaranteed by forecastFor's
|
||||||
|
* band() today (lo=min(v,·), hi=max(v,·), width→0 at lead 0) — those loops can
|
||||||
|
* only fail on the day someone re-plumbs band(), which is precisely the day
|
||||||
|
* they should. Disclosed here and in THREADS rather than dressed up as
|
||||||
|
* coverage.
|
||||||
|
*
|
||||||
|
* @param {object} def parsed storm JSON
|
||||||
|
* @param {string} [name]
|
||||||
|
* @returns {{ok: boolean, errors: string[]}}
|
||||||
|
*/
|
||||||
|
export function forecastHonest(def, name = def?.name ?? 'storm') {
|
||||||
|
const errors = [];
|
||||||
|
const v = validateStorm(def, name);
|
||||||
|
if (!v.ok) errors.push(...v.errors);
|
||||||
|
if (!def || typeof def !== 'object' || !v.ok) return { ok: false, errors };
|
||||||
|
|
||||||
|
// The truth must be measurable before it can be told.
|
||||||
|
const s = stormStats(def);
|
||||||
|
for (const k of ['sustained', 'gustPeak', 'rainPeak', 'rainPeakMmPerHour',
|
||||||
|
'hailPeak', 'hailSeconds', 'hailSize']) {
|
||||||
|
if (!Number.isFinite(s[k])) {
|
||||||
|
errors.push(`${name}: stormStats.${k} is not finite — no honest line can be printed from it`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The vocabulary must reach the stone. Numbers band honestly at any size;
|
||||||
|
// the WORD is the card's opening argument and it must not undersell ice.
|
||||||
|
if (s.hailSeconds > 0 && s.hailSize > STONE_WORD_CEILING) {
|
||||||
|
errors.push(`${name}: hail.size ${s.hailSize} is past the stone vocabulary's ceiling `
|
||||||
|
+ `(${STONE_WORD_CEILING}) — "${stoneWord(s.hailSize)}" would undersell what falls; `
|
||||||
|
+ 'grow the word map before offering this storm');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute the band promises from the def (structural today — see header).
|
||||||
|
const contains = (b, val, what, lead) => {
|
||||||
|
if (val == null || b == null) return;
|
||||||
|
if (!(Number.isFinite(b.lo) && Number.isFinite(b.hi))) {
|
||||||
|
errors.push(`${name}: ${what} band at lead ${lead} is not finite`);
|
||||||
|
} else if (val < b.lo - 1e-9 || val > b.hi + 1e-9) {
|
||||||
|
errors.push(`${name}: ${what} band [${b.lo}, ${b.hi}] at lead ${lead} rules out the truth ${val}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const lead of [0, 0.5, 1]) {
|
||||||
|
const f = forecastFor(def, lead);
|
||||||
|
contains(f.sustained, s.sustained, 'sustained', lead);
|
||||||
|
contains(f.gustPeak, s.gustPeak, 'gustPeak', lead);
|
||||||
|
contains(f.rain, s.rainPeak, 'rain', lead);
|
||||||
|
contains(f.rainMmPerHour, s.rainPeakMmPerHour, 'rainMmPerHour', lead);
|
||||||
|
contains(f.hail.seconds, s.hailSeconds, 'hail.seconds', lead);
|
||||||
|
contains(f.hail.size, s.hailSize, 'hail.size', lead);
|
||||||
|
contains(f.changeAt, s.changeAt, 'changeAt', lead);
|
||||||
|
if (lead === 0) {
|
||||||
|
for (const [what, b] of [['sustained', f.sustained], ['gustPeak', f.gustPeak], ['rain', f.rain]]) {
|
||||||
|
if (b.lo !== b.hi) errors.push(`${name}: lead-0 ${what} band [${b.lo}, ${b.hi}] hedges — tonight is exact`);
|
||||||
|
}
|
||||||
|
if (s.hailSeconds > 0 && f.hail.chance === 'none') {
|
||||||
|
errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but tonight's card says "none"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// THE OFFER BAND, and THE PAIRING HALF OF THE GATE (SPRINT17 gate 1)
|
||||||
|
//
|
||||||
|
// Landed ON TOP of forecastHonest above, by the other half of Lane C this
|
||||||
|
// sprint — two sessions converged on gate 1 and split it cleanly without
|
||||||
|
// meaning to. `forecastHonest` answers "can the forecast describe this STORM",
|
||||||
|
// which is the storm-side gate and stays exactly as it landed. These two add
|
||||||
|
// the parts it does not reach: WHAT THE CARD ACTUALLY PRINTS, and WHICH YARD
|
||||||
|
// the storm may be printed over. Nothing above this line was changed.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE HONEST BAND FOR AN OFFER CARD — an ORDERED LIST of lines, not a bag of
|
||||||
|
* named fields, and the shape is the whole point.
|
||||||
|
*
|
||||||
|
* ⚠️ **WHY A LIST, when `forecastLines` already returns every string a card
|
||||||
|
* could want.** A card that reads named fields prints the fields its author
|
||||||
|
* remembered. A's board shipped `f.wind` and `f.stones` — a reasonable pair —
|
||||||
|
* and that pair MEASURABLY LIES ABOUT NIGHT 7:
|
||||||
|
*
|
||||||
|
* | night 7's two offers | wind line | stones line |
|
||||||
|
* |---|---|---|
|
||||||
|
* | the soaker (scripted) | gusts to ~55 km/h | fine pea stones |
|
||||||
|
* | the early buster (alt) | gusts to ~76 km/h | pea stones |
|
||||||
|
*
|
||||||
|
* On both printed lines the soaker is the SOFTER job. It is not: it delivers
|
||||||
|
* **14.9 s of hail against 2.4 s, and 44 mm/hr of rain against 16.5**. The
|
||||||
|
* soaker's whole design is that its wind reads gentle while its hail is the
|
||||||
|
* trap (C, S16 gate 3.1) — so a board printing only wind and stone size
|
||||||
|
* recommends the trap, in the storm's own voice, before a dollar is spent.
|
||||||
|
*
|
||||||
|
* The fix is not "also print rainRate". The fix is that the FORECAST decides
|
||||||
|
* which facts a night carries and hands over all of them, so the next fact
|
||||||
|
* added to a storm cannot be silently dropped by a card that predates it. Same
|
||||||
|
* construction, and the same reasoning, as A's `stormsToPreload(nights, pool)`
|
||||||
|
* split: an omission that is possible will eventually happen.
|
||||||
|
*
|
||||||
|
* ⚠️ **It never renders MORE than the truth either.** A hail line appears if and
|
||||||
|
* only if the storm hails — "hail: none" on a hail-free night teaches that the
|
||||||
|
* absence of a hail line means nobody checked.
|
||||||
|
*
|
||||||
|
* @param {object} def parsed storm JSON
|
||||||
|
* @param {number} [lead] 0..1 — the BOARD passes 0, and only 0: both offers are
|
||||||
|
* for TONIGHT. You are choosing which yard to stand in, not which night to
|
||||||
|
* work, and a hedged board would invent uncertainty the job sheet contradicts
|
||||||
|
* one card later.
|
||||||
|
* @returns {{key:string, text:string}[]} reading order: wind, hail, rain,
|
||||||
|
* change, confidence. `key` is for styling; `text` is the whole printed line.
|
||||||
|
*/
|
||||||
|
export function offerBand(def, lead = 0) {
|
||||||
|
const f = forecastLines(def, lead);
|
||||||
|
const ff = forecastFor(def, lead);
|
||||||
|
const t = f.truth;
|
||||||
|
const i = (v) => v.toFixed(0);
|
||||||
|
const lines = [{ key: 'wind', text: f.wind }];
|
||||||
|
|
||||||
|
// Hail, with its DURATION. `chance` at lead 0 is a duration threshold wearing
|
||||||
|
// a probability word (hailSeconds > 6 ⇒ 'likely'), which is honest enough on
|
||||||
|
// a job sheet and far too coarse on a board where two hail nights sit side by
|
||||||
|
// side: 'likely' covers both the soaker's 15 s and the ice night's 21 s, and
|
||||||
|
// 'possible' covers the buster's 2.4 s. The seconds are what a tradie buys
|
||||||
|
// steel against, so the seconds print.
|
||||||
|
if (f.stones) {
|
||||||
|
lines.push({
|
||||||
|
key: 'hail',
|
||||||
|
text: `hail ${f.hail.chance} · ${f.stones} · ${bandStr(ff.hail.seconds, i)}s of it`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rain RATE, always — the line A's card had no field for, and half of the
|
||||||
|
// soaker's trap. mm/hr is the unit the ponding model reads, so the card and
|
||||||
|
// the sim agree on what "heavy" means instead of each having an opinion.
|
||||||
|
lines.push({ key: 'rain', text: f.rainRate });
|
||||||
|
|
||||||
|
// The change, always — including "no change forecast", which is information,
|
||||||
|
// not filler. storm_03_southerly and storm_03b_earlybuster are IDENTICAL in
|
||||||
|
// every other number this band prints (sustained 13.0, gustPeak 21.4/21.2,
|
||||||
|
// rain 16.5 mm/hr, hail 2.4 s at size 0.70) and differ ONLY in when the change
|
||||||
|
// lands, 30 s against 18 s. The board offers exactly that pair — the buster is
|
||||||
|
// the drawn alternative on nights 5 and 7. Drop this line and the board offers
|
||||||
|
// two jobs whose weather it has just claimed to describe, described
|
||||||
|
// identically, and calls it a choice.
|
||||||
|
lines.push({
|
||||||
|
key: 'change',
|
||||||
|
text: t.changeAt == null ? 'no change forecast'
|
||||||
|
: (lead > 0 ? `southerly change ${bandStr(ff.changeAt, i)}s`
|
||||||
|
: `southerly change at ${i(t.changeAt)}s`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (f.confidence) lines.push({ key: 'confidence', text: f.confidence });
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `band` under its own name — the module-scope helper, reused rather than retyped. */
|
||||||
|
function bandStr(b, fmt) { return band(b, fmt); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚖️ **THE PAIRING LAW — which yards a storm may be OFFERED over.**
|
||||||
|
*
|
||||||
|
* `forecastHonest` above asks whether the forecast can describe a STORM. This
|
||||||
|
* asks the other half of SPRINT17 gate 1's rule, the half that is a fact about
|
||||||
|
* GEOMETRY × STORM rather than about wording: a band can be perfectly honest
|
||||||
|
* about a storm and still be an invitation to a night nobody can work.
|
||||||
|
*
|
||||||
|
* ⚠️ **These three were three prose comments before they were a rule.** A's POOL
|
||||||
|
* carries them as a comment listing what is "deliberately NOT here" — and this
|
||||||
|
* repo's standing verdict on that arrangement is A's own, on CONSTRAINT_KIND:
|
||||||
|
* an unenforced enum is decoration, and the carport shipped typed as a post for
|
||||||
|
* a sprint behind a JSDoc comment saying it couldn't. A comment cannot fail when
|
||||||
|
* somebody adds the sixth pool entry. Promoted to data, checked, red in the suite.
|
||||||
|
*
|
||||||
|
* **A — two of these three are YOUR reasons and are recorded as yours.** I have
|
||||||
|
* not re-measured a separation pin or a beyond-saving flag; I have moved your
|
||||||
|
* stated grounds into a structure that goes red. Reword freely — the mechanism
|
||||||
|
* is what I am defending, not the prose.
|
||||||
|
*
|
||||||
|
* An absent key means UNRESTRICTED, deliberately: this table records refusals
|
||||||
|
* somebody PAID FOR, and inventing one for a pairing nobody flew is the same
|
||||||
|
* offence in the other direction.
|
||||||
|
*/
|
||||||
|
export const PAIRING_LAW = Object.freeze({
|
||||||
|
storm_06_soaker: {
|
||||||
|
sites: ['site_02_corner_block'],
|
||||||
|
owner: 'C',
|
||||||
|
why: 'MEASURED (C, S16 gate 3.1, both shipped yards flown through gardenfly before the '
|
||||||
|
+ 'storm shipped): backyard_01\'s buyable geometry caps hail cover over the bed at '
|
||||||
|
+ '~31%, and its full-cover quads pond-tear at 3.3–3.7 kN in this mild wind on BOTH '
|
||||||
|
+ 'fabrics — best membrane line 37.5 against cloth 17.3, a fabric bet with no win in '
|
||||||
|
+ 'it. The soaker\'s forecast reads "fine pea stones · rain to 44 mm/hr" over there '
|
||||||
|
+ 'exactly as it does over site_02, and over there that band is an invitation to a '
|
||||||
|
+ 'night nobody can work. The swing lawn is not measured under it at all, which is '
|
||||||
|
+ 'the same refusal for a cheaper reason.',
|
||||||
|
},
|
||||||
|
storm_02_wildnight: {
|
||||||
|
sites: ['backyard_01'],
|
||||||
|
owner: 'A',
|
||||||
|
why: 'A (S17 gate 1, POOL): its separation is PINNED to backyard_01\'s site data. Fly it '
|
||||||
|
+ 'elsewhere and the pin describes a night nobody plays.',
|
||||||
|
},
|
||||||
|
storm_02b_icenight: {
|
||||||
|
sites: ['backyard_01'],
|
||||||
|
owner: 'A',
|
||||||
|
why: 'A (S17 gate 1, POOL), on A\'s S13 measurement: `gardenBeyondSaving` is a fact about '
|
||||||
|
+ 'THAT BED under that ice, not a property of the storm. Offered over another yard the '
|
||||||
|
+ 'night would carry the excuse without the evidence.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May this storm be offered over this yard?
|
||||||
|
* @returns {string|null} the refusal in English, or null when the pairing is offerable
|
||||||
|
*/
|
||||||
|
export function pairingRefusal(stormKey, siteKey) {
|
||||||
|
const law = PAIRING_LAW[stormKey];
|
||||||
|
if (!law || !law.sites) return null;
|
||||||
|
if (law.sites.includes(siteKey)) return null;
|
||||||
|
return `${stormKey} over ${siteKey}: not an offerable pairing — ${law.why} `
|
||||||
|
+ `(offerable: ${law.sites.join(', ')}; ruled by ${law.owner})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every pairing refusal in a list of candidate nights, as data.
|
||||||
|
*
|
||||||
|
* ⚠️ Deliberately NOT a filter. Silently dropping a bad entry leaves a morning
|
||||||
|
* with one offer and no explanation — a runtime surprise wearing a fix's
|
||||||
|
* clothes. The caller (c.test today; a `board.js` load-time assert the day A
|
||||||
|
* wants one) decides how loud to be, and gets the measurement in the message.
|
||||||
|
*
|
||||||
|
* @param {Array<{id?:string, storm:string, site:string}>} pairings
|
||||||
|
* @returns {{id:string, why:string}[]} empty = every pairing is offerable
|
||||||
|
*/
|
||||||
|
export function pairingRefusals(pairings = []) {
|
||||||
|
const out = [];
|
||||||
|
for (const p of pairings) {
|
||||||
|
const why = pairingRefusal(p.storm, p.site);
|
||||||
|
if (why) out.push({ id: p.id ?? `${p.storm}×${p.site}`, why });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolved against this module, not the server root: server.py serves the repo
|
// Resolved against this module, not the server root: server.py serves the repo
|
||||||
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
||||||
// import.meta.url is right under both, and under whatever Lane A does next.
|
// import.meta.url is right under both, and under whatever Lane A does next.
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { START_BUDGET, HARDWARE } from './contracts.js';
|
import { START_BUDGET, HARDWARE } from './contracts.js';
|
||||||
|
import { offersFor, premiumFor } from './board.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ladder. Escalating, and each rung introduces one new idea rather than just
|
* The ladder. Escalating, and each rung introduces one new idea rather than just
|
||||||
@ -187,19 +188,28 @@ export const NIGHTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A night entry, every shape → a full JOB.
|
* A night entry, every shape → a full JOB. **The one normaliser**, SPRINT17.
|
||||||
*
|
*
|
||||||
* The plain-string form still resolves (SPRINT10's promise), and now so does a
|
* The plain-string form still resolves (SPRINT10's promise), and now so does a
|
||||||
* night with no client: an unbriefed job is a job with no letterhead, not a
|
* night with no client: an unbriefed job is a job with no letterhead, not a
|
||||||
* crash. `pay` is per-job so the schedule is data — see PAY for the defaults and
|
* crash. `pay` is per-job so the schedule is data — see PAY for the defaults and
|
||||||
* for why the clean bonus is the one number here that is NOT yet measured.
|
* for why the clean bonus is the one number here that is NOT yet measured.
|
||||||
*
|
*
|
||||||
* @param {number} i
|
* ⚠️ **SPRINT17 — WHY THIS IS A SEPARATE FUNCTION FROM `nightAt`, and it is not
|
||||||
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}}
|
* tidiness.** The board can install a POOL entry as tonight's night, and a pool
|
||||||
|
* entry is hand-authored data with exactly the same optional fields a NIGHTS
|
||||||
|
* entry has. The first cut of `week.take()` stored `{...offer.night}` raw, and
|
||||||
|
* the board probe found it in one call: a pool entry with no `pay` block threw
|
||||||
|
* `Cannot read properties of undefined (reading 'garden')` out of quote() —
|
||||||
|
* i.e. a chosen job was NOT the same shape as a scripted one, which is the
|
||||||
|
* single promise gate 1 makes. Both doors normalise HERE now, so "a taken night
|
||||||
|
* is indistinguishable from an authored night" is a fact about the code rather
|
||||||
|
* than a thing two call sites both have to remember.
|
||||||
|
*
|
||||||
|
* @param {object|string} entry a NIGHTS entry, a pool entry, or a bare storm key
|
||||||
*/
|
*/
|
||||||
export function nightAt(i) {
|
export function normaliseNight(entry) {
|
||||||
const n = NIGHTS[i];
|
const base = typeof entry === 'string' ? { storm: entry, site: 'backyard_01' } : (entry ?? {});
|
||||||
const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {});
|
|
||||||
return {
|
return {
|
||||||
storm: base.storm,
|
storm: base.storm,
|
||||||
site: base.site ?? 'backyard_01',
|
site: base.site ?? 'backyard_01',
|
||||||
@ -210,9 +220,53 @@ export function nightAt(i) {
|
|||||||
// False by default on purpose: a night is presumed savable unless its data
|
// False by default on purpose: a night is presumed savable unless its data
|
||||||
// says otherwise, so a new night can never inherit the icenight's excuse.
|
// says otherwise, so a new night can never inherit the icenight's excuse.
|
||||||
gardenBeyondSaving: base.gardenBeyondSaving ?? false,
|
gardenBeyondSaving: base.gardenBeyondSaving ?? false,
|
||||||
|
/**
|
||||||
|
* SPRINT17 gate 2 — CLIENT CONSTRAINTS, as data on the night.
|
||||||
|
*
|
||||||
|
* Empty by default, and an EMPTY ARRAY rather than null on purpose: every
|
||||||
|
* consumer (the job sheet, the invoice, the offer card, B's session) wants
|
||||||
|
* to iterate this, and a null that four surfaces each have to remember to
|
||||||
|
* guard is three surfaces away from a crash. The scripted spine ships
|
||||||
|
* unconstrained — constraints arrive on the board's callouts — so this
|
||||||
|
* field is `[]` on all seven nights and nothing about the campaign moves.
|
||||||
|
*/
|
||||||
|
constraints: base.constraints ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonight's authored night, by index into the ladder.
|
||||||
|
* @param {number} i
|
||||||
|
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}}
|
||||||
|
*/
|
||||||
|
export function nightAt(i) {
|
||||||
|
return normaliseNight(NIGHTS[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callout fee, and the ONE place it is computed. SPRINT17.
|
||||||
|
*
|
||||||
|
* Extracted because gate 2 adds a third term (the constraint premium) to a
|
||||||
|
* formula that quote() and settle() were each spelling out for themselves. Two
|
||||||
|
* copies of a two-term formula is a latent divergence; two copies of a
|
||||||
|
* three-term formula is the quote-vs-settle lie with a longer fuse — and the
|
||||||
|
* sheet promising a premium the invoice doesn't pay is exactly the failure the
|
||||||
|
* warranty derivation was built to make impossible. So: one function, both
|
||||||
|
* callers, rounded ONCE at the end.
|
||||||
|
*
|
||||||
|
* Order matters and is fixed here: severity → standing → the client's squeeze.
|
||||||
|
* The premium is a fraction of the fee you'd otherwise be paid, so it compounds
|
||||||
|
* on the rep multiplier rather than on the raw fee — a ★5 tradie is paid more
|
||||||
|
* for accepting the same ban, which is the right way round.
|
||||||
|
*
|
||||||
|
* @param {object} def tonight's storm def
|
||||||
|
* @param {number} rep standing AS OF THIS MORNING
|
||||||
|
* @param {object[]} [constraints] tonight's client constraints
|
||||||
|
*/
|
||||||
|
export function calloutFee(def, rep, constraints = []) {
|
||||||
|
return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep) * (1 + premiumFor(constraints)));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The economy, in one place because it is the thing most likely to need tuning
|
* The economy, in one place because it is the thing most likely to need tuning
|
||||||
* once somebody actually plays five nights in a row.
|
* once somebody actually plays five nights in a row.
|
||||||
@ -363,6 +417,17 @@ export const REP = {
|
|||||||
*/
|
*/
|
||||||
export const BROKE_BELOW = HARDWARE[0].cost * 4;
|
export const BROKE_BELOW = HARDWARE[0].cost * 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPRINT17 — the default week seed.
|
||||||
|
*
|
||||||
|
* A CONSTANT, not a clock. Every board in the shipped game is drawn from this
|
||||||
|
* and the night index, so the campaign has one fixed sequence of offers that
|
||||||
|
* the selftest can pin and John can play twice and recognise. `createWeek({seed})`
|
||||||
|
* is the door for a different week — which is where replay comes from in a
|
||||||
|
* later sprint, and it costs nothing to leave open now.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_WEEK_SEED = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How the week is remembered, by gardens actually held.
|
* How the week is remembered, by gardens actually held.
|
||||||
*
|
*
|
||||||
@ -434,6 +499,31 @@ export function createWeek(opts = {}) {
|
|||||||
let bank = startBank;
|
let bank = startBank;
|
||||||
let done = false;
|
let done = false;
|
||||||
let rep = REP.START; // SPRINT16 — the number on the letterhead
|
let rep = REP.START; // SPRINT16 — the number on the letterhead
|
||||||
|
/**
|
||||||
|
* SPRINT17 gate 1 — the week's SEED. Every morning's board is drawn from
|
||||||
|
* this and the night index, and from nothing else: no Date.now, no
|
||||||
|
* Math.random (contracts.js §Determinism, and the selftest depends on it —
|
||||||
|
* an offer pool that reads the clock is a suite that goes red on a Tuesday).
|
||||||
|
* Same seed, same week, same two offers, forever.
|
||||||
|
*/
|
||||||
|
const seed = opts.seed ?? DEFAULT_WEEK_SEED;
|
||||||
|
/**
|
||||||
|
* SPRINT17 gate 1 — TAKEN JOBS, keyed by night index.
|
||||||
|
*
|
||||||
|
* This is the whole mechanism behind "the chosen job IS the night", and it is
|
||||||
|
* an OVERRIDE rather than a replacement on purpose. `jobAt()` falls through to
|
||||||
|
* the authored `nightAt()` whenever nothing was taken, so:
|
||||||
|
* · the scripted week runs byte-identically to the pre-board game (474
|
||||||
|
* asserts stand on that, and none of them know this map exists);
|
||||||
|
* · a taken alternative is a night entry of exactly the same SHAPE, so
|
||||||
|
* every per-night surface downstream — sheet, quote, invoice, ledger,
|
||||||
|
* warranty, rep, end card — reads it through the path it already had.
|
||||||
|
* Nothing consults "was this scripted?" to decide how to do its job, which
|
||||||
|
* is the property that makes the board impossible to half-wire.
|
||||||
|
* @type {Map<number, object>}
|
||||||
|
*/
|
||||||
|
const taken = new Map();
|
||||||
|
const jobAt = (i) => taken.get(i) ?? nightAt(i);
|
||||||
/** @type {Settlement[]} */
|
/** @type {Settlement[]} */
|
||||||
const log = [];
|
const log = [];
|
||||||
|
|
||||||
@ -463,11 +553,11 @@ export function createWeek(opts = {}) {
|
|||||||
/** 1-based, for humans. "Night 3 of 5". */
|
/** 1-based, for humans. "Night 3 of 5". */
|
||||||
get night() { return index + 1; },
|
get night() { return index + 1; },
|
||||||
get nights() { return NIGHTS.length; },
|
get nights() { return NIGHTS.length; },
|
||||||
get stormKey() { return nightAt(index).storm; },
|
get stormKey() { return jobAt(index).storm; },
|
||||||
/** SPRINT10: which yard tonight is on. main.js loads it. */
|
/** SPRINT10: which yard tonight is on. main.js loads it. */
|
||||||
get site() { return nightAt(index).site; },
|
get site() { return jobAt(index).site; },
|
||||||
/** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */
|
/** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */
|
||||||
get job() { return nightAt(index); },
|
get job() { return jobAt(index); },
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What tonight pays, BEFORE you rig it — the "budget Y" half of DESIGN.md's
|
* What tonight pays, BEFORE you rig it — the "budget Y" half of DESIGN.md's
|
||||||
@ -482,7 +572,7 @@ export function createWeek(opts = {}) {
|
|||||||
* @param {object} def tonight's storm def
|
* @param {object} def tonight's storm def
|
||||||
*/
|
*/
|
||||||
quote(def) {
|
quote(def) {
|
||||||
const j = nightAt(index);
|
const j = jobAt(index);
|
||||||
// SPRINT16: the fee is priced on your standing, and the deduction for
|
// SPRINT16: the fee is priced on your standing, and the deduction for
|
||||||
// last dawn's broken corners is on the quote — AT QUOTE TIME, so settle
|
// last dawn's broken corners is on the quote — AT QUOTE TIME, so settle
|
||||||
// pays exactly what the sheet said. Both read the same sources settle
|
// pays exactly what the sheet said. Both read the same sources settle
|
||||||
@ -492,13 +582,27 @@ export function createWeek(opts = {}) {
|
|||||||
const warranty = warrantyItems();
|
const warranty = warrantyItems();
|
||||||
const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
|
const warrantyTotal = warranty.reduce((s, i) => s + i.cost, 0);
|
||||||
return {
|
return {
|
||||||
base: Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult),
|
// SPRINT17 gate 2 — ONE fee function, shared with settle(), so the
|
||||||
|
// constraint premium cannot reach the sheet without reaching the
|
||||||
|
// invoice. (Before this it was two hand-written copies of the same
|
||||||
|
// formula agreeing by luck; adding a third term to both by hand is how
|
||||||
|
// that luck runs out.)
|
||||||
|
base: calloutFee(def, rep, j.constraints),
|
||||||
garden: j.pay.garden ?? PAY.gardenBonusMax,
|
garden: j.pay.garden ?? PAY.gardenBonusMax,
|
||||||
clean: j.pay.clean ?? PAY.noCollateralBonus,
|
clean: j.pay.clean ?? PAY.noCollateralBonus,
|
||||||
feeMult,
|
feeMult,
|
||||||
rep,
|
rep,
|
||||||
warranty,
|
warranty,
|
||||||
warrantyTotal,
|
warrantyTotal,
|
||||||
|
/**
|
||||||
|
* SPRINT17 gate 2 — the constraints the job is quoted UNDER, on the
|
||||||
|
* sheet, and repeated verbatim on the invoice they were paid under
|
||||||
|
* (hud.js prints both off this same array). `premium` is what they
|
||||||
|
* added to the fee, so the sheet can show the client's squeeze and the
|
||||||
|
* client's compensation as the two facts they are.
|
||||||
|
*/
|
||||||
|
constraints: j.constraints ?? [],
|
||||||
|
premium: premiumFor(j.constraints),
|
||||||
/**
|
/**
|
||||||
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
|
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
|
||||||
* to say when the garden bonus is money the night cannot pay.
|
* to say when the garden bonus is money the night cannot pay.
|
||||||
@ -522,6 +626,59 @@ export function createWeek(opts = {}) {
|
|||||||
get total() { return this.base + this.garden + this.clean - this.warrantyTotal; },
|
get total() { return this.base + this.garden + this.clean - this.warrantyTotal; },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* SPRINT17 gate 1 — THIS MORNING'S BOARD. Two offers: tonight's scripted
|
||||||
|
* night, and one alternative drawn deterministically from the pool.
|
||||||
|
*
|
||||||
|
* @param {(night:object) => object} [priced] optional per-offer pricing /
|
||||||
|
* forecast / exposure, injected by main.js because it owns the storm defs
|
||||||
|
* and the site JSON. board.js stays pure; the money is computed HERE, off
|
||||||
|
* the same rep and the same calloutFee() the sheet and invoice use, so an
|
||||||
|
* offer cannot advertise a fee the job sheet then contradicts.
|
||||||
|
*/
|
||||||
|
offers(priced = null) {
|
||||||
|
return offersFor({
|
||||||
|
scripted: nightAt(index), weekSeed: seed, nightIndex: index, priced,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TAKE ONE. The other vanishes — no backlog, no decline cost (SPRINT18
|
||||||
|
* decides whether saying no costs you; SPRINT17 deliberately does not
|
||||||
|
* charge for a choice it hasn't designed the consequences of).
|
||||||
|
*
|
||||||
|
* Idempotent per night and only forward: taking twice on the same morning
|
||||||
|
* is the same take, and there is no untake. That's not a limitation, it's
|
||||||
|
* the point — the board is a commitment, and a player who could re-open it
|
||||||
|
* after reading the job sheet would be shopping, not choosing.
|
||||||
|
*
|
||||||
|
* @param {object} offer one of `offers()`
|
||||||
|
* @returns {object} the night now installed for tonight
|
||||||
|
*/
|
||||||
|
take(offer) {
|
||||||
|
if (!offer?.night) throw new Error('week.take: needs an offer with a .night (board.js offersFor)');
|
||||||
|
if (!taken.has(index)) {
|
||||||
|
// Stamped with the offer id so the settlement — and therefore the
|
||||||
|
// invoice and the end card — can say the job came off the board and
|
||||||
|
// WHICH job it was. Non-enumerable so it can never leak into a night's
|
||||||
|
// data shape and make a taken night structurally different from a
|
||||||
|
// scripted one: `jobAt()` must hand back something indistinguishable.
|
||||||
|
// normaliseNight, NOT a spread — see its docstring. A pool entry is
|
||||||
|
// hand-authored data with the same optional fields a NIGHTS entry has,
|
||||||
|
// and a raw one crashes quote() on its missing `pay` block.
|
||||||
|
const night = normaliseNight(offer.night);
|
||||||
|
Object.defineProperty(night, '_offerId', { value: offer.id, enumerable: false });
|
||||||
|
taken.set(index, night);
|
||||||
|
}
|
||||||
|
return taken.get(index);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Tonight's taken offer id, or null if the player is on the spine. */
|
||||||
|
get takenOffer() { return taken.get(index)?._offerId ?? null; },
|
||||||
|
|
||||||
|
/** SPRINT17 — the week's draw seed, for the board and for a.test's pins. */
|
||||||
|
get seed() { return seed; },
|
||||||
|
|
||||||
get bank() { return bank; },
|
get bank() { return bank; },
|
||||||
/** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */
|
/** SPRINT16 — the letterhead's number. Stars, 0..5, half-steps. */
|
||||||
get rep() { return rep; },
|
get rep() { return rep; },
|
||||||
@ -532,6 +689,10 @@ export function createWeek(opts = {}) {
|
|||||||
reset() {
|
reset() {
|
||||||
index = 0; bank = startBank; done = false; log.length = 0;
|
index = 0; bank = startBank; done = false; log.length = 0;
|
||||||
rep = REP.START;
|
rep = REP.START;
|
||||||
|
// SPRINT17: a restarted week re-offers the same board (the seed is fixed
|
||||||
|
// for the run), so clearing the takes is what makes "play it again"
|
||||||
|
// actually replayable rather than handing you last run's choices.
|
||||||
|
taken.clear();
|
||||||
return week;
|
return week;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -550,12 +711,15 @@ export function createWeek(opts = {}) {
|
|||||||
// function, after the money is computed, so quote and settle agree by
|
// function, after the money is computed, so quote and settle agree by
|
||||||
// ordering. Round after the multiplier, exactly as quote() does.
|
// ordering. Round after the multiplier, exactly as quote() does.
|
||||||
const feeMult = REP.multiplier(rep);
|
const feeMult = REP.multiplier(rep);
|
||||||
const fullFee = Math.round(PAY.feeFor(gustPeakOf(def)) * feeMult);
|
// SPRINT17 gate 2: the SAME calloutFee() the quote printed, constraints
|
||||||
|
// and all — quote==settle by construction, extended to the premium the
|
||||||
|
// same way it was extended to the warranty deduction.
|
||||||
|
const fullFee = calloutFee(def, rep, jobAt(index).constraints);
|
||||||
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
|
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
|
||||||
// Per-job override, PAY's default otherwise — and settle MUST read the same
|
// Per-job override, PAY's default otherwise — and settle MUST read the same
|
||||||
// source the quote does, or the job sheet promises a number the invoice
|
// source the quote does, or the job sheet promises a number the invoice
|
||||||
// doesn't pay. That's not a rounding difference, it's a lie on paper.
|
// doesn't pay. That's not a rounding difference, it's a lie on paper.
|
||||||
const gardenMax = nightAt(index).pay.garden ?? PAY.gardenBonusMax;
|
const gardenMax = jobAt(index).pay.garden ?? PAY.gardenBonusMax;
|
||||||
const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax);
|
const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax);
|
||||||
|
|
||||||
// Only hardware still on an unbroken corner comes home, and at half.
|
// Only hardware still on an unbroken corner comes home, and at half.
|
||||||
@ -566,7 +730,7 @@ export function createWeek(opts = {}) {
|
|||||||
|
|
||||||
// The clean bonus. Per-job data first, PAY's default otherwise — so a job
|
// The clean bonus. Per-job data first, PAY's default otherwise — so a job
|
||||||
// that wants to say "there's a lot here to break" can, without code.
|
// that wants to say "there's a lot here to break" can, without code.
|
||||||
const job = nightAt(index);
|
const job = jobAt(index);
|
||||||
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
|
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
|
||||||
const clean = collateral === 0 ? cleanMax : 0;
|
const clean = collateral === 0 ? cleanMax : 0;
|
||||||
|
|
||||||
@ -642,6 +806,15 @@ export function createWeek(opts = {}) {
|
|||||||
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
|
gardenBeyondSaving: job.gardenBeyondSaving ?? false,
|
||||||
gardenMax,
|
gardenMax,
|
||||||
client: job.client, addr: job.addr, site: job.site,
|
client: job.client, addr: job.addr, site: job.site,
|
||||||
|
// SPRINT17 — the invoice repeats the constraint it was PAID UNDER
|
||||||
|
// (gate 2's requirement, and the reason this rides the settlement
|
||||||
|
// rather than being looked up: the invoice for night 3 must state
|
||||||
|
// night 3's terms even when you're reading it on night 5's morning).
|
||||||
|
// `taken` says the job came off the board — the ledger records that
|
||||||
|
// you CHOSE this, which is the whole of arc 2 in one boolean.
|
||||||
|
constraints: job.constraints ?? [],
|
||||||
|
premium: premiumFor(job.constraints),
|
||||||
|
taken: taken.has(index) ? (taken.get(index)._offerId ?? true) : false,
|
||||||
// SPRINT16 — the ledger. The rig record IS the work history (the night
|
// SPRINT16 — the ledger. The rig record IS the work history (the night
|
||||||
// computed all of it; the week just keeps it), the warranty items are
|
// computed all of it; the week just keeps it), the warranty items are
|
||||||
// tonight's sheet answered on the invoice, and the rep block is the
|
// tonight's sheet answered on the invoice, and the rep block is the
|
||||||
|
|||||||
BIN
web/world/models/sail_post_corroded_v1.glb
Normal file
BIN
web/world/models/sail_post_corroded_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/sail_post_corroded_wrecked_v1.glb
Normal file
BIN
web/world/models/sail_post_corroded_wrecked_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user