phase: 03_simulation_movement_and_interaction

**Final verification pass — phase 03 `simulation_movement_and_interaction` (all 5 task files already in `complete/`)**

- Verified all phase-03 deliverables exist and work: `sim/` kernel (`step`, `state_build`, `rng`, `movement`, `collision`, `interact`, `carry`, `replay`), replay fixtures + 3 committed goldens, `docs/SIMULATION.md`.
- `bash .agents/validate.sh` → **passed**: ruff `All checks passed!`, pyright `0 errors`, `undercooked-levels validate` green, full `pytest` **1033 passed** (coverage **99.71%** ≥ 90% floor), slow sim stage green, repo-hygiene clean.
- Perf smoke (4 players, 2 000 ticks): mean **0.049 ms/tick, 20 423 ticks/s** — ≥ 10 000 ticks/s criterion met (test lives in `test_perf_smoke.py`, which the gate's slow stage runs; the overview names it `test_replay.py`).
- Completion criteria: import `undercooked.sim.step.step` ✅ · `tests/sim` green incl. determinism + goldens ✅ · perf ✅ · constant-change-fails-by-design ✅ (committed-hash equality + mutation test naming first bad tick/field; goldens never rewritten by tests) · `docs/SIMULATION.md` ✅ (constants, 8-step order, purity/determinism, re-bless) · full suite + coverage ✅ · app launches: `/healthz` 200, `/` 200, `/api/levels` serves content ✅ · replay CLI `check walk_solo.json` exit 0 ✅ · no regression in phases 01/02 (their suites green) ✅.
- Documentation: README.md — fixed one stale inconsistency (Status opening listed only 01/02 complete with 03 pending; now lists 01–03 complete, next pending `04_kitchen_pipeline_and_scoring`); all other README claims (CLI flags, config table, shell text, replay CLI exit codes) verified against code. AGENTS.md — verified consistent with the project as it stands, no changes needed.
- No deviations. Next pending phase: **`04_kitchen_pipeline_and_scoring`**.
This commit is contained in:
2026-10-07 02:46:10 -04:00
parent 3426264ffb
commit 4febdfe780
41 changed files with 17690 additions and 23 deletions
+46
View File
@@ -0,0 +1,46 @@
# tests/fixtures/levels/_replay/pick_drop_pass.yaml — the fixture kitchen for the
# pick_drop_pass replay golden (phase 03 task 05). Not campaign content: it is
# loaded by `tests/sim/test_replay.py` through `load_level_file`, and the `_`
# prefix keeps the content loader's discovery (and `undercooked-levels validate`)
# from reading it. It carries one of every fixture kind the hand rules need —
# a spawn shelf, a prep counter, a serve window, a trash can — and nothing a
# golden must not depend on (no hazards, no pipeline inputs, nothing a phase-04
# rule would reinterpret).
# Schema: docs/LEVELS.md
id: 99_replay_pick_drop_pass
name: Replay Kitchen
order: 99
difficulty: 1
time_limit: 300
target_score: 1
blurb: >-
The committed kitchen for the pick_drop_pass replay golden: one shelf, one
prep counter, one serve window and one trash can, open floor, two spawns.
grid:
- "############"
- "#T.....P...#"
- "#..........#"
- "#....X.....#"
- "#..........#"
- "#..G.......#"
- "#..........#"
- "############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"G": {kind: floor, station: trash}
spawns: [[2, 6], [9, 6]]
orders:
pool: [soup]
concurrent: 1
spawn_interval: [12, 16]
deadline: 60
hazards: []
station_limits: {plates: 99, pots: 99}
escalation_notes: >-
Not an authored campaign level: the fixture kitchen the pick_drop_pass replay
golden (phase 03 task 05) is recorded on, checked by tests/sim/test_replay.py.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
{
"name": "carry_two_player",
"description": "Phase 03 task 04: a scripted two-player sequence — one pass (a set-down picked up inside the hand-off window) and one contested pick — reaching the golden state hash after 12 ticks. Regenerate only on a deliberate sim-contract change, never to silence a failure (the re-bless path is formalised by task 05's replay harness).",
"tick": 12,
"hash": "6952e2a139770605f56ee188f988f3d5cfad044b6b9024578583662793553e00"
}
+151
View File
@@ -0,0 +1,151 @@
{
"name": "pick_drop_pass",
"level_id": "99_replay_pick_drop_pass",
"seed": 24301,
"tick_hz": 20,
"record_every": 1,
"tick": 139,
"hash": "9d580cb87be31b959336883d90c9023d8add1f589436d219d5b52e47998777b7",
"hashes": {
"0": "e60663c7ef05691d1a57a651107883bfa03efa6a9328a4c67c3d94bdbc04de11",
"1": "d01f3eabab5be03c78854c25813a7d00aabadcd1507a6d6b3f8b8ee79e5d1d75",
"2": "7ff500659d5b7a172f971dc1346b9babe3286ee8c62b5a5410b102ece1d0d5c1",
"3": "e5cc0227f291684b8fae99df579f1c77c25b6ca38e1a7123abf2654108383b52",
"4": "a354fd79d257157a6fe9e6c673ef08129f16caf6d25824b8c4385b7e85f6977f",
"5": "5518ef0776f1e0639145c963307af322f1f6f2b61afe7e33bead87f33566938c",
"6": "3275d1263aebdb26d59e65b117f398d1e320ae24b9dc0c394ec1f5087dadbe5f",
"7": "e560b01f13d9ec8e032d8817a0d6cd1dc808fa126f95db9616f87d49d2c5252c",
"8": "3ed1e0c20b9271962b7a62ca630fcc2bf1198eeea943c6828aa7bbca37ed0d99",
"9": "7bff758f25567adafc019ed95f08721da9c6201389f12df65265d04ceab1361f",
"10": "7ebe678a1b643a7f421b2357a9c55c4a404e416020c1f6b1f6cd50dc00022f34",
"11": "ac049e864c4e5f76351830401ccb63e4983a909ce263d0aa6b498c89fe2715ef",
"12": "27f723d651c9c4e7c4210cdf899dea008c445c8159bb6bfc91173d9a0ab8cdc9",
"13": "a5faaca7f13fdba41bd072104bcfeb49c41e7effdf34584442611d959582f31b",
"14": "cf99d26ad2fb8136c96d774504cc11b1ea3741e375be8171a4e8ca1635089524",
"15": "15c37c0608fc5c3090dbd90b10067044a700e58cafaaec8b9599bc4d0da1c1d9",
"16": "b37198229f13a0d9c0e7c44cb307fccf1a6e8282bfcb26a462072960fa293a2b",
"17": "30c549ef5af56a59c5e872c41bb03419416a5c23ae4f32609ca7287a2659a0bc",
"18": "93361439c1a043f822972a824f857f2d1289be416e4dccd6d64a9687ffd30203",
"19": "fa943153750091dff80c99f8b5cfeb1dd44e40f31664ac684f41e06365816054",
"20": "f0ffc4e130fe2ded9382a666b79166576a8ce201f59b69bdb004f6a189e41ca3",
"21": "5764f8f49fb14a73ce1d38e5fcf57549d965ca9484a5311ddb2bf9f4e20ac6ad",
"22": "c4957b341b6b69b0c322e059ce38b5b5fea48402502dbd8e3ca0822b7fe3300d",
"23": "485810da13e46839aa2a569645ca98c455fa878f6565d9856a4df139824fab84",
"24": "59991c9e2d535af5ea6511b536300eb0196bc3ba70d5b8cd9d18451ad4a3a2d4",
"25": "597df9d310b0f857f2dbaeea5996f9eecb04ba7c021ded42796d08eb3182abb0",
"26": "dd4bb0127c745969141e7c98a9bc94c02e746c71fbd25c679d949902536e406f",
"27": "c3d5464bac84de45dbc167d16cabd42df31783488cb34d6f298000a70a2e2cca",
"28": "5eb4ff0eb246505260329334fc2032fa8bd33213e4bd3e0ed2f0946f4a6b8299",
"29": "0953bcab5e4f4a9f9517125e70425b55e5dc6f101e99533d59b2bfae5f89e33f",
"30": "b0224db100c2591acaa5f82a87064e5a273432e57a812a4430e7f11a6b7069c0",
"31": "c6e23c7715bedf693d4aaa58c281853aba9cff7fc645af609da8d33c1f8c7d95",
"32": "f4c01f3abc9d04d09e4499749c95d699858d6ec9ff11a1975e977a6023d7617d",
"33": "63c05536886f2145ae264c465e7265c542fc0aebe17d8b4e1e4a26e0b5a79bf9",
"34": "5aa0a46418baa86dd39d37a14b146f01c3ee07e94c5fd185f33ed2191a1fa666",
"35": "c187807971fcb8388027cce9bf4eac08f34d26e15d231625110291cd0a82752f",
"36": "a877f3a9b051bf0992629a1e95e0ed419c802b74befdb3c79cdded5521701ba8",
"37": "0c7b2d9b412be16f96f36be3fe5d200bfe18c78a68a950ab5dae770b0342819e",
"38": "70e3c68c87440d08de51308af9b9539d5538e1f3dc878d1033035cb164e4e9b2",
"39": "f6205bf5c5b178de5abb7a4a1e96bc7205d8f5dd34b67b7f3fb2899259a65ae3",
"40": "75f4c828f487a2cf9280eb4bb8552d1583122e9cd79c1db32394217561664181",
"41": "d7e24eb0dd4ed15013c548b5f7b149c730000841b49ad7f20c6a577008c3ad4a",
"42": "b65aa60ff083143dd32df17965f5f5675d262dc9c29e7708a44af9e893030c3b",
"43": "e9606bf7c347dacdf7ceb791fe87608bfa858d539c86ab2405ea47c582f79616",
"44": "5afd9e92c3e4cfc47bf27f174dd96680496f014ee8e49e39fb3f4a9868db8532",
"45": "af4c18803a5cef7b1ada454844c1a6e0cd05aff851f81dfd6a542b612a86746f",
"46": "f8bcc76f753e369b9ff0870a918d4733015cc7bb166ec6451dea870e77e894ab",
"47": "2847fd10ef05f59eaae2d1e1f4cc664bcada4fb8ce393ed3034fd021b0395784",
"48": "46c6358485d1a6df9c049fbbb2647144cbfcba3d111e0e187fb226a21c3186f9",
"49": "3c88c093900788eb0f6a3b2ba96ba1af28f451e13ded4a3f40e88b62d7ec26d0",
"50": "4582e8685eb51f35f0d37d0767300abe7150ff32a127938ccc29767c1e79dd48",
"51": "db666451defd17419e989ce471b394846e0f3eacbf69f0c9445f64c19144bae9",
"52": "85d3ddeaa614d979efbff9501c46ff0ac436297e057d04072a690d588729d1bb",
"53": "2495ce0509ebfeade949275d83c5102fad887b54fac7d6e9fcd1f000070df2f3",
"54": "4f9b3c44808770e47966fe6c86191b1e5ef4bf13ea3e2783430367657e9f9808",
"55": "f7981717965fc1866238fa4c2971c5d3890ce4500060de8d8c3e100c80bbf9c3",
"56": "1aebc15f5e3cc8c93d2fb24ccba1b306be4ecf3135e1a22a5dbee99211579816",
"57": "19d74eb9c9b067fb15b910585b53deea24378b9740a11266948c3efef8d8bd36",
"58": "45d9f7ab88fa74f7cf6f2d9c5d7598e9546b00d8fbb289636a91f16256a3ce19",
"59": "128480f9faafe3f565c07255883c24f66c428959d63108776e375b85d12cb3fb",
"60": "9c801c40b21f665ad02d5ec6f79b472721f039fc7cb57bc7c916a502b127bd51",
"61": "0758a34c2fc8c2d6de981c79794fa4ae609624454794d8304d7c48e406916a1c",
"62": "eeb506e5d668df7ddeb7954dbb6d0cfdc8793c752359ca854492172489c4a48e",
"63": "a507fb780e5db7fad6a1a37344375795440b7dec9f4d26a68ff5f3c748faa504",
"64": "c33fabbad15182d80982111077b1c7acd8417aad6f062a402bd17b3bb9d1d765",
"65": "60925ce607094d6f05f45ba5ee84d8eb0412624ff9999a0dc47655ec6ae283d6",
"66": "f040e5ff29a6390609632293a83eba48602e73c0bb212e6407b259b62579efe4",
"67": "353e047c12a35cf63f4904e5264c58fab59af3000208d575c8ffbfc173f9d7b3",
"68": "9209d24088f61525eb9398249aa0c6b7da7d25c304ae72e5b577af894dab6048",
"69": "570af619166215bab8cbc8ee2519d810fa784bc2af611b6986a372beba57edee",
"70": "bda87b1fd48148b2ac252a93d9e3e3a061cfa895b80c5d76d037636877d09c37",
"71": "ae9ffbece9e478acc5abe56cd7aef1672d36f3c5b5e32c088c05609f06e2cf63",
"72": "dd88a647cc58fbd9c5901887fc707ad431ba5af0595e3c4e0cc80e56c4d8ca99",
"73": "972998fd5d4cf1b3937d9c73ce299bcb9f07f3bdcf6db3a2cc6b30c7c5090eb4",
"74": "ebdfb27c5ed5bb12a823876f7852a6853cf0e9c738cb24353118d57b282101aa",
"75": "5a629cb59f3bfc786403dd69c0283162dbd80bdaedb79968bbc24b969c127003",
"76": "a0dd4d647a4bb2ccdcd938ffa5746cc622ec64a718108e848958cd1d13fb36eb",
"77": "dcf47168d56ef932f42bcfbd9e68b2d5126fda6ba4473130b56229ea2530533d",
"78": "1afa944df7462ff17bb29f5cc90b5079b93cb443f28ae797e8dafbaf428ce577",
"79": "42a49098546b454636dd084531960e65c7137bce0fa607ca1d5fa163d4c439fb",
"80": "b65b270b4a047b353e6b1e6b221a9be8373a99e6fe7f6f97226e0cf69c79e273",
"81": "c6eaee5ba8e79be6a5c283664c89acce8aac38b52a120c875350f78ef9595da9",
"82": "3393c520c7b3c2f16ed0958a03ba921e55bcf2aa12db8f9c0076e9f2b3ea1167",
"83": "ec97faff3dcb283cd7937bf9a5f902b7c8421a84fd438381f5a1e4cd69c25792",
"84": "bece02dd3c8d7c0a3dbc0d93ad30fc1cf43a2539fc076a1bd003e4c48757b04c",
"85": "94cfa8553fc8ee6e2a5a0a59192168c3cfca08313637405f609d87399bf1457b",
"86": "1bce2c11840289ee6b2a7a50ade178bf72dd0b6cd40a18797801ae13285dede1",
"87": "2efb75a35a220c56389f9637c94e5525eb252a06fefe3e41419447b6475d9e85",
"88": "382d2e89dd7da93d53b6221cdc94e66a43c5856f61581e74c81fbc6afaf6f921",
"89": "e9bd7999cd2b1643e9e6f56d17d9224c618e68ba1ab282b230a2932f08f2b9e6",
"90": "1cf9b7c72cfe7db1c1a5db84373e359b38f4f8d6e390695ce49226095397fe52",
"91": "7b70a858d6f416cfbab6385ae71e8aaba3142c2a197e2236e3a3a6089bd43292",
"92": "9d7405cd981fcb8dc60eb86e9518a8d18e4dfd98e504a19f4716806def964d9a",
"93": "be25bcf4e4e1df8cbc1919350099cf09f89b5968ed2f050c6a9494eb337bdd35",
"94": "b6c540b5e9324202ccd9d514f348be2bb7b1cc40fc49019c099d20b4054c8ee8",
"95": "1a82a2a1077959ad525d76eb1a98823391c8f94ad95c6618c5f4bee15853296d",
"96": "232868f8d5f0139acc41eaa3c451e7c725842d8602a699cdf59d59b9dbfaf0f7",
"97": "fd585d38e91d6fc1fd282beb77afc7cd0403a03d737ca25efddae03d9fa56eed",
"98": "733512424a1140985c7a1b30c927b1d3b7ae6481ebdfeee052ce75bc54ba15a7",
"99": "7fd2a3466837c6b34fe7def3598032b4ee13d169842ce77d513fea6d25da0e7a",
"100": "dd56c6df308dd77824476a30c8145e059fb87ccfbadae4e7e974ca00206003e5",
"101": "1effe26eda11c8ecb3fbf0d8fc7e256572c5b46e7684ad19aa502854783b6b30",
"102": "a23eca4f48dbcee6896ef1a620ff35e9ad3ed1254906a27ba4c8373afdc1e005",
"103": "2eecb65b7cb68bf6c330aa7e4beee8c4ab0b33a5035ff8ee391e1fb8bc93475c",
"104": "7d6ca6926397417e3e2e57b28aed80e2e0a96a4e6d5bd5a760051900e09f7105",
"105": "3dcfeb49c46d29c35b9eb4982520ec4fda9eab10c24d1a630da89db240cf6b85",
"106": "9e45c3dcf7d32a4717df781ee7afe5fefd8247533dcaa80079dc6cf4386e4a17",
"107": "d33c582f02d2e6fe23e3189e6adae706b49e23d237c9eb640dff1ef72dadf846",
"108": "b2bb4a339373f98228955bd325f3b920f45ed87b964822f361490144d40ed8a2",
"109": "58163aa41dcbca4e846bbf91e1fe605685d45dea18baf0363f1f3cfdf7df3e50",
"110": "5fe4d1ca79ba2552c7b79284d9e0c5909d23e82ef2d98756bb278d8fa0e31fd4",
"111": "0311821df15d58320ba5accd0881d923c736eb32eea3df7ba4a9da28f89383d4",
"112": "fe0c90408929dae669e6a56f0721ff132d69a31202fc04c742b1d222fe727ca6",
"113": "0f4eee893d08d2dfc095a5ead57c6a14ebd3c04d02da88074f430baa6135b2c1",
"114": "0b9a4e1cb8a44b1b5496378451ce66f75298185ab19c325d36fab0730f90bb6f",
"115": "b600152b057664eedaab660dcf6eb41915b29ca744a23c4ac2e93630797c207d",
"116": "06e4467c64ee979c5cc7b3bd9b528cc35e784d4f5ba4a92f04bbb9ba2fd9f132",
"117": "790e9f73e6aaeb10f5201ce3b32ea07b7961b4e4315786ace221807b27e3ac10",
"118": "a0f911e5ac26ce7014e59893f8a022a301c5585a3fc19ca250bbc8edf16cfa2f",
"119": "a28b511f7e3a1df69966f5c1dda0facc96e8949da9a45f9f7ea8785274d9b6d8",
"120": "0e0f90e34dc24ae6f9d3f24c714dd78e56197722d2feb55c8746a193b49420bc",
"121": "01567af813d5ccdd2a58797824e9d875115cea4ed5f660225ba843c82df417fe",
"122": "09285092ae8070e39a3f7c0f7d2d4853f0457aa5f3a2e2792775d49030b860c0",
"123": "2a2c29f0b05d84b6c2cdeb94d6e37aec771e0f148f1857503afcb7a1777b793c",
"124": "d63ad56c19d5d358d6330f066f6c9b5f870c97868d0c4b5e66f45d84f766f1de",
"125": "b1b007855def3ae205a27201bd1710a003606402ea82306640a7d28c884d4504",
"126": "34b7f6580b83a2ef6c6df2fb9b49fc510f82b2dcd4653ebec5b440f82cd5af37",
"127": "b4ec65269959f743cc9aa4223753c8ff0de45fe15e6e9aada64ef7c27f09dfb9",
"128": "1a977572f010669cc3491b27e86ae1fee237645bdc09c17dfa13403c31fe47eb",
"129": "9483930702e96f4c7a6850e027d2903c7c9d1e3c6b51faef58b44bf865261ce8",
"130": "eeb732a16a2e7908672f61ad5f2df76961796827e08374f3bd94e65675663068",
"131": "e85821425270ae2fd71b3a4afbffd68488cbc9a3bc23e0ca48805110c2181b63",
"132": "e76323b6790848bf27aabf21629f80e7810fa9a2bbc6774625994ed654c4d92b",
"133": "84477d85b1836004097cad23df02bf95f6250324ae59c23f0f19e98ff56124fc",
"134": "bf4e8b790be8758f5878f909e705527565c16c75ef5a2b95c3f3592cfbca22dc",
"135": "f93764383a8fd7b5d9448f91d686ddf17a11487a717badae49fd23a0640a8e76",
"136": "b5408f3cef5b6d72a6b2a1bd80a70b694991b8d48747f303eb9eaa8828aa795e",
"137": "af84e1b1e86023c79c87e228640c80d0ef928107d8c67230de225c4e6243ef28",
"138": "cbd16d486a3ff67aeccca06c156ed9479c75c3f7d0e4f2b79c9b866defd39826",
"139": "9d580cb87be31b959336883d90c9023d8add1f589436d219d5b52e47998777b7"
}
}
+612
View File
@@ -0,0 +1,612 @@
{
"name": "walk_solo",
"level_id": "01_first_kitchen",
"seed": 336,
"tick_hz": 20,
"record_every": 1,
"tick": 600,
"hash": "7038d049a6adb89bf759aa74868e9c2a12a5d48fd07f216a6557d9d2eb0629f1",
"hashes": {
"0": "d01b9501e91ede3c3842561fc939a8d7e9c9d350875e3c95ee6c3ef5d9ec33f9",
"1": "ed2ffb258677df2f5bcd77e0125a7bfaba16918c952d2d4368496de33f019809",
"2": "cbe90483aae91b1ce6a3be98507ccbcd75d0c58c2262c10b10c9737f97183d34",
"3": "0dfc963d1669549c3357dd9e8220d04eac934fd4097f47c9ecf2068ff46e6a8f",
"4": "6437e40e850133c067e6f6289525c6b59410dc4b382c23f87db6ecd93e275f85",
"5": "36191ce45a4b7734fa6f59c1fa3b991657f70576b031bc2f0fece692a79ac309",
"6": "8e4cd2fb715e76320ce9c717b91db3078d1016d0a5a4fa0066f461a489c39edd",
"7": "d44f0f04fed8606a45f3e210c2ea591912c2043578dabce5b14d57358966f75f",
"8": "b41f43b464520a10d0019068ef332ec4ed2643539eb6d7ce76bf91e72fc5663f",
"9": "7406dc6262c41b6eef6df56e393a2aa2dae6ac12d17627e14e8b4284267afff0",
"10": "915e3131e1c5d7c8b93065fcc5a5ff703a53d6cb502cf996c2cc29d5a7b00dbd",
"11": "4c453a0ba85375e4ba1d673f3f1399b77b79c50296b9f85c7938b991a722cbc8",
"12": "bdcb3dc0afe2eab758956704b2d56d2edca1cf9aa881f9c92ea41b2159a31a7c",
"13": "1fd49080f48adfe33453a42681fe789bfde5f8bd13c832ffb4937d51115b98dd",
"14": "469733ed1b25cad99f0d188d4fcab47d342b352a60afd9b199e436b5ae5ad487",
"15": "450be736a4fd10cebfe0a5ec42a5be1dcb04131c59dfc453b6315a1f4a070c7a",
"16": "2a24240aaac0006f82f34e990ba412edc6e6a300f935394ae6e105196f81fe07",
"17": "5d79bc60f19bc03c746d3c92f33ded071c738f12e900c26410c783a233a9f8c5",
"18": "34dc84d5da85d188e2d431a94494be8cdf432f3604a46c5d973687c0585a4349",
"19": "32b18651ca34900e7ccd680af5122cb61da90a305d865410c256edd9fe095322",
"20": "48123833336f8a0acc520540f601f79b324a0a96bf95d27e91855af55c9dd563",
"21": "b3919b63e4e264d70ee02668a08bfaf6e4aab84c7c68f415c948a0264db251d4",
"22": "7632a6b7dbbbd9047aa6206e4e2f2e17907b15e5a0b90a807dd5869d7d9b0df3",
"23": "e9009b9ca8ef2b268babdb08c90350e92e21db25ad309e9a9a3d3426645ba64e",
"24": "1f7f90589bd2d4c2890f28aabde7b6beba00053d7fe55d82e792968b57690122",
"25": "c54b2009dd4ec8cb1b028f35e9fdaf4c8c1ab6c775e47cf39c9e3796198d8f5d",
"26": "da9fb48ec6888cb61320bca87a4ce7e10184a9d2ef609f16db558f6191dfddcc",
"27": "c5b31defef3e320debe326a0465f9011365e52a5be943820d207897796a15edc",
"28": "f9843ea13359c26167c3599fe6c8c048ae3e1ba57629a845d5faa4fe87e39bce",
"29": "34c3e79ef06e38ff0a47fb539c05c8f706bc3782e7ec15f82b7838a60b3cd0ae",
"30": "adb1ca68c7d2d3fc5806c583113a1ea656d55f8777d6c03c6367e3f2f8a4c8ca",
"31": "c9be967f6a6f7dbbcc1acf36c0b7d6f0b8f62a124e27d6fceda07599a4fd2767",
"32": "eed3475a74a7013872bebb82895ea9a038ff84b773b21a3359c3565f0902793f",
"33": "6af517dfdd460b2c243b650e928bb46a7899fb70f445f8def557e5a3bdeadad6",
"34": "307cbe4c1cff3265a54fbac26a5a5f47b7a722cb8326b4fde4dc772090afb0d7",
"35": "528831422a6067d7b0be84826b9db12dfef8a0fd1ca7e0e17c32bd2d848e8e9a",
"36": "cb2db91b2a3605d09ec286501254ef57e99da3ab57b5fae688fc3888de44ad96",
"37": "e07a0e4b2eee6eb37e35b15793aa840f6589dd2c6bd80d758e9fac009ce39926",
"38": "6557540b685a2bd11f6fadbb1eab1fdc6e3eaf6afc81dc30cc697affcab87be7",
"39": "b01260c725f5ee84ac75c362fac7192749e926772c745ffe777d786d605ba425",
"40": "f486ff9b4a74bb257e613c86bfdb9f6935f6132ed4b78ff6403155c4699470bc",
"41": "0e4dcb469b3c0f1943c2438131e12d2d3912f79a9c1e44c489111c6cff7d178c",
"42": "c1fe4a80b02ea372568f9f7941820fcd3e15f8d353a5f34252d65286b6938fff",
"43": "9d2539aa6a163dc9c3fa890a4fd48dc9fcf6cb70c3da8b0c33856b64edea24f9",
"44": "205feef74d9f9d6cce6490c7dab79daea6b44665378632d885e2172be48587a3",
"45": "305bc51d81847657a2724ae32834ed683efaa5027b0cfee93cf51b438d043a47",
"46": "c641e75c08a301068c7ca2a148429ab31936091e65df833f2904945ac0b5281f",
"47": "0de968b9716f0574b5870becf2bf31317b4bc5a397b60944864c4364e143d59a",
"48": "f8c40e24d0cba876e63f4a859dd242cdf00fbbf0f9c1668120d355f5014fa172",
"49": "1c0a5352a6998489761a434c5dd9fd146ae670beab0286e15c49ec07ee3d32fb",
"50": "325bfcdbccd438555845bb78207c93462fb329feb871fb6b048fc5a8ea9154b6",
"51": "8e2a46ff7a9122f1e0fd8dcff20b6535bf60004d8134b8b4b35d8c40a1733430",
"52": "26fdb89d8389848654cff00d2fde6977bc9447f00d0786e670ac165267f89310",
"53": "b450c73aa15be721d640b09cb727d4367d57c4837d0c00a4cd2eca0b8c2f65aa",
"54": "bfb78312a5e8c03124634f0425ab174b8d1b15bd4930bf76efd5313318d4619a",
"55": "96ba9c24bfd0c6affc00b708abc712c086888ab431c0b2635424c1ddae0a8cdc",
"56": "eb369933ddd4f90c3774941d04a96e123f43b466c87204e968db384dd0f56deb",
"57": "f5f66906a9c32cfa85d33f04611db87c1e865513987e426e5b180701aed4ebcc",
"58": "c65af500d12ef6a66237fa4f0347ee4b819bb4658dcd585ea9adc705f601e9b7",
"59": "14336200d7e1c602835f790babeb4155e3e1bf732fd18e6a6bead07404cfb776",
"60": "ca814c33c74f01ea85c2d55a4f0e15e758cbecd054475c1f32e534cda4f2a164",
"61": "5bd9ecedf3da67fe51928cceeec3d00fe2353cab9649f8641806dae410692d5e",
"62": "decc64d7dc92a4d7538f7d0262fc0666f435060e9de33cee28eb7054868418c6",
"63": "caecdec151880834544c1f268b0270679f4595f45b91eb5099db098d64695960",
"64": "5f34d76af6062e5831893815c1231997229ba82fcb5b9bbee667eb967684cf4e",
"65": "07e04f5bf6a75663162ec02ab907897241ebe35bf3fb40668a1d8c852f9eea82",
"66": "045c4d80044b9f832502e8ee7f1e72663f0773c87918681fff2d4a3ef8813d72",
"67": "51a78bfb6c507dec66d8f1020e9d5a7f95d98c948d60c9cc85775ab98ba10870",
"68": "85d21ab061bca1655f18754392eb00fc27a242e32572071c6838447e0c4b6288",
"69": "56a100e0a8f0de36e37b186907dc3a10e3eb478e8aed97d0ccb24f390acdf2e0",
"70": "589dbabf11fc198da3c7f8ec12272abc467323b060fec7270b58d697acb57c96",
"71": "a30cebc65671da46c00c34edff560f6108043482cb469d388a0c25e7046591bc",
"72": "8e84fd18da4caec52f1a49b6136036e598afb323bea53775b2526f4461d7c082",
"73": "ae1a99e325223f51577473eacffa7b7c25e599071af365d5567ab6455f54bec6",
"74": "d0243bd0e5b97c61c267eb325b9b1b656cce642508b4b8a928a2b5fb3b1cacfa",
"75": "edf1c0187cd94590bdae9d7864a5e582570884473f0bc20912fb97d76bc8741f",
"76": "41db46328e7c0b8d7d8f0fc95c1c153f3e4fbcf21620eb097b9a4cda17b0bf28",
"77": "b81d78ad83780a947c826e3bb74ffc66ba53957a581779a2570b43d0c910e676",
"78": "bc3fa0fe738e72d317e16cb68841b3d6d53415870aa3f9526b28e7ce18dffe37",
"79": "4f23bf35c1ff3b80a9b475abb233f4d6dba173bd119c7716459f15da609a19c1",
"80": "0492fc7d44e13615a2f257058c63bc86341398ae0ec0f68e4cd9c4735a405e7b",
"81": "daf556bb53ad253a93a9ecb0656a36880793c77396897e3c2a676eb3f1db8a72",
"82": "2c38645fe7557934aaa0014b9bbd7cc930d3ed9e2c4bea74bfbf30d4f27c27c0",
"83": "59caf4e84bccf3f9c65378ff4ee0acc2dc196ef241bf19c78e3a8dac922a1aad",
"84": "17f95b8dcae6e2eac5a90a040bf583f2d53ce4524f517014ce8bfabf6cec75f7",
"85": "17afb4b428c00a92cc17dc140eff704f8117c371b200327057ec74803bacb6b2",
"86": "e84a611eec8de8526cf63ea61956ad1f6338a61a445d6c4ff3d8bb25db6956a5",
"87": "f72f94aeb6e74e1272c797ff26a41f7e5a8db99a472b426797ed6bc27509f1b9",
"88": "4f24b2ba60ff2fe935114d4eafa308defdce4374647c2a39b86541c1b9208943",
"89": "a3fc5acc85514200ac8239a3edd4532a99ccc5f08f50bff928c876f3304e2f9c",
"90": "25d8bbb4fc1065ce1df0fb7350ceba6c58440b27c1ded9de05eba0688a24981b",
"91": "ea7407cc010b03a9f0ffb94093aa49ba639b8a6ca9b618bd61bf0e0802ecc36a",
"92": "9ab2b8b92252297ff9d4d0f028e0e0d2f9d66dfd3e2ca84c46cb7522f0f97c16",
"93": "f3d17094c109c49f25cab886dd52be4583148e7abf4db1f24d743b64e2b99034",
"94": "c6d53b3407dda17ab47227b6ddde6e2d0083dc5c8557582677a40c4df6121410",
"95": "038ce780f79ba11ad16600790c2e93e49603567ce23a9577f25d04112fab233d",
"96": "8712621000808c35c61376621c0cfbd9cd45200fc7be2fb2e4506b5803b79bcb",
"97": "bd8a8746eb18adfb4fe4f4f50c03e0bfb013c58859b36b629fc5bfdad6969af2",
"98": "421ada81aa8d5adfb4632ae3a317096a6b186ca95b744f540d26db2e0e8e3271",
"99": "8c7682dfa4f1928b579a4396f9a8ae908141ae8da6a898dd9b3aa0a40f0366ec",
"100": "7592be86af7cac18414e7f103da9f51c75b5443f4701b2f57c35c9e4650c2648",
"101": "746e44db400a520a6b9a74de6fde44da3609c0337f3c83876443575dbb69e716",
"102": "8cde025b8bceb2ae76a5909b08ba0296da8f6b0fcecb0168a04ef5e05411ae8e",
"103": "74a8a17384f066c93b60ca9e9d64432c62d5c218df04419011a7d134c78fd8e3",
"104": "968159410755844d138e3f5021e1b19609d9ed6f0bcd928a20a0acabc1463819",
"105": "790365bec727b9c673ed4a3a5f2b63f75f9f7dfecad1c4917d7c87cc0672739d",
"106": "5b4acdc945bda4efffefc30babd9a19db31c26b624e1a33743bcd93927963ca3",
"107": "a3b3a41a194a5059f5db31c6e3bc35bd60628399729967721d09b8f3dce34f18",
"108": "cbfda32a9dd3766b7a6699894df13a38f7d6bec446e87adcb45a7c9c0cee6d3c",
"109": "1b339403de5c6e326d092203d6f9115a5c3e1a0083e2e66b36955612c1055c3a",
"110": "8b4a726ce04fbe026f054bf0c3f54a409d29f35ba8f1205dcd965abeab6d4055",
"111": "dabe6120922a873d16fc622b64108078eb7c661f67dfe9860f9a6b16e2f752be",
"112": "fb18fdabe7750baa272f6832f39fac3edea3501f9fed6e23ca1214333a04b7e4",
"113": "782316ccb106671ef28a2a816ea859d2d0d744c5cf19dc303f33248b435b03f3",
"114": "9ea875117355f0ddb7adb8ca59e27885f5bd13bfa4e2880350cb7877c2f15c46",
"115": "cef559aa27f540d9c125a0d1a3219b26cbef6d6a53e4c5ecc1e506c100425fe3",
"116": "17477c03ccba60df2a94dfdf3ad5b60f63bc54220c55e450a94622d428d3fee9",
"117": "22bf8b5930e503b8126e0b0ab70abbffa7b32e0832bf6419915a08eaa2ca1582",
"118": "2e4481288c84e62eb6a7f9b81f11dc110d1dfa093e65336b8c5f4607c8d78c85",
"119": "64f3ecc519a130f67237ea2169fa2fec22b7a8fad2c09e27fab17471fc2bdc45",
"120": "543ae4d26f2725d71b9e2dff94f27f6435d34fad164bc65713fae6e346e52c04",
"121": "de0e27d6596ec501ecad6c25f3179bd2172179f0e01890a62b48b4d18695610b",
"122": "5720ea4a5c69f90bf2dfaa669e1743c7b2706e178616e7516d17192aec4fdfe1",
"123": "fb6b89a4ccc2d4edbf334330d75dc75a98a9252a8191f3757cb7adeaff768cc3",
"124": "326ecfac0314a0167a5ad710eb60cc0e04490b26b166df4af2a2b96e56b705f5",
"125": "c7604a66c5e579c1541cc5554b64b203ecd8df2f063e61788c3174dd4a4fb47c",
"126": "bf94dab30c5d50abfbd037762b459134f5b5591e8e0a3e0bc15e89106137bd75",
"127": "7c81965aa4b26ac9ccb9f331fd3c8e4da0201fc7300dd2fc445fbf630d6fbfed",
"128": "c2f17944d97c265362d479702205a8396fee1e9933de96c8445b6a4ab1885dbc",
"129": "ce05686fbb227c6d6757bfefcdfeeef7e7ec632d64cdbb3afe78bfd01a5abf6d",
"130": "85ed2f81a2f94a323b9a123ee652a20aadd16f6103f8d1503d27d5403b67085e",
"131": "b7f258f810c5d94a3293acc93705ae60d64a106886224867b9e1ffffc688d560",
"132": "953fbe5482f8502a04b701c96ea46748d6e11716de56b42f4997d72b92977d4e",
"133": "6d51e2c0a296a4b5f52ce4fcb36bdf99028920c02e5d6d2cc1bbdd0af128e9e6",
"134": "a8a616c7259ccb851b0b34af88626d3c3acdef60fea1effe71950d772a1e6ee6",
"135": "fde295e71fa889ea4748717ee7273cc44ddb0fabf4f5d730911571414aef388a",
"136": "253d9a9a7f10bcc6b6c0bc8b8b81458da4a9696c50b712a9aa832ffb67f94f16",
"137": "2cc0d43168ed1c233bd8ef0d34de867d8efc1df955e987214941f1194032d08a",
"138": "24239f688786f05304561f4af42ec131e6843841ba26958ca164e131f7908fce",
"139": "773b6deaa75c2990e138edd2cc4e107818488d96dc965f8272d260db411173ab",
"140": "577f100c64e669fa7df191e21b2a0b128ec4dd0665859c93ea42fb386bdd4d7a",
"141": "bb07f67b4920dce93f3311ee7ccec00be5fc8d2ce2ebacb0de0212798c532392",
"142": "d9cfa629aa9cee52a2a2f8afea2fd6e1ac845b7ab0e664937ae03e3e293ec0a1",
"143": "24c62fbc5c26b6762f45b0aba105d34989fe69151d1e9be4fd62feca8eeb91db",
"144": "3d884a5734ade60a4b78b00025bfaf43e6b5421c0d6db26aeecc7868dea42837",
"145": "c4a0132c68e2b6462217b3cf5e98f19b505f14e3bdd07788746e21943833f77e",
"146": "cc29147e5941c4ebea543fabca3d49b714b13edf5060a7c4b4ae66ece1aa4469",
"147": "7c6df6101b384a9ef76efcaae6f73189edf6b7a3c3a0165ca97ba890ceee7094",
"148": "05410190158e4354e2b5b4776e5c23622a8485a48955bf8601b02ede2cb59e21",
"149": "1bf4f4d6e6428d844077d00a22376509532a8af05c8612ea8cc9cbba3fa09325",
"150": "0a5259e26f61e911820003d9fe92ad38035190a966461186f72ee891c80e765d",
"151": "1a5892ea4a5b741b8caf78f7fdbe99de4d23fcce41d2be8532a4e6a0be0897e4",
"152": "a3947241dbe1e28f990bacd6c52b8333e409f9401fc35feb35dcf4423c41c093",
"153": "d7a43947656d6326980c8c5ba4d75694ad39568cc8c1625490a41717cca794c4",
"154": "843963a85adfd6ab6ff0f73aa39d1e93ccfb9542b1ec6e6fa7bcdb5a66593052",
"155": "bd456f1c4d5045776aef138f3c0890c23af97e19429906ff1ee09cccad9a09ad",
"156": "2b58b6188f4ebf6055e1b47a923f0dced96af032295136b61c527385ba3df7fa",
"157": "cff6a3f5657b88a0ea0c9e99fd270344291daa3e1eb669845cda787ea1c1bf3e",
"158": "040c8c82d61c6503b81ffa9ea331570ad22da7bafbe8360243a99aa562c093f7",
"159": "e0747e331564ab030c2eee3a748a4297cec064322931a1a6cd572dcd4da31e74",
"160": "97f43f383a8299f5e57e32565a307ab7f06324d0424cc97b7d902ed93763d2d2",
"161": "7fbce90b7b643b7ce93385500092c0925cb27e7a55b1a585e8ccf7abe44b3bc3",
"162": "c188d2e4087868cde87f0ff1e5262d9b9b0e98e1c8ba51351a52c504ffa42aa3",
"163": "596e8079a7b37eb5644e05af686898414834188b05b94f1c41c6fa0cf9794ccd",
"164": "9a1525f122c8ffe53a5bf95302cc32c9186af56b3fbc264df3519bf62062d2e8",
"165": "15acf2842be9f8db79f046b97c734cb3cdb29f13c2fc830cb0495377baace337",
"166": "418e039e7fc209b1409abfdce9b84f5b5ff803bee983ebc88316a328af458aec",
"167": "b55eed845e89db1631dea36de08da8dc9cdd78aa92afefa14ab9dc0c71edb413",
"168": "5d9ca6d17df00fa61a8daed37f17e337a504e8a8e033d8f61b0d1c3eeb0f9477",
"169": "593f70673e9aefa45d05e3b5ac6223780970f550127ba384e08c72f8d88a966b",
"170": "963c183a2e0b2d3d7ea755610e66bd8492d314ad03bd70c9ea10f289b2a3794b",
"171": "404b14c18020c3e9bfce174e69783a23f2963f6b8356521009c51b60da4bca41",
"172": "1ed2cf3b1849e4f8f888208b9b6b2b137a41662bcaf1770b0d4a3592901270ea",
"173": "1a9b2f6c76a780622992442bdfc29f71a61c4e6e9d6fde22ed1ffba28b09e253",
"174": "0734ab5ec48cedc01026c8757c0e15f3b53356965f05777396bb5ee680e154ad",
"175": "78ad72a81d47a758941c043245778757b1e4f6b4e72b94121593a38f252fb67b",
"176": "873d23682e6dfe51791d2c7d42a78fc8321ad75af4da4091d48cd768d6862346",
"177": "242c896b79dca24c48fd69c6b9988f16a058aa86f9ed002fa06485f1382c107c",
"178": "7957747cb34b82f29ecf82891f2590e1fef30f805213067f2dd39b357f948fe6",
"179": "605992872da4520179890a8c3e48caf1833cf7a38fc8e32850e968e07cc37a4e",
"180": "88eb098577ea4f6664004a96b6bfb2d884dac7906892058640378ced741bca64",
"181": "a8699cac0dc2f7d992dd7eb5de5d794cd7f5ed1c42202629229832bd1965f8ed",
"182": "d4585441b512f898f0231e09c0ed4548d0ca6ac25855c70dee2028becf3d2e55",
"183": "ca4696c3964ca48ce7c29f4999ecbb41117da2e4c2de50f6a63c661b0bfb132e",
"184": "2c783cf6f4cba1a223e9fdfa86e822a9eee5fd377f5694c997f8c0f2cb1b204d",
"185": "5104bee1846a3f20ba592179b4d94d33686ccfea3ecc4482bd09187793e2fec9",
"186": "b780a411b581b494108941fba2f5945c3d7fcabe6d658321a1963bf729f83b14",
"187": "952d416aa6657b3ebbdf8ecfaeda33c4d4ae0060b707b1bb90ed62b1bdfa172d",
"188": "990595481ec8ecb500bd8f83fadbb7df7b7eca696215f7395d379f60b223b79d",
"189": "563ae0d0f4e5dfd306fa5154efc13ad310f02679e1741912480e469ec8be3fc7",
"190": "1dd7b1d1f8bb8f610480b0299159bdde4bcc16814eecc5f13c1d83cb2aaa1205",
"191": "e8fbd7d496878fde952c3fa1ef944ec2d6b4a0655e16d1113be88b6d9e44b13e",
"192": "22a577becc2190484ff1d485faeaf8ca96d0cb850eec72ec733ac8118ad2e842",
"193": "86d1576ddefeb9f7092df2584a17c83acfa118ca9d20c197b7d62b93a2222347",
"194": "7c7b0c75ded6aaa94359d75666112a050a29ae2eab91493b037b9cc421a2ef15",
"195": "1704f4ef93efe3caa8bb0586e008fa9de3249b048c39dad6db7760548ec52948",
"196": "e01f5f7a3b0a71ea0c4d68c99f81ea853db3397f36fe3c37c021dbc218c201c2",
"197": "990cee00681394d3a6fde6a5d03a93959a2a00d5150994448f2237ec120c0472",
"198": "b5e047c400d83c84a18d2471f194da0215c4cac7e9dc356546989157bb6a13ab",
"199": "ee5f9f804671f58ccd7401d7695b21fd6663259b57d3638cf37015aa30174831",
"200": "9177add10943a7ae2f902865d5c22fb84ed43657473e961541a0c2bf0ecf4e2d",
"201": "b30070181883b3e5a215161aa11e95e36efdebd74e5a75792740a625d95c454d",
"202": "48e930517e73ac3b379eef9a94e1ea760149184ee99d163fc302f91efe3d44f8",
"203": "bbeeeb350af58279a1a1047d849492433a59cfc31ddb3148b433c613654dcfb5",
"204": "872b913c5ad228d4d8e499201a083f87baa35489098b10d723e572ca32844400",
"205": "5fa7db039a14e366ea38c7d4451bc141f67bc43f287c33396f331c83077157f1",
"206": "765293375e4a75733216ec8bff7ecdfb3df774acdaadec1ba311bacd9db86e0e",
"207": "746a5e78ad8dbb20c641ba4cccc6857c2f39e647490824a8b93777765e3a5617",
"208": "bf804008be553221c49517404821a06c9bf3ca19a8f11222e3983519e5dbe12f",
"209": "ad521daee1668a74caaea42124b3b7549697326a187d3410eb92380c5c819dea",
"210": "365dbf7cc935a42e886663a5023d60b165b9276557eaff97df6869182f1ff890",
"211": "19fc9ddd3e6d75a8d1ac2e47e8289b4a337cf0383affc196bfb6989707841520",
"212": "7ccf3f83c74709da8b23825d1e62f4347a80ef1a08fe64856a1cb2c331607673",
"213": "30a4fe1da020977c60e2804e54e696195992a7a067b2691422a5deb552f36d3d",
"214": "a378177df35981a28ffc7c9dc9c3e07e638a175e6b183eb1932107e18641251a",
"215": "ec559f63669bcc09c20a7f7d2ec04d10170e610231244f37d28a0dfd93647e03",
"216": "5582a0ef29cdb600865330ffe9b7628aeb7ad2d0c9be06225c90ab67d93815c5",
"217": "4b5f4875e028913da8a6ad65de48fe7de281dafd7362a2be43a53d3061a6d7e1",
"218": "ee4c7e484d83211373f99ebcf7825072f90ce1783bc8034188d162e560725f16",
"219": "cf15182203f28559b3e1556df7b4cd0a0b9c1c4fc969471df3d2ee2bf3c46875",
"220": "572cfe1cdb5da6fde0e9d4ae4383231149e8ea519d88343c44c37363f7c92d66",
"221": "4744c7b8c96681e86b970e992b20b8d1c9c2a3ecc6b94d2e431be4458484c31c",
"222": "b1fd61f3bc75891ccb90fc3bae8778c34d8b406f665aa0453ff85fe00a74f187",
"223": "a6dd8a01141e8e3f711473c9af0ec85663ee96bbe722b4b92db7a4f69f0b6a77",
"224": "1390d017598a17ba3090dedcc1b32f10c0eaf98124344895f30d27d67b1aaf07",
"225": "fe5dca66b3e1236a73739d15e281012d948006d59d552a8530cf0528f5fe859c",
"226": "42363a078c943a3cf5a4aa4c6d06844f3bd3e22ee9db5aaadbfdff6cd1b42a55",
"227": "a8cce5835d4b8a7b938b9faf32bb3ac7cac7d91833a1c02585148383075eeb8d",
"228": "090625867de43be2f49b8bda1da2f71042779fe1f411af614f1b466f3b081bef",
"229": "2fe62ba4ec06b78c07ea11bd5d2713658ad19174825c1142798506d885882067",
"230": "bde5a534a55bf83235094c8038fc9e6c64f72309ec3b1cc85a8168a79792bf58",
"231": "fc4f206a10f97712dd72931e2287f15938f9a3bda96e196cf8159837e3348ef8",
"232": "10edf604b767ac263086cff145800cff564c7a704f7dd19d98f4bde8d83314c5",
"233": "c1aac07bc586103ceff227012bb4c5e50fec142d8d50bf72886b7e6e1fc53581",
"234": "24e4eb30ee46feedd9c2cc1946457bbc93c88641ab8b381d7117575ab84ea930",
"235": "17df30fad789586067ed3a6a47abb6257fdc85ea61f98e0861d02ade1f5a4679",
"236": "535c9f6334871ce365159fcc15d5be272903b290c4050e74b0f6146cb7a5d677",
"237": "ee0b039ca7c493b5b5d87465980212645a59c9f4b93faa68bff60428edb5feba",
"238": "d0d28e7150bb9c15105c62b9b0189be1263ad9ba2e9b332c01aa70954e297d0f",
"239": "37014b538ac029cf429b3cbebeaca51f6101a5e20f429965559abbebd5934a08",
"240": "a9d694b0e01a95db6ef28352ff598effc1c33010d08fa403760d0f0f37e66d4a",
"241": "17931cb5f3072868020673a95e4ba6926224771f6cf4ded74f8f84347d71dcb2",
"242": "a2f5b139b7c0d858ee79f693f0408f782fc13cf3bb6f10952e261bc0a9b0b6ee",
"243": "24894e9967cccf8b53560626aade1d312fcc01b3ff93f81cf8bcec5ec94d9781",
"244": "ffd7f30a45f34824a127bf36bdef3753af5e65f2613552ca082da5e82681bfe6",
"245": "9a8cdb5030e8b996347895494c267430562ef8e21de78991ee4eb4fe8112a355",
"246": "500ec97b1052cf8ea4a4330ca4648c6540fddba0a16619b44324d448aa3053d8",
"247": "0247aac22cfd3ca87b2e6d3845ad97e801f35c7a72a250bc041c9768942a5668",
"248": "da684178b037c5d41fd416675ba99108961e56a0c02aee1e19916c042d4445be",
"249": "3c679c8b98f58c36e652758254982945c4d238340678d7be09819e2b17ebb762",
"250": "7fcd02116703c6d3a81331470e295d03e35e8c7cbc2bd45e858b5b61aeab67e1",
"251": "34abeb2944c0fb41f735590ba6313fcf7b8797e1c649ecd790964bd5f1ed2586",
"252": "512c16176e77d7ad07016c485a1055690197e7dca7c093a3d85f650e109df56c",
"253": "23e802dc0b27613ab15c73ec577ce102f54836fdee27fec41a25925053c7e6d0",
"254": "8e24e0a584e441d657700cb36fadb7dea39a1bba3edcb2cbcf1cee6ea7ae8e01",
"255": "439f61a48e832899a56c95de3f659d863371211f1e18442dd7d1ca410962470f",
"256": "08bed4b2febe6b122409d2f714fcefafc106f51f6fad6dd8c9ad432e85a6be46",
"257": "36a29f044741ecaf2a2d173cbffb7e521c69ed4702d756ec1a819162ecad1e69",
"258": "932d750fac6e97abecfb147a559dc49ab9a7db2c326ad878a3aa2598f6db824b",
"259": "7e0ad0a7445687b0a769d94ad899e0f63f29c9b02b173db32d8ea64632bc292a",
"260": "cef87ed4575ee73e9010dc215b6df135e902be9f7409d3815940d29815999fe2",
"261": "1c0355aa9811e25cde9cd353b3f32288e7fa6e28f6edacc3e879e1a8788a1004",
"262": "21184255fbc4aa88bda74009c09ab654a6aee6c420a2b9f209219f3f5dedf7a4",
"263": "9fadfca376b65717eb84a64a597f024ba5b53d4585eb9a37ed82438a8161bdc4",
"264": "c4256c4822b9e11ce2daf6f40fe7966bdc825db4c0600726931ab07e4d85f571",
"265": "4b8cc90825971ed0d553cc87fed9263cad671af7ebb4019d209328ffeec3b0c4",
"266": "77c4e37e778eec51bcdaa5fb540e545ae6e0cf33ded812b4961003c154ad223e",
"267": "3dfb085cbbedf53c3e71ee8d21ba124b25dcdb15b55ed226b4e2b6a6337b916b",
"268": "ae23634ca0b2c0c770ea11026a40677cba2fa08aa34a539b1edd804708d9aef1",
"269": "763278b1430cbe557886b95f60617b24ada9d87ca686404c25506f9dcf1ee0e5",
"270": "52832bbbacbe722541eed1093ac7d71cbe719e1287801ef99df0f55c0ab22583",
"271": "38227d4633aa964e50982b308a32d997a0945406f01f997697b06ca7cc6235e0",
"272": "bd3c04fc65f454600590cc57c4464debf0b7c158b2e47eaf72ebe560072f3537",
"273": "60919b035f86b843c1ea4705469506a6afed07bf41b71f821ffd01f234d3e3c8",
"274": "5afe970a88f0fd101b9cd467f68fcc55c486a78d99ba1684fe2f33e79e5db214",
"275": "e56f74b3960e127a98e1678940b9f5303afffabd1cfcae6625639bbd8aab9cf4",
"276": "5a6e4b3ee2a768df8120ad33d5ebf22b3af73afd5004d9bb0539303468ddcf2c",
"277": "8b943c823446fd6c65801200341670f8d8db26fb5cc446b1dd8f2580979ddcfb",
"278": "4316770ae2a51f0fee105c37d0e9b208869095499500a3557aa855f6b6f3b995",
"279": "08255f5dd16a5bf357f9398f24af39a478ec4b94914db1259f33ac47dcdc020f",
"280": "074efc56072e970d877df0a6c223485ed4016624681db7f479a715a89a860b57",
"281": "410191293a48a78e1dcfab6a7a1ea6f1c3b3a24e6d0bf5ba58b824452e42a198",
"282": "fc734547936d23b3d5295161fd0522a3f3f4b77b2f41fa800131eb986055b80d",
"283": "457deca0be28c727dc8e7a5f097bb20244ec2c07feb6697b030d5ef066f5df59",
"284": "cbdb31908c2483a318f671b1e2fe95bafc5ca872af895b11b67b0e7854791308",
"285": "b5d6610556d5eda6139dbd1ef7bcb0582918d9d185abcfe9103fbfa3b0c97dbb",
"286": "7fc90b784fd9b9b2581f4cfe0df00c3fb2c5b9a2aca3b3510ae961b1291d98af",
"287": "97fd82e1ced2b5bca9326e27becb637bfa47f14af7ba79716b24e8f8d4cfcea9",
"288": "4cc481600bb417c71e869dfca8b93b0baf661281190882852c8078cf0dab1748",
"289": "cd2d9cad8bab35385f01e797d7b262f01559cd53b29d59c52d930f1a65fbff27",
"290": "de8ac142e15482adcc8b1c11b3e3221b1bbdf3b043d80f85bccebb6507b081a3",
"291": "d62ff908aea616f350bbab0cf48ca608915d08b216a8fee1f9ffb78c4120df54",
"292": "d2fa6c3a4220081bdc3e93aea8ec5b434e8a3fa85f253762b5ddc418fc826e27",
"293": "008a51efb469e2d972fb110c796b696591c6c110f357f4daa63dc4d38edbecbf",
"294": "15aa77046a4b49c80c71056d1fdbc8e33c1e228163ca0fe1140575c73d4dcadd",
"295": "1a1b8ceec12037c0d1ef4ec6cd187b64ab77396a56e1bf632bed44a9cd247a09",
"296": "6b1c465d05b56f865af349e9375598f496c719faaf1f1c382fb257cb559d2f39",
"297": "d94539046b1ba96d439f425aead31a8835549c730127e41cd0c82470a2cca7c4",
"298": "345414bbf570dbf5a45a67d1e11db4822cc3201d2a5ba369eab5cec3f9f896f9",
"299": "2a906905f5dc6d07b1f877e2e34b0324346bb8ed8fb3acca8d70fed5ea5f2bad",
"300": "539b34fcc0617eacb0b7bfe820b3ccda6a3340b02fac809de0f411f54b0e8d1b",
"301": "40107145cbf46f7c487711d7e57fa2188ddae64b40e6a013957e5577472d5b15",
"302": "4da87084047568af745623d09b499ac505d029b6fd4b98668486ce1b324300f6",
"303": "6294346fa31182b8819317428e446cd3e4fc9077a8b9b156c32b8a46e039acf3",
"304": "1359c3743857692a1a04dc441b57af4985de674fef9e2351ce7b150c91e2f69c",
"305": "033a27b3d4635473e9aaa594f1e18c063991da3470c29b397839522aaa4f1096",
"306": "b2567e2d3c11b661abc73fbe3c0889619a5c5a47c30cdb978845d57add689ad0",
"307": "aafcb8d7798717f04833c0edf61a49b49f754ca0f632558da1aa12a3bb032b45",
"308": "aa3a57f710b2d097ab76126316b061a7f8eccb924e5eb27ebf2a10cb0a7c6cd2",
"309": "027dda6533093c985d4cd7c2fb4663dd898528ac07ba26c502063249a1ad5b9c",
"310": "2dcb014aa570ed01283ef2c47858aeed27990142b01cfa5551be42bdf0829c60",
"311": "bfc490ce9cf253c1d472376527f30f3305b64fff24ffee2e9f52850899b808c0",
"312": "0c8d3b4f65d8b674f6cd24a582ff542ecfb6d880344f1ad89d31b686ccd93f4b",
"313": "076e6a5d63162cfd7c90862b6931e2810c375e75a8c1e2f18e4322898181d73e",
"314": "273c1c6b0b9496e9b3b445b5627176c1368bdac852e4b2568f8972be4d4cbeb1",
"315": "607cf38fdfb9fc027877db06ce94c5f2a57dbd51a4422692e4f53400b48e0a2d",
"316": "a3522fe26401ca0e341b93832d56445493875ba0c6e1265acca74ca2f53b77b4",
"317": "bef8b6cff3c14a719aec9933f43525e4b9917327617d849c6b7e0567b768fde5",
"318": "62b56d8e9b7fa1df9cc8be877968b8f0457406f92fcafcdc3967e0eee1244eb5",
"319": "c5255c6347bcfc2bd522bb1c7c17c8f954e8eb569cd55e56104708a360bf9c75",
"320": "a21734a05c5fe614d5f3fd4cab7f9c5fb1b099e34b0e0a1f4eae139ff06ec2b5",
"321": "ff4771568a25f688746048f1e6390ac38fdf2785e5c2e2fecf012428409b46be",
"322": "3f15422e675c5dc13384f766781b2104bb993b4178ac0b86dd4abefc70dcfbed",
"323": "8876a824e7c4228691949036197fababb1d2c703a80edf5f3b5df6a106690f6e",
"324": "14c26f66f0a2faa994875a23fa8a35cccc74b9bb8f37ba4dda61534fd58a40a9",
"325": "7a1e51909e3aaafd0c37b69a06a2d77f11d94a503873bb6eedb47ae679404658",
"326": "dd952c3eddce5308df451da9a91f33fa837f00fd2dbc6748e46a49c179c91df7",
"327": "ba5ded39e9dc45051692ba34745af1d43af9c0033b574cc521cec7ecf8b4e692",
"328": "7bf770e393b0f5ffa0d0b7470c3fa4511742b3e0923e3c0500fabf24558ff1ef",
"329": "5958141c1ba82f63163ed429dce8a589a7e6a2126ee05c929033e895ed75f05b",
"330": "5351bbeef47b712d588bb0b01afb64f33e338d2483b71faa0570816980d9d064",
"331": "5a95b9c137b4d675be9be4b06d7edd1abe6124cce5051703db794693dc1c8ad2",
"332": "0ca06cf2f8e74247fad1c8e4f84c5f5df98f1287341c906dd03aec0ab0e180e0",
"333": "fe4ef4c7f5dec4301cf14d874135c23b154aab0b1696ae9d3d1bc57d461f2cb6",
"334": "099697fd3bafa06b79cc4ea0b06f78d3af0136842057b5b8d8d13a0938d50053",
"335": "7d6a92cbb55b67ff0577a73203989f8eb5263483e041cda6d4e26358747282d0",
"336": "2ded024ac4706f656303e166fd05c9e4da773a2d63eaaf397f560dcd2e5dae92",
"337": "d53789e3a4dee1f2b412fb5077d32035373324c0340f590656e181fa43c4f07f",
"338": "16655b97a30fde371c4dad93a8b178a0e3702f981ddc1b77ea1dc1930a683e16",
"339": "366e913f20167e27789c808bd7009b81694d472093bc76aefe6adb723d818b1c",
"340": "cba8adc7f330d999294a0c5fcb5730944c421eaa3aaf8b5b8ee89a940683b0bb",
"341": "bbac6bfdeadca56491b2c745c76313e0b09bd9c94a0fc24139ec9446dcbf687d",
"342": "890545cbe2ab6c4a0b64a73ab8003f7f95acc5a2fdf39f4b01b84fcaaaa995a0",
"343": "f163d8f5d4b8d044feb079f09bb473b0621de92f9238fb25199ce6aff4e9e429",
"344": "9062610940b53d11aead7bc5ceb138778d5bd4377dd44df3d9dc1a29d4f7d5d2",
"345": "e1652065871d3306c7208b13447e5c5e42d31a1d840b39d5378686f85d1ad859",
"346": "b975bc2ab053de67e5fe610a08aa6c8aa22dddc5ec59e57866163a05764cd159",
"347": "478a854521a75511cea076bcdb8ddc30d3eed18e83a6f3ccdc9a8c970b5f615f",
"348": "f70d0934dd4c0a33f0790d035f5f35531b2b7c2072295ca11b21509c71129d7c",
"349": "5c31721d7143dc6ea598847800ee3d44357a40770bfa8df67056c3aa1d7f3711",
"350": "94d4a2dade5d3c17137264679912bff598df92fb574a2acd371cc1dbaf63da82",
"351": "eb9f91df7303e3ef52b581988a499eb56684a317498d8a43d6edc186dcbc64c7",
"352": "acbfa1a6de3e89a31399bc6a75c0841b319c4bd010dfb90566a2c52d1b0fe914",
"353": "c68e64560a8810a2d2b46d3d50211f87978aa66418e3b81c77bff90650694767",
"354": "d4f18f17429b63a0a49e8a7ee51fdbf69bdc46d2b8c0fd7bd08a2658d6ba7f54",
"355": "7820d65d46a500e68f69d3417ab687de6a1663fed3ea3e982c029519b44edace",
"356": "b870eb97060a73cac0ca1cb30034f0dd96ecb8919b0392810a42e54ec51b172d",
"357": "7d73bd089946a5d812553e75874ffc7dd690cf4bc8c4ab8ae620b1e5ec0b2888",
"358": "2c164bc1386130a227942e1932d071e6c48eeb2b1d4bfbcadf2bc1230b674a77",
"359": "aaec667f04c0b080ad97202b32e5f7c6f866def2b46d14e640c1ed0a6ddd4067",
"360": "bed282e3b2663a577acaa5dd12d724969412f5933626c1ca2f34c448bf2511d8",
"361": "1bf8bb18ea1643e387f71d0a986b18be08e5b747725da25bef8f6243a8a4f95f",
"362": "3fa924c900bd6e22a694aa741f36a86398f38240515bbe5f5682b99443b4137f",
"363": "a2d524cfad6c267c986a2d4c48bd3c87b132f74f17b764a070b55abae0e2517d",
"364": "a46276181ee029fe806d21d19a92a67b08fc012c3380fec336f0d4da8eb4df55",
"365": "2ab65d875468cc7e27e0be81680f0cb2b288bc2e495b7dd813f316eddb81fb0f",
"366": "5c6439220514c8927eb6494c6b7544026f24f2cd8172ecd2c4acd2162e1ae528",
"367": "7f9d23c312c0bb9c71bb4217f1f0c5ac5f12505867d3402b02832a01409b65ea",
"368": "a0bc2be4d01f917abd1d32d3e2d9dc4c73c93551e13627be727e9fb7558f9aab",
"369": "c0ebe332ac10f73b8c95145984f673c37fb46db57867a9170178b28ba526b68c",
"370": "e2b2a5ebdf3b0c701e0f16c374cbeef6927f79e53e1c62a802a3f3c4f0a099c3",
"371": "e136d381e1ed697cb702e5b09754545a48a4bbf4a8c48ee8e2b7c77e93c00fd9",
"372": "85b56bc4c2255303d790a6e73bc09bdd3278205e4ab029c1393eb97c6e766383",
"373": "f5c47dc95d2ea91c6a3f3dec705ab4a822235d876d11666aa2e882c9a01bdc66",
"374": "cb3b6933e8da34c0001783b33e01ac63e30d55c27de33573d9b63c4369136a0e",
"375": "c1273a3c61c3d65805b09a896e4eb33714a0a7f292c48e0813a2b5dcff0474f5",
"376": "87fa9620b96fec24aaed6bcca34a2e52285a985b2012569f3b5cf1904adb39da",
"377": "47fee06b3c93430d56fe0236f345ab1a14e2fb00ea41246e4ebd980746cf4515",
"378": "8b01106610577b327732cfdce531ea660174c5f945b06cf2fcab810f34b249ac",
"379": "fabbcef00e791de0b4e995cca59b0084d18c1d6d71d8df4a99be95cff7ce5af9",
"380": "15138ef042f3214a2357d4bbe68dd0da0a31dd19faf432ef4570ae9bf25feca6",
"381": "b12d09049e47335e0aa7b3727d20e70e8085671f703295ba26da542b449e772b",
"382": "55687ae90ec45801af52c3d2fb98f37c149c4a37f38e8a920e88ff307d50fd66",
"383": "319e6857f078169d465e1e5f6e92c6d7bed9509de9f27fec32c27e7b8a12b81f",
"384": "ab3a3798efb81ab64e9d2627c97046f65a4a7910a85dc0d912dae77b39e7dae9",
"385": "f5df07c768bfebb34cc637813e5628e25265d4bbdb76c7c869acec911b8e4321",
"386": "67a5f0ca4a91a3b81f2a420790d35f04cf6174fc82793bc921681f52fec89952",
"387": "84ababbe6593cbeae7468b7fc3ce4ea3b1b64f0d0df7e7a0cd3586e16dc5e5b7",
"388": "e4405b6b53467bd718102a604f4f1e538333650c5c075fb6fbd446c3a69e6290",
"389": "44c46199dad4c8ff91680ea1a9cc4221652d245a5afa913666acac8c39d00bab",
"390": "5d30473558467fbeed4042cc0868b807f2a3d8b4aff305444c5fd4f829f86732",
"391": "76dc36380c94f7bd46b3516d127670e0ba9bc1a9cf0908bbf59d001fe76d32a2",
"392": "623eba9d521d95e8c1bbab3f3f4d170172517195702b29df67948c7627faf3ae",
"393": "0488c6eb4d2d0cf042e4c13ab36f89909b33c85ee41093391b93f5eec9f2d29d",
"394": "91ac5d54ab6f15a76621a8fc5989ab2a4e228e824746b07e539cb0f8bc870ea4",
"395": "58a61845e7591d5a8b60b8bbbf9c617c418d9bd139d02b447ccb642dac66203a",
"396": "457613f5fae065c7a2eb1928049b7a4f1c9638d1e38c287f58fc7822943ed9d2",
"397": "2ae3736fae6ab534cb0a82dc7f583af44e379db1f8c94eeadd7fcd471cb823b1",
"398": "645c563cf4ddb52039374ba8020df484307f0735e24303236f16ea8026271c46",
"399": "a39b07c53004ea394db72637e091098e545175c6f62d1ce9b2d2a5fe4cde42aa",
"400": "4ee68846677a079c640617b2fb07630daee13633d4224b21512e29ae6107955b",
"401": "aa967a0505960c43951ac3acf23037b0a05a7993f6ebf00d9c2c4b2ad8f2d985",
"402": "a9ae443f92a67d417c33b70818b584ed3f411dd2f32c5e6c2e154e713ab1af6f",
"403": "147a7ef0d9b42e2c35e9b5b4435b8db030644749d67dc64e841a21139c71514b",
"404": "dd9c6a371bf378a38a76386b4d3cb61b02615857298a2bd685560032012c1d76",
"405": "f5b1f5472d4b80f1fe051ee5a092cdf32fa8fa71fea8c12eb430f4dde614c184",
"406": "0a3278172f1349cf16b25d991bde2fde1f2d4d02271d6866c471df2e9dccaca3",
"407": "9910ed6be874f5d4a231d6effcb3126833e90303f048eb7367781b8eda967413",
"408": "0f93a29ba3fc3b6f9ed875e132125f705732731cbd7710c0a3ad9e49cc1c35c0",
"409": "5c66eedce16ec09e613e5223d22e56e5a3492b0cfc4be6d8ef3ecb7409ce5abf",
"410": "92240c2dd70931bef585f070985852b47595097b75fa1efae17b7305ae2a45e2",
"411": "0b0d111d316eedd4fcbee5f8d5b3209752d4f0fd106cb143b2a0db6fcad4cc8c",
"412": "34ace0343a5e841f9243807174c402ed7a4a7904691249bad03a3444fd12dda7",
"413": "e9ad7c1adf1479a125a6ad07939a76c8cc539bb1c9b8daa3297a6d9944fbc4f4",
"414": "0beca092b44f26b9b1bd0747827fc20a36e1c39543cac6333bf9bd51cd54f89e",
"415": "5f35bf67d9ea62171323ba05541d3690ec999c8347ea8a153e4814971cb25e7c",
"416": "0770feb3e275910527534690715659995d3ded6fdee80efc3cb89e3333cd04c5",
"417": "56a8f8640ff87ad295b0b3e59c5c012ed1eb220d1c21c722e00e9975a0693657",
"418": "99f01fc27d803adeee736144e857d151d32661e6117fdb30bddd8d6b34cbb100",
"419": "9145362b470803e215bb0e59bf8932ec4ea9869415dbaef128c288fd9379cf58",
"420": "5f2045e6569c616cf7727b192a20715e360557aeeda292add1ee9893e7759dce",
"421": "a6a4413c26b534968009fd1a38187946dcf0610685f2b183a919b8fe2c7a64a0",
"422": "fba464fe63f826c6f2876c26ab3f52ed03bffcaf640f638cfda68d6a5d6b99ac",
"423": "1ed0b7b53f5ebfac07e368b393cc40b4489d650d7aa31a59889ef8c1dc1491ac",
"424": "f8064d5d67db705d13b1c7cc74be8017c05510c3fb372db1f43cc43042e4e3f0",
"425": "9bc3d3226612c1c27a188683a0def9aef4af35a306c476bfb849e9f56c611a30",
"426": "0228edb80450550bcc068f9f1fbf9656bdf13e463577aa03aa588fa61ea75952",
"427": "7ee205a053f7078333652ffd2769c454037b29f0934257bf1e1774a05ef95e26",
"428": "8082043f8b8bbc7d4f4d32f1eb70d380e1917f2b0fc6ff574fdcceb3ed7114e2",
"429": "e77c25d8cfae427d22d7ad19703fcb3c888ce52750515f5f3a2d27c96b641e3b",
"430": "876ff04f19e37c3f20c3e7391e8e9e91564d19314e7240d83822410c7f3afe42",
"431": "ba1fc18823372ee09fa81518b15fcc60d96eab53edc9c9576a44290ad0ece266",
"432": "c4f86f5ca31101b4c07f405fa9397e8da6b95782631f08a733f23d1741c60123",
"433": "ab74ced7a53c70a29706bccc94b3fc100036a5b89f5d1523b6598e317e93746d",
"434": "04fe1446edecdcbcb2c6dd316b34ceb4612c51aecd486fea228c2c39fe49c97e",
"435": "27b8bcd0f77685c8507f1e6177d3b511953d63d199f4da2b244430110cb54fc8",
"436": "87f6a7871d0160f8286a1a8a94bfd423624873417072ba84049385bed87855ae",
"437": "fdeab7e21cb59a8116a5f48b133c30259219c0bf64085cd86c67c6401f04fc2f",
"438": "c002095868d0b701aa641041476f528530695d5ec3fcd14e8afad6454d2604c1",
"439": "fd30f559d2bd7548c793e705fdba560da0972ad78e59198f56ec2f9c80a62e2a",
"440": "72f3982c1e33276fe659324516800b876b40386f71e4f64bc6be9e78734bc567",
"441": "719346ef6f2c38913f17960d776a6a64ab45a95374a817dcf74db7ddc357ea78",
"442": "c3721525b19326e5ae2506b5040bc252a538f287a6cdf864ee39d1a3e11da3cb",
"443": "2bb0a3238175dff454a903a93cb368dd7004e236a567acd2c0ca4f033d779e5a",
"444": "98f4cab72778199dd80a638b007ac8a91493e11892ac767f1fc95af944cfdff3",
"445": "82e5c7ba0b10ca28d749bd0ca7ef040024dc1f166a9dd43881a171b24202a8dd",
"446": "343bd6c1674f4b44b5abb333973eccfe31c7b58304ed3484a8a7a236e7d428e4",
"447": "2e157a5f91c1c807cb51bc635d9eac578b3e9f3355da77f6c5f77608d168fb77",
"448": "79d130538a7822cd40c7b43991c11876d4672a9f5aad6c026087d4d152842a83",
"449": "b26071d7feb3b1d5cf3d6c6478d0b1f804bad00bee667968a5ea7ea1c1ff4e8f",
"450": "dbfb0fc8081ff332bbae32dd29e600e0fa1a205535ade6c133150422da63cbf8",
"451": "fbcd3917b1c5ebe0ac1e6872293fa761b42c401eb9a3513647776947844b79bb",
"452": "32fa773e34ce35912c0e1c45e70cbfcc77a69e159890a9e2cdf84fd170cd22c3",
"453": "7220c3916872c4a7cd2a870930f538f7645f8fb0f850027981ae7db795833a7b",
"454": "fdd6afc81dee8c1deaf66657e579a7c5ff4a70dadeeffe55f5dd9d5a8a00477a",
"455": "74e18b6a0745436de0fa80e74d11377604b904f8ee3d62669534955bc11e3207",
"456": "a0ec0dd713fb29b900a04234d33fed5e54a6aab0b645debc56316197066acf8d",
"457": "ad66a70cc802a244eb92000cd4f67f95447b9c0cc661c02b8c9b9151d9e9aa49",
"458": "af80e62a2520cef5357a4756e8fd202e0aac9ccfa1947dac5da3c50805cc4188",
"459": "6a35a991df73fbfb9f5b733875c757c84fda1b330983f93e6afc66bf18c4a38d",
"460": "160f83a85be80b16bac7782afee7e1faf3958ad6e87149d7c39cd15b2fce6343",
"461": "0f6462422de305f9d3670014179a70b49f0d66789bb43784e33bfb4cd9a6f557",
"462": "0a1832f3c2fce1aa3430ac21e03e1a79bb16b81cb87f1af08a5808443f84163f",
"463": "356d8df28330a5321c985db0a86cacdc84c326d42cdb057287c9cff3ec301bd9",
"464": "e6714428a713b031b946fad05ad59e6f5eda45810177cc0e06431cdb9459c34b",
"465": "3efc667f45c51c28d4c6df4e364fa826566ec0ee542bafdb1350580352ecba1a",
"466": "424bdfd941d550b0944d857d9e126af06e31b4bb7b84a94419c7a4c50d90a188",
"467": "bc6ae1918192766e23fc5238fef3740f1c9211a9c7d39cd60f5d269906e20b95",
"468": "de2d54002a576351c716bc3e3a062c16e18c5287567690523da2a1c05538e215",
"469": "e41d8d17f064cf76723b1c8295b1d2b753d4ac5aba5d14306c655f440a95bc45",
"470": "5e90b39c9cc3ee30ea334dadbef11f22f093b086a3ec78a7abeb5ac2b5aecd50",
"471": "b790dae7dcd09a2b802b9331c4cb858ceb257dde00c148d5be1dbba3272bd4fa",
"472": "e3477872726afe5b2f221bcdc6dc650a31e8316ee7d1701b8f646525ba3f4921",
"473": "e94ec6c6e6a4cd6b1baea0403700594fdd5ed4361807111959cda2fcf502e4cc",
"474": "c45b5e30802fab044d0b97e438f96c4a40b00714643b7e0321efa75e74b39977",
"475": "f6492e6c3f794c32e8e20d79a9bf01a7fb65bb79af038e79697c1fa240d249b6",
"476": "6187e01e203c7394fc2f6866c2e7cd56b415230ef8b3678ece2d22428491c903",
"477": "5850a79b2a8f63d288a7c2390953846ebac547ee4d63ddcf40ed14fda6dc9e9a",
"478": "c8d72e6fe8e91ce842a800e400bc653845bcc2647f0745a494e3e6cf7c62a9ba",
"479": "1eea0faa4e0c5f47f22c980064af4031680435a928b5521c3752f4d366ba708a",
"480": "e617fba622acc0a3a9846501f34d3cd823aba0182d147c5e0ec31d3886cf6bc8",
"481": "cb7b10f265c3f43b6f4e78d317a8c038b42db7b16837c4fffe16a6f003eae36a",
"482": "c0941e9770144b6dd160a99c85028c30dec85e4d693e101b73b2991d2ac015bc",
"483": "4143a50371719a84e7b59ad62c8935089449f2d118d2ee0cfff0e8f54b17bae2",
"484": "937b8a194a19f27405b8586fa20e21b695cdb4fcd3a7bfc1dffe5ca63c76897d",
"485": "e6d9b8deee950ae7080997b624ab4e303e395ca7a66b4f53862c9a3d8f5a4773",
"486": "1f7741c608bba8a9c197896e23db1bf39289857899c405dd7d660942d2b8d2a5",
"487": "ab82e178df29e7beb20c574741194e601842e994f3aabc282708d182071389f4",
"488": "c6e26b93fc71200e6738b08a0b2ecf13c73baeb00cae3f6137651c5bc28a9dc0",
"489": "0489b189a748288a612286d5dfb516e7299cf224052764eb1cd47f34abbf1a1b",
"490": "470821314b458a4cc8cf93fbd993002d578ab413679cad2ee92826df94fc7bcf",
"491": "eba40a78e6beb17e8a7ab3dd6c3963894a4243a2205ab829dfb23fe060ffcfd1",
"492": "1df5eebc07a3fc345399b4701e62f2359a492a6974a63257e9079ffc5e0e4d3f",
"493": "ba889154c39a1a402a0a0a531b1bd7f6d076a9b0d871b8e9fcefae44c8cd1ff3",
"494": "157153a7f76903a18ab07a39b9ff009116cdd9f918b4d3404cb15da9bf1a1d3f",
"495": "2f61748f31963341d77e2401ad889dc6794205bd966611779af560a774141a17",
"496": "1f6ef2160abe7625d227dfd89d155a3a4760b44d16872d0e751ab7bc76cb53e8",
"497": "cec84a9606f8fe89f6ed60eb557e8376d535bba156b491368afed753f5c8323a",
"498": "efb8613d3831727ff8cb455cc2facb519f67aa00dee4faba3f22903ac90a4d22",
"499": "c29f204fd5eeba926b29bfc5ffdab9a6db8759835ce76eb8c3c295b8fbe1b6ac",
"500": "74152e193b83bf7b07850d3e7d338e15b2341b3da0d8da7852113723d713ac93",
"501": "14ce82a67cf62c301799241c17a8466ad0a2d04547e083e1c0af1846b0742da1",
"502": "a33968c8906fcabad71814904dbdaf51104c5f483623734b6700a3a3c876e1ec",
"503": "e23e9316fe3a66d257021758a279ec44b2f3bcd2de5bbcf8e0e5da7ef8a9589d",
"504": "303b8f37d427664fe5700123933d04b75cc794ab3c0bce70346d272ee8686985",
"505": "cf1eac1614ba43b76d9a6db69db0403e784771784efa111cf9d19277254c6187",
"506": "d0c679ccc63557aea139f9123c82e593b98edb6fce503d112ef5395a1e36b0f6",
"507": "9c5581e4f1d4e9f38e2eb2b8a43056c2612fdfcefa79735e07252a19d3649667",
"508": "d9ace05b1a12eaf98654678b23f6998bbeac81623aaa9cfe328234a26856e61f",
"509": "0477716fbfa111f7e47d322dd507f03af21511c3200a55e05a08a470bb9233ea",
"510": "b541515e444f941f4a90c67f7bdaaad5588a2dad84d3af85d3641ad13e4c240f",
"511": "42e7cd704dd34f16e2d45add2ce7fe3119918010d9fca374883ac1538d451c5a",
"512": "626fe444e4e6198d1f8907580b1ee86b8529a006435aaab9b09646c98f2075fd",
"513": "3e649eefbc00968a8e7bd6b5f75613d9919972030fbc337beb3a22c7a407fbc4",
"514": "5275dc6b0c37f3072ef6b1020139aa77453961ccbc341485cb706ede8daaca01",
"515": "aee98510a95848e65e4fdecea1f4eef2031d5b8057b6c7a23d54173a1131bb27",
"516": "64422acaf1d6321cbde088dff49473037ede88c99b2029207f6cec77013f1868",
"517": "a311766475485660732f6f168d60318dae621a7e5a3fe5967569db178c6dfe26",
"518": "b4ab2d72dec7a70e1915bc9a61eeddd195d1a22081d0e81a657db32884e94d01",
"519": "9864f54db192ddd424ca33091ae5ddd7dd705061aa2c2f44d6d0d0036b5462d5",
"520": "ddc6b101dbcf776c6d267c51d4b57d07feb4c8573ee225e37ded0f3c64ebf8ce",
"521": "e467755a261618fa864ca5dcd9d43df95b7c3ba6a466e4c3afb81e167d09659f",
"522": "169eb9fd6e7bb6de8ff338124902bcb87788ba350fc4b547b8d229135fa05198",
"523": "83b0d0e88cfc60450a348f89af52d44dc30784b5e3b8d1aebbe45aaa49a19f87",
"524": "708445526750d2aebedb46ad40cdf7c5dceecaeaa27e64c87de29eaec1ddef4f",
"525": "cd70df5d9bd7be0305c19ae63aebed939e7640f294692c767e40ca2bb57a45a1",
"526": "8caf5258447e464554b5eb64e4e67cf013f2788cbeb5224fa8c97db768988657",
"527": "7bd9d6d022abc237fabe7c679940d51efa690e88fe53b570acac8d597408b984",
"528": "94aa6b43381c9a495ed33435d225d1f762859bdd6a788c81628ce4520357e202",
"529": "fad63c2d055c8242401b7c6ce64b45a70182b9774636029c55cd1a928380fd99",
"530": "db498051375571a7389ad5e0c96669cd47bd79df4968d5ae6b99e3fa89972ec1",
"531": "4c02d713d4f29eed5a4ba778687cea7da3686a8794aaa7b92980485ff3d2e832",
"532": "f0ad84686979b844eae2e431ca91ddf2276eb4c1c8b1b5af0ae7bca68fbe704d",
"533": "84d126b10ba381a348a47fe7e74a53845fbd50ceb19c3cbc0294375794c1140e",
"534": "6a4b13861ad76a1154a674148540d4c14bb350f6f9633395830dce2f67715595",
"535": "bd321ffd934cacb305dfefe5876efe1f18f8c22c55ae218d0311686f12c1151a",
"536": "94af1fbf727cf52a29ea8a6a534dd184bcb6cda7cbc786732823edccb4f5525b",
"537": "c65cd573d337f375a5f281996011834004a16ff36970a869cece044a558bad22",
"538": "6d6e6c1cefc88117b00ed3b2f6bbcff26fd2d32fabf5961dc01e19f0bc3cbce5",
"539": "749a7e0291039cee137c68164a9ccada6d0428cd442e5f62790db2e2728d51ce",
"540": "0b90285d48e154700a6323a28e74b3b090db9a77856c233cbe0296ec244b620d",
"541": "144f71c1b479241229c8ec62ddb8a137a026296017d334ab4fdcf34c035980e5",
"542": "5c52d32ec9e8f658a600e18ec5c55a29b8258992d29a22fac22803fe2549e466",
"543": "f542722c2ed4edaae7cde2e5aeab0c032170868abd4495583153564d49838099",
"544": "a9ad6c4836013cc6e24c33acef8a53d11028bf10b0c4b0cd8b70bcd109f832a5",
"545": "357eba8ba5349fcb1748793ea97abe674d270ee6daac2afc0db763e221b7ab39",
"546": "c1a7524e58cd1503dd80d2a06c538f5f6f54c407f89c1db97ad2d3cfc153fa99",
"547": "ba39083e3c73555891715e6ad0cd9e5adcc3c98b8b698fccf399cbe529022c54",
"548": "63dedf2515a0e9323d0dbac9b014183770e9e2183e051295a7dfeb491c8fef6e",
"549": "1e18dc70f475b23387b9f0f5c05ad718953c193b701fdc3b7a9a3871bbe06450",
"550": "864706933c6aa2e097a1ab2f0c406fa439c2ec5d8277c7a63fee86b3e9365406",
"551": "ec36c05862ecac2d063f25f77bd27b7028f4f9b21d809ea3797be697c2d84368",
"552": "a9f65836106426a40779b1a8cd064694ea02174cfe9e280de320f8f519f00fe7",
"553": "f496c8102f3e99463398946494852f571b0540ea283b0a7c1d87012a072d0ee7",
"554": "a1a88d09d25c6950a12fd191f99a9f6acc09f429ef893d07933d640f71efe45c",
"555": "1cea167e6d53269e718ec2a99c7a2c26d949c19999fae203225efb5c8306d1a3",
"556": "49a7f5031e6bb454ccbfbf94802762bd8053311b682d42d51498621fe4852950",
"557": "14147fce7ab960cf2a85a2e2590020a6820349b8dcbfe6fa290c20e2e0888275",
"558": "5668eb67d29d89ed277b47f27eeed843e3914fab7f52f830d77562ac51bb70a4",
"559": "1bd229e6d5361de8b5c282f3adb428aafc6b8576f079e2c96c8558d2cb63905b",
"560": "f8efff848a4b6809bde2b99a6f44885cacce1d660841d07237d47775d22ecf32",
"561": "56022009e7e47b83bdbaf775bbc3478abd171811c7893f656331f1ff577a621a",
"562": "682806cff6e1587125aee658cce53a30e2de9725f5af54b2af39f98b0dc08ba7",
"563": "e11167c9c630a3b09226a2521e13ad581802dc5954bef51aba9e2f9ea04f4b75",
"564": "d2df70ae10314780d9738c92b3f293937248c087f1019ae6a2a5adca0151d85b",
"565": "5135fede0e2117804c776b35da39b477f5b913444bafe565ced20486630a138c",
"566": "bf96033436cf66dc621d631219fc579510dcba04a6ad3b740b58aff45e5e49cb",
"567": "b718c303b67f5d2676e7e3a0d3b242cb26dce305e3c0bf912757b390af08537d",
"568": "680c4b5b6df22227a99099a7e144c055fefb4233d4b3983cc911b9e79e9f44d0",
"569": "d4554511f47526795ef796dec96dadd446d3ea89b4a67c00d8994738161a31fe",
"570": "59fface3a47d42509977e9e42b840e09843eae165e163612b08b7cc16884ace7",
"571": "e893062022228edbf1741450d3fd5526f6ea12fe2e62d7c2f2e16def5ec6971c",
"572": "78565159fe4e8d70df3a9a3030ece976747fdaba7cfd70a59b375fd8bc65b7da",
"573": "dfcf34ac9447a3106ad470716d5ca27640099021f9b11a29cf736fe0366615e9",
"574": "67580de8620d29c02836822a4f54e8a2bc0c50824ee31a2140d7bfb07a095f89",
"575": "0c2af3d8bc4888f45244c611ab8ddef0b99ffa2f78fc112a6e3ff93f2f39275b",
"576": "962e064fa4aece36d173b79ce568c6eaf42f589bb551e917fa3af04f94cdf213",
"577": "431d348dcbed1986ea325443cbc197b8d21afb02afe0660f78c4d3b2f0231bb2",
"578": "ee1926298d7e9def6984728515460ebbe2583d1b208240834dda0a239721a947",
"579": "1203491ec9a7b3e392362b5c7a019a3933bc628c7e9c53ef92fa7dc8ab3f9709",
"580": "cf0c0e97db308037ca4b3b7f62c8c087ead0159304d92570c3a90a41430dd6f6",
"581": "17047d0c2b6f8ec84a232d292d63036d386dc433ddc66befc4d4c0d5f376429d",
"582": "5d1ab417528b72d7b95c032066453486ac5ba94a237b4dad7d75123f6b681c8c",
"583": "bc33b2404d94c10168be7ea3b30b942a4cac6e32501b49808e56905c77669d00",
"584": "853e6194aea8f0bb0384173716d82d1b99a3d7e9fb24e646b3db9ad1e5104ccf",
"585": "7c1eaa16629ab960bbca17da6f14d39f9f6c402f7590074246bc8fe85d920780",
"586": "8dee90325d600ce4e38c2b1edabaaa07332bc7a07664964860dc0b0c6e034f4f",
"587": "85b06dddd60cde14101016174a3a2e555f3dc905f0e0db3cd709b31f1845d1c7",
"588": "05ba1898d97a576df9837ef16fb5d9c3587ef576e962b19b37fcf213e26c8ad9",
"589": "447b01bae8bbe12b6a84de93bcac76205298ed366f48ecc5b2e03eb38ade39da",
"590": "1c5622add47ce4adc879cda40ad4226831eb14a78ae856ed4a709fee038fc14a",
"591": "88caa1d64066f261776d6e3df86385e6ce2840d60927ac99674be30e0b0f4aba",
"592": "b2bd47360bce53a02ebdc6ecf4d47a993241a544929efe7e133a5f1ea6a80825",
"593": "4cec7bd384bc000c52c2ecd2945b053db4c59d72f143ef3bd30b6e4acf0f4a2c",
"594": "3defaf08b5d52c5b9e1922762e5a1f4c4fa69f3d4b62c85c52b33358005abf60",
"595": "c95fcff56138b79799d382dd14a9cff5c9cf553066567b4578dc984091232cbb",
"596": "9838f49f7d4bbe87282af3153fa787822ae787192e87420d7e2c2c812fecdd01",
"597": "dfc93dd04800409d7892a89b6e7069f57faf57825440065d2539e398a7b25d5c",
"598": "3b143c158a7fdaeea3732b9e4e2ad793384fa865b0af55bb4fc75de46b2277a5",
"599": "454a430b4113c2114696c806cc8f2f6c5b074cc1846ce9ad990f031f613793f5",
"600": "7038d049a6adb89bf759aa74868e9c2a12a5d48fd07f216a6557d9d2eb0629f1"
}
}
+611
View File
@@ -0,0 +1,611 @@
"""`sim/carry.py` + the action gate's carry verbs — the hands, pinned.
PLAN §5 gives the cook one button and a pair of hands; this file pins what the
hands do:
* **one item per hand** — a second pick while carrying is `HANDS_FULL`, and a press
at open floor with a full hand is the ladder's named `NOTHING_HERE` (v1 has no
floor drops);
* **the set-down** — `RELEASE` / `PLACE_ON_STATION` / `PLATE_DISH` put the item on
the fixture, stamp it with a mark, and leave it grabbable the moment it is set
down (while an unmarked pipeline input stays the work press's); a pick-and-place
round trip preserves the `Item`'s identity and clears the fixture's cell;
* **the hand-off** — a pick of a set-down placed by another cook within
`HANDOFF_WINDOW_TICKS` (inclusive) is a `HANDED_OFF` with both ids; a self-pick,
or a third party's pick outside the window, is a plain `PICKED`;
* **the trash** — a long press over the trash can destroys the carried item on the
8th substep of the hold and not the 7th; a short press and a walk-away do
nothing; a threshold met with an empty hand is `TRASHED_ALREADY_EMPTY`, never a
silent no-op; the raw score delta rides the event payload;
* **the contested pick** — one winner, one `CONTESTED_LOST` loser, the item count
conserved;
* **the conservation law** — a 100-tick fuzz of random simultaneous presses from a
fixed seed re-checks `hands + stations + trashed == initial` after *every* tick,
and runs bit-identical twice.
The two-player pass-and-contest script with its committed golden hash is
`test_replay_carry.py`.
"""
from __future__ import annotations
from dataclasses import replace
from typing import Any
import pytest
from undercooked.sim.carry import (
HANDOFF_WINDOW_TICKS,
TRASH_HOLD_TICKS,
TRASH_SCORE_DELTA,
first_loose_entry,
pick_item,
place_item,
trash_effect,
)
from undercooked.sim.grid import Grid
from undercooked.sim.interact import (
ItemTarget,
StationTarget,
action_gate,
resolve_action,
select_target,
)
from undercooked.sim.model import (
Event,
EventKind,
Facing,
Item,
ItemForm,
ItemKind,
KitchenState,
Player,
PlayerState,
Station,
StationKind,
StationState,
TileKind,
Vec2,
)
from undercooked.sim.rng import Rng
from undercooked.sim.step import STEP_DT, PlayerInput, step
pytestmark = pytest.mark.unit
WIDTH, HEIGHT = 13, 9
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
# --- fixtures ---------------------------------------------------------------------------
def make_state(
stations: tuple[Station, ...] = (),
players: tuple[Player, ...] = (),
tick: int = 0,
) -> KitchenState:
return KitchenState(
level_id="03_carry",
seed=7,
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
tick=tick,
stations=stations,
players=players,
target_score=40,
time_left=90.0,
)
def cook(
player_id: str = "p0",
*,
pos: Vec2 = DEFAULT_POS,
facing: Facing = Facing.UP,
carrying: Item | None = None,
state: PlayerState = PlayerState.IDLE,
seat: int = 0,
) -> Player:
return Player(
id=player_id,
pos=pos,
facing=facing,
carrying=carrying,
state=state,
seat=seat,
)
def fixture(
kind: StationKind = StationKind.STOVE,
*,
x: int = 5,
y: int = 4,
state: StationState = StationState.IDLE,
contents: tuple[Item, ...] = (),
dirty: bool = False,
) -> Station:
return Station(
id=f"{kind.value}-{x}-{y}",
kind=kind,
x=x,
y=y,
state=state,
contents=contents,
dirty=dirty,
)
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
return Item(kind=ItemKind.TOMATO, form=form)
def lettuce() -> Item:
return Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW)
def rice() -> Item:
return Item(kind=ItemKind.RICE, form=ItemForm.RAW)
def frame(player_id: str, seq: int, action: bool, tick: int) -> PlayerInput:
return PlayerInput(player_id=player_id, seq=seq, action=action, received_at_tick=tick)
def kinds(events: tuple[Event, ...]) -> list[EventKind]:
return [EventKind(event.kind) for event in events]
def payload(event: Event) -> dict[str, Any]:
return dict(event.payload)
def total_items(state: KitchenState) -> int:
"""The conservation quantity: hands + station contents + trashed."""
in_hands = sum(1 for player in state.players if player.carrying is not None)
on_stations = sum(len(station.contents) for station in state.stations)
trashed = state.counters.get("trashed", 0)
return in_hands + on_stations + trashed
# --- grabbability: a set-down is an object, a pipeline input is work ---------------------
def test_a_set_down_item_is_targeted_by_a_free_hand_on_an_idle_surface() -> None:
"""The whole of the hand-off: a placed item on a counter is grabbable at once."""
prep = fixture(kind=StationKind.PREP, contents=(lettuce(),))
state = make_state(stations=(prep,), players=(cook("p0"),))
state = replace(state, item_marks={prep.id: (("placerr", 3),)})
target = select_target(state, state.players[0])
assert isinstance(target, ItemTarget)
assert target.item == lettuce()
def test_an_unmarked_loaded_surface_holds_its_item_for_the_work_press() -> None:
"""An onion on an idle board is *to be chopped*: no mark, not `READY`, not a shelf."""
board = fixture(kind=StationKind.CHOP_BOARD, contents=(tomato(),))
state = make_state(stations=(board,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert first_loose_entry(state, board) is None
def test_a_ready_unmarked_fixture_hands_its_output_to_a_free_hand() -> None:
ready = fixture(
kind=StationKind.CHOP_BOARD,
state=StationState.READY,
contents=(tomato(ItemForm.CHOPPED),),
)
state = make_state(stations=(ready,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, ItemTarget)
assert target.item.form is ItemForm.CHOPPED
def test_the_shelf_hands_its_entries_whether_marked_or_stock() -> None:
"""Shelf entries are all grabbable: stock (unmarked) first, then set-downs."""
shelf = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(), lettuce()))
state = make_state(stations=(shelf,), players=(cook("p0"),))
entry = first_loose_entry(state, shelf)
assert entry is not None and entry[0] == tomato() # the unmarked stock entry is first
target = select_target(state, state.players[0])
assert isinstance(target, ItemTarget)
assert target.item == tomato()
def test_a_marked_set_down_stays_grabbable_after_the_placer_moves_away() -> None:
"""The mark outlives the press: the object is on the counter, not in a hand."""
station = fixture(kind=StationKind.PREP, contents=(lettuce(),))
state = make_state(stations=(station,), players=(cook("p0"),))
state = replace(state, item_marks={station.id: (("other", 5),)})
target = select_target(state, state.players[0])
assert isinstance(target, ItemTarget)
assert target.item == lettuce()
# --- one item per hand -----------------------------------------------------------------
def test_a_full_hand_refuses_a_second_pick_with_one_named_denial() -> None:
ready = fixture(state=StationState.READY, contents=(tomato(),))
state = make_state(stations=(ready,), players=(cook("p0", carrying=lettuce()),))
frames = {"p0": frame("p0", 0, True, 0)}
_new_state, events = action_gate(state, frames, STEP_DT)
denied = [event for event in events if event.kind is EventKind.DENIED]
assert len(denied) == 1 # one refusal per press, never per tick
assert payload(denied[0])["reason"] == "hands_full"
def test_a_press_at_open_floor_with_a_full_hand_is_a_named_nothing_here() -> None:
"""v1 has no floor drops: a `RELEASE` at a FLOOR cell is `NOTHING_HERE`, not a drop."""
state = make_state(players=(cook("p0", carrying=lettuce()),)) # no stations at all
new_state, events = resolve_action(state, state.players[0])
assert new_state is state
assert len(events) == 1
assert payload(events[0])["reason"] == "nothing_here"
# --- pick/place round trip --------------------------------------------------------------
def test_pick_and_place_preserve_the_item_and_clear_the_station_cell() -> None:
"""The identity fields survive the whole round trip; the cell is cleared on the pick."""
item = Item(kind=ItemKind.TOMATO, form=ItemForm.CHOPPED, quality=0.42, ttl=3.5)
ready = fixture(state=StationState.READY, contents=(item,))
shelf = fixture(kind=StationKind.SPAWN_SHELF, x=8)
state = make_state(stations=(ready, shelf), players=(cook("p0"),))
new_state, events = resolve_action(state, state.players[0])
assert len(events) == 1 and events[0].kind is EventKind.PICKED
carried = new_state.players[0].carrying
assert carried is item # value identity: the very object left the fixture
assert new_state.stations[0].contents == () # the cell is cleared
# the set-down: move the cook to the shelf, press again
cook_at_shelf = replace(new_state.players[0], pos=Vec2(8.5, 5.5))
new_state2, events2 = resolve_action(new_state, cook_at_shelf)
assert len(events2) == 1 and events2[0].kind is EventKind.DROPPED
assert new_state2.players[0].carrying is None
assert new_state2.stations[1].contents == (item,) # the same object, back on a fixture
assert item.quality == 0.42 and item.ttl == 3.5 # the fields never move
@pytest.mark.parametrize(
"kind",
[StationKind.CHOP_BOARD, StationKind.PREP, StationKind.SPAWN_SHELF],
ids=["place_on_station", "plate_dish", "release"],
)
def test_place_verbs_put_the_item_on_the_counter_and_stamp_the_mark(
kind: StationKind,
) -> None:
"""`PLACE_ON_STATION` (board), `PLATE_DISH` (the counter) and `RELEASE` (the shelf)."""
held = lettuce()
station = fixture(kind=kind)
state = make_state(stations=(station,), players=(cook("p0", carrying=held),))
new_state, events = resolve_action(state, state.players[0])
assert len(events) == 1 and events[0].kind is EventKind.DROPPED
assert new_state.players[0].carrying is None
assert new_state.stations[0].contents == (held,)
# the mark: who put it down, at which tick — the hand-off's whole memory
assert new_state.item_marks[station.id] == (("p0", 0),)
# --- the hand-off -----------------------------------------------------------------------
def _pass_kitchen() -> KitchenState:
"""A at the prep, B at its left diagonal, both facing the counter; A holds an item."""
prep = fixture(kind=StationKind.PREP, x=9, y=4)
a = cook("cook-a", pos=Vec2(9.5, 5.5), carrying=lettuce(), seat=0)
b = cook("cook-b", pos=Vec2(8.5, 5.5), seat=1)
return make_state(stations=(prep,), players=(a, b))
def test_a_pass_is_a_handed_off_with_both_ids() -> None:
state = _pass_kitchen()
# t0: A sets the item down (the marked place)
state, events = step(state, STEP_DT, (frame("cook-a", 0, True, 0),))
assert kinds(events) == [EventKind.DROPPED]
assert payload(events[0])["player_id"] == "cook-a"
# t1: A releases; B picks it up -> the sanctioned pass
state, events = step(
state,
STEP_DT,
(frame("cook-a", 1, False, 1), frame("cook-b", 1, True, 1)),
)
handed = [event for event in events if event.kind is EventKind.HANDED_OFF]
assert len(handed) == 1
assert payload(handed[0]) == {
"from": "cook-a",
"to": "cook-b",
"item": "lettuce",
"form": "raw",
}
assert state.players[1].carrying is not None
assert state.players[0].carrying is None
assert state.stations[0].contents == ()
def test_the_hand_off_window_is_inclusive_at_the_twentieth_tick() -> None:
station = fixture(kind=StationKind.PREP)
a = cook("cook-a", carrying=lettuce())
b = cook("cook-b", seat=1)
state = make_state(stations=(station,), players=(a, b))
held = a.carrying
assert held is not None
placed, _events = place_item(state, a, station, held)
at_boundary = placed.with_tick(HANDOFF_WINDOW_TICKS)
_new_state, events = pick_item(at_boundary, b, station, lettuce())
assert kinds(events) == [EventKind.HANDED_OFF]
assert payload(events[0]) == {
"from": "cook-a",
"to": "cook-b",
"item": "lettuce",
"form": "raw",
}
just_after = placed.with_tick(HANDOFF_WINDOW_TICKS + 1)
_new_state, events = pick_item(just_after, b, station, lettuce())
assert kinds(events) == [EventKind.PICKED]
def test_the_placer_picking_their_own_item_is_a_plain_pick() -> None:
station = fixture(kind=StationKind.PREP)
a = cook("cook-a", carrying=lettuce())
state = make_state(stations=(station,), players=(a,))
held = a.carrying
assert held is not None
placed, _events = place_item(state, a, station, held)
new_state, events = pick_item(placed.with_tick(1), a, station, lettuce())
assert kinds(events) == [EventKind.PICKED]
assert new_state.players[0].carrying is not None
def test_a_third_party_pick_outside_the_window_is_a_plain_pick() -> None:
"""A's set-down, C's pick one tick past the window: a pick, never a pass."""
station = fixture(kind=StationKind.PREP)
a = cook("cook-a", carrying=lettuce())
c = cook("cook-c", seat=2)
state = make_state(stations=(station,), players=(a, c))
held = a.carrying
assert held is not None
placed, _events = place_item(state, a, station, held)
late = placed.with_tick(HANDOFF_WINDOW_TICKS + 1)
new_state, events = pick_item(late, c, station, lettuce())
assert kinds(events) == [EventKind.PICKED]
assert payload(events[0])["player_id"] == "cook-c"
assert new_state.players[1].carrying is not None
assert EventKind.HANDED_OFF not in kinds(events)
def test_a_same_tick_pass_is_inside_the_window() -> None:
"""Place and pick in the same substep (seat order): the pass, at zero ticks old."""
station = fixture(kind=StationKind.PREP)
a = cook("cook-a", carrying=lettuce(), seat=0)
b = cook("cook-b", seat=1)
state = make_state(stations=(station,), players=(a, b))
frames = {"cook-a": frame("cook-a", 0, True, 0), "cook-b": frame("cook-b", 0, True, 0)}
new_state, events = action_gate(state, frames, STEP_DT)
assert kinds(events) == [EventKind.DROPPED, EventKind.HANDED_OFF]
assert payload(events[1]) == {
"from": "cook-a",
"to": "cook-b",
"item": "lettuce",
"form": "raw",
}
assert new_state.players[1].carrying is not None
assert new_state.players[0].carrying is None
assert new_state.stations[0].contents == ()
# --- the trash ---------------------------------------------------------------------------
def _trash_kitchen() -> KitchenState:
trash = fixture(kind=StationKind.TRASH)
return make_state(stations=(trash,), players=(cook("p0", carrying=lettuce()),))
def test_a_trash_long_press_destroys_on_the_eighth_substep_not_the_seventh() -> None:
state = _trash_kitchen()
for substep in range(TRASH_HOLD_TICKS - 1): # substeps 1..7: the hold builds
state, events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),))
assert state.players[0].carrying is not None
assert EventKind.TRASHED not in kinds(events)
# the 8th substep of the hold: the item goes
state, events = step(
state, STEP_DT, (frame("p0", TRASH_HOLD_TICKS - 1, True, state.tick),)
)
trashed = [event for event in events if event.kind is EventKind.TRASHED]
assert len(trashed) == 1
assert payload(trashed[0]) == {
"player_id": "p0",
"item": "lettuce",
"form": "raw",
"score_delta": TRASH_SCORE_DELTA,
}
assert state.players[0].carrying is None
assert state.counters["trashed"] == 1
assert state.score == 0 # the raw delta is phase 04's to spend (clamped at 0)
def test_a_short_press_does_not_trash() -> None:
state = _trash_kitchen()
for substep in range(3):
state, _events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),))
state, _events = step(state, STEP_DT, (frame("p0", 3, False, state.tick),))
assert state.players[0].carrying is not None
assert "trashed" not in state.counters
def test_releasing_before_the_threshold_starts_a_fresh_hold() -> None:
state = _trash_kitchen()
plan = (True, True, True, True, True, False) + (True,) * TRASH_HOLD_TICKS
for substep, action in enumerate(plan):
state, events = step(state, STEP_DT, (frame("p0", substep, action, state.tick),))
if substep < len(plan) - 1:
assert state.players[0].carrying is not None
assert EventKind.TRASHED not in kinds(events)
assert state.players[0].carrying is None # the 8th substep of the *second* hold
assert state.counters["trashed"] == 1
def test_walking_away_from_the_trash_voids_the_hold() -> None:
"""The gate re-derives the hold: off the can, the button is no longer `TRASH`."""
state = _trash_kitchen()
for substep in range(TRASH_HOLD_TICKS): # the hold's 8 substeps, walking right
walking = PlayerInput(
player_id="p0",
seq=substep,
action=True,
move=Vec2(1.0, 0.0),
received_at_tick=state.tick,
)
state, events = step(state, STEP_DT, (walking,))
assert EventKind.TRASHED not in kinds(events)
for substep in range(TRASH_HOLD_TICKS, TRASH_HOLD_TICKS + 6):
state, events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),))
assert EventKind.TRASHED not in kinds(events)
assert state.players[0].carrying is not None # the hold was voided, nothing trashed
assert "trashed" not in state.counters
def test_a_trash_threshold_with_an_empty_hand_is_named_not_silent() -> None:
"""Defensive path: the threshold met with an empty hand is an event, not a no-op."""
trash = fixture(kind=StationKind.TRASH)
state = make_state(stations=(trash,), players=(cook("p0"),)) # nothing carried
before = state.state_hash()
new_state, events = trash_effect(state, state.players[0])
assert kinds(events) == [EventKind.TRASHED_ALREADY_EMPTY]
assert payload(events[0]) == {"player_id": "p0"}
assert new_state is state # nothing moved
assert state.state_hash() == before
# --- contested picks (the new paths, conserved) ------------------------------------------
def _contested_kitchen() -> tuple[KitchenState, dict[str, PlayerInput]]:
"""Two cooks on one ready item: seat 0 in front, seat 1 on the left diagonal."""
ready = fixture(state=StationState.READY, contents=(tomato(),))
state = make_state(
stations=(ready,),
players=(
cook("cook-a", seat=0), # (5, 5) facing UP: front cell (5, 4)
cook("cook-b", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1),
),
)
frames = {
"cook-a": frame("cook-a", 0, True, 0),
"cook-b": frame("cook-b", 0, True, 0),
}
return state, frames
def test_a_contested_pick_has_exactly_one_winner_and_conserves_items() -> None:
state, frames = _contested_kitchen()
initial = total_items(state)
new_state, events = action_gate(state, frames, STEP_DT)
picked = [event for event in events if event.kind is EventKind.PICKED]
denied = [event for event in events if event.kind is EventKind.DENIED]
assert len(picked) == 1 and len(denied) == 1
assert payload(picked[0])["player_id"] == "cook-a" # the full tie goes to the seat
assert payload(denied[0]) == {
"player_id": "cook-b",
"reason": "contested_lost",
"verb": "none",
}
assert new_state.players[0].carrying is not None
assert new_state.players[1].carrying is None
assert new_state.stations[0].contents == ()
assert total_items(new_state) == initial # the item is in exactly one place
def test_the_earlier_received_frame_wins_regardless_of_seat() -> None:
state, frames = _contested_kitchen()
frames["cook-a"] = frame("cook-a", 0, True, 1) # seat 0 arrives one tick later
new_state, _events = action_gate(state, frames, STEP_DT)
assert new_state.players[1].carrying is not None # the earlier frame wins
assert new_state.players[0].carrying is None
def test_a_contested_start_work_claims_the_worker_slot() -> None:
"""A loaded unmarked board: the single worker slot is the claim, not an item."""
board = fixture(kind=StationKind.CHOP_BOARD, contents=(tomato(),))
a = cook("cook-a", seat=0)
b = cook("cook-b", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1)
state = make_state(stations=(board,), players=(a, b))
frames = {"cook-a": frame("cook-a", 0, True, 0), "cook-b": frame("cook-b", 0, True, 0)}
new_state, events = action_gate(state, frames, STEP_DT)
denied = [event for event in events if event.kind is EventKind.DENIED]
assert len(denied) == 1
assert payload(denied[0]) == {
"player_id": "cook-b",
"reason": "contested_lost",
"verb": "none",
}
assert new_state.stations[0].contents == (tomato(),) # the input stays for the work
# --- the conservation law: 100-tick fuzz -------------------------------------------------
DIRECTIONS: tuple[Vec2, ...] = (
Vec2(0.0, 0.0),
Vec2(1.0, 0.0),
Vec2(-1.0, 0.0),
Vec2(0.0, 1.0),
Vec2(0.0, -1.0),
Vec2(1.0, 1.0),
Vec2(1.0, -1.0),
Vec2(-1.0, 1.0),
Vec2(-1.0, -1.0),
)
def _fuzz_kitchen() -> KitchenState:
"""Three cooks, one carried item each, at a shelf / prep / trash in a row."""
shelf = fixture(kind=StationKind.SPAWN_SHELF, x=5)
prep = fixture(kind=StationKind.PREP, x=7)
trash = fixture(kind=StationKind.TRASH, x=9)
state = make_state(
stations=(shelf, prep, trash),
players=(
cook("p0", pos=Vec2(5.5, 5.5), carrying=tomato(), seat=0),
cook("p1", pos=Vec2(7.5, 5.5), carrying=lettuce(), seat=1),
cook("p2", pos=Vec2(9.5, 5.5), carrying=rice(), seat=2),
),
)
assert total_items(state) == 3
return state
def _fuzz_run(seed: int, ticks: int = 100) -> tuple[KitchenState, list[str]]:
"""One fuzz run: random move + random press per cook per tick, from `seed`."""
rng = Rng(seed)
state = _fuzz_kitchen()
hashes: list[str] = []
for tick in range(ticks):
frames = tuple(
PlayerInput(
player_id=player.id,
seq=tick,
move=rng.choice(DIRECTIONS),
action=rng.next_float() < 0.35,
received_at_tick=state.tick,
)
for player in state.players
)
state, _events = step(state, STEP_DT, frames)
assert total_items(state) == 3, f"item count lost at tick {state.tick}"
hashes.append(state.state_hash())
return state, hashes
def test_the_item_count_is_conserved_over_a_hundred_tick_fuzz() -> None:
"""The per-tick assert is the point: a leak mid-run would surface immediately."""
state, _hashes = _fuzz_run(0xC0FFEE)
assert total_items(state) == 3 # and at the end, as the task requires
def test_the_fuzz_is_bit_identical_across_two_runs() -> None:
first, first_hashes = _fuzz_run(0xC0FFEE)
second, second_hashes = _fuzz_run(0xC0FFEE)
assert first_hashes == second_hashes # the whole history, tick for tick
assert first.state_hash() == second.state_hash()
+881
View File
@@ -0,0 +1,881 @@
"""`sim/collision.py` + `sim/movement.py` — the two promises walking makes (PLAN §5).
Everything here is table-driven against hand-computed geometry, because "it moved" is
not a specification. The numbers come from the shapes, not from the code: a cook is a
square of half-width `RADIUS` = 0.35 and a tile is 1.0 wide, so a cook pressed against
the wall cell at x=6 sits with its centre at `6 - 0.35 - CONTACT_EPS` = 5.6499. If the
implementation ever says otherwise, one of those two is wrong.
The property groups:
* **terrain** — walk into every `TileKind` (and off the map), and stop flush;
* **speed** — the ceiling, the ramp, the harder brake, no diagonal boost, and the
server-side clamp that makes a `move` of 50 worth exactly a `move` of 1;
* **other cooks** — the symmetric soft push (two cooks and a chain of four), no
velocity injection, the transfer of what a wall refuses, and the deadlock revert;
* **stun** — pinned, one event, expires;
* **facing** — the last non-zero intent, a press that never turns you, an explicit turn.
The invariant-only version of all this, over thousands of ticks of nonsense input, is
`test_movement_stress.py`.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import replace
from itertools import pairwise
from typing import Any
import pytest
from undercooked.sim import movement
from undercooked.sim.collision import (
AXIS_X,
AXIS_Y,
CONTACT_EPS,
PASS_THROUGH_STATIONS,
body_blocker,
body_cells,
cell_blocked,
station_body_cells,
sweep_axis,
)
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
EventKind,
Facing,
Hazard,
HazardKind,
KitchenState,
Player,
PlayerState,
Station,
StationKind,
TileKind,
Vec2,
)
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, step
pytestmark = pytest.mark.unit
# --- fixtures ---------------------------------------------------------------------------
LEGEND: dict[str, TileKind] = {
".": TileKind.FLOOR,
"#": TileKind.WALL,
"=": TileKind.COUNTER,
"o": TileKind.VOID,
}
#: 13x5, interior x 1..11 / y 1..3, walls all round.
ROOM = (
"#############",
"#...........#",
"#...........#",
"#...........#",
"#############",
)
#: 21x21: big enough that nothing touches an edge for the first twenty ticks of a
#: full-speed run from the centre.
FIELD = tuple(["#" * 21] + ["#" + "." * 19 + "#"] * 19 + ["#" * 21])
#: half-width + the epsilon a body stops short of a blocked cell by.
FLUSH = movement.RADIUS + CONTACT_EPS
TWO_TILES = 2 * movement.RADIUS
def grid(rows: Sequence[str], legend: dict[str, TileKind] | None = None) -> Grid:
return Grid.from_rows(rows, legend or LEGEND)
def kitchen(
players: Sequence[Player],
rows: Sequence[str] = ROOM,
stations: Sequence[Station] = (),
legend: dict[str, TileKind] | None = None,
) -> KitchenState:
return KitchenState(
level_id="movement",
seed=1,
grid=grid(rows, legend),
stations=tuple(stations),
players=tuple(players),
time_left=30.0,
)
def cook(pid: str, x: float, y: float, seat: int = 0, **overrides: Any) -> Player:
return replace(Player(id=pid, pos=Vec2(x, y), seat=seat), **overrides)
def press(
pid: str,
x: float,
y: float,
*,
seq: int = 1,
received: int = 0,
action: bool = False,
face: Facing | None = None,
) -> PlayerInput:
return PlayerInput(
player_id=pid,
seq=seq,
move=Vec2(x, y),
action=action,
face=face,
received_at_tick=received,
)
def walked(
state: KitchenState, frames: dict[str, PlayerInput], ticks: int, dt: float = STEP_DT
) -> KitchenState:
"""`state` after `ticks` substeps of `frames`."""
current = state
for _ in range(ticks):
current, _events = movement.step_movement(current, dt, frames)
return current
def occupied_cells(state: KitchenState, index: int = 0) -> tuple[tuple[int, int], ...]:
return body_cells(state.players[index].pos, movement.RADIUS)
def assert_no_body_in_terrain(state: KitchenState) -> None:
"""The core guarantee, asserted as geometry instead of as a remembered coordinate."""
blocked = body_blocker(state.grid, state.stations)
for player in state.players:
assert not any(blocked(x, y) for x, y in body_cells(player.pos, movement.RADIUS))
# --- collision.cell_blocked -------------------------------------------------------------
KINDS = ("#####", "#.=o#", "#####")
@pytest.mark.parametrize(
("x", "y", "expected"),
[
(1, 1, False), # the only walkable cell
(2, 1, True), # counter
(3, 1, True), # void: blocked, never a fall (PLAN §5)
(1, 0, True), # wall above
(1, 2, True), # wall below
(0, 1, True), # wall left
(4, 1, True), # wall right
],
)
def test_cell_blocked_per_tile_kind(x: int, y: int, expected: bool) -> None:
assert cell_blocked(grid(KINDS), x, y) is expected
@pytest.mark.parametrize(("x", "y"), [(-1, 1), (5, 1), (1, -1), (1, 5), (99, 99)])
def test_out_of_bounds_is_blocked(x: int, y: int) -> None:
assert cell_blocked(grid(KINDS), x, y) is True
def test_extra_blocked_uses_row_major_cell_indices() -> None:
board = grid(KINDS)
assert cell_blocked(board, 1, 1, frozenset()) is False
assert cell_blocked(board, 1, 1, frozenset({1 * board.width + 1})) is True
# an index for a cell that is blocked anyway changes nothing
assert cell_blocked(board, 3, 1, frozenset({3})) is True
# --- collision.station_body_cells / body_blocker ----------------------------------------
@pytest.mark.parametrize(
"kind",
[StationKind.SPAWN_SHELF, StationKind.CHOP_BOARD, StationKind.STOVE, StationKind.PREP],
)
def test_fixture_stations_block_their_tile(kind: StationKind) -> None:
assert station_body_cells(Station(id=f"{kind.value}_0", kind=kind, x=4, y=2)) == ((4, 2),)
def ordered(kinds: frozenset[StationKind]) -> list[StationKind]:
"""The pass-through set as a stable list — a set has no order a test can rely on."""
return sorted(kinds, key=lambda kind: kind.value)
@pytest.mark.parametrize("kind", ordered(PASS_THROUGH_STATIONS))
def test_pass_through_stations_block_nothing(kind: StationKind) -> None:
assert kind in {StationKind.SERVE, StationKind.SINK, StationKind.TRASH}
assert station_body_cells(Station(id=f"{kind.value}_0", kind=kind, x=4, y=2)) == ()
def test_body_blocker_layers_terrain_fixtures_and_exclusions() -> None:
board = grid(("#####", "#...#", "#####"))
shelf = Station(id="chop_board_0", kind=StationKind.CHOP_BOARD, x=2, y=1)
blocked = body_blocker(board, [shelf])
assert blocked(2, 1) is True # floor, but a board stands on it
assert blocked(1, 1) is False # plain floor
assert blocked(0, 1) is True # terrain
assert blocked(2, 0) is True # terrain
carrying = body_blocker(board, [shelf], frozenset({board.width + 2}))
assert carrying(2, 1) is False # the cook holding the board walks over it (phase 10)
assert carrying(2, 0) is True # `movable_cells` never opens terrain
# --- collision.body_cells ---------------------------------------------------------------
@pytest.mark.parametrize(
("pos", "expected"),
[
(Vec2(2.5, 2.5), [(2, 2)]),
(Vec2(3.0, 3.0), [(2, 2), (3, 2), (2, 3), (3, 3)]), # a corner touches four cells
(Vec2(6 - FLUSH, 2.5), [(5, 2)]), # flush left of x=6 claims only cell x=5
(Vec2(2.5, 6 - FLUSH), [(2, 5)]),
],
)
def test_body_cells(pos: Vec2, expected: list[tuple[int, int]]) -> None:
assert body_cells(pos, movement.RADIUS) == tuple(expected)
def test_a_small_body_stays_in_one_cell() -> None:
assert body_cells(Vec2(3.5, 3.5), 0.1) == ((3, 3),)
# --- collision.sweep_axis ---------------------------------------------------------------
SWEEP_MAP = (
"########",
"#......#",
"#..#...#",
"#......#",
"########",
)
@pytest.mark.parametrize("axis", [AXIS_X, AXIS_Y])
def test_a_sweep_of_nothing_moves_nothing(axis: str) -> None:
blocked = body_blocker(grid(SWEEP_MAP))
assert sweep_axis(Vec2(2.5, 2.5), 0.0, movement.RADIUS, blocked, axis) == 0.0
@pytest.mark.parametrize(
("pos", "delta", "axis", "expected"),
[
# a free corridor: the whole move is legal, both directions
(Vec2(1.5, 3.5), 0.25, AXIS_X, 0.25),
(Vec2(5.5, 3.5), -0.25, AXIS_X, -0.25),
# the pillar at (3,2) stops a rightward move one epsilon short of x=3
(Vec2(1.5, 2.5), 3.0, AXIS_X, 3.0 - 1.85 - CONTACT_EPS),
# the same pillar met from the right stops one epsilon right of x=4
(Vec2(5.5, 2.5), -3.0, AXIS_X, 4.0 + CONTACT_EPS - 5.15),
# down into the floor wall at y=4, up into the one at y=0
(Vec2(1.5, 1.5), 3.0, AXIS_Y, 4.0 - 1.85 - CONTACT_EPS),
(Vec2(1.5, 1.5), -3.0, AXIS_Y, 1.0 + CONTACT_EPS - 1.15),
# already flush: no room on that axis, and no bounce back either
(Vec2(3 - FLUSH, 2.5), 0.5, AXIS_X, 0.0),
(Vec2(4 + FLUSH, 2.5), -0.5, AXIS_X, 0.0),
],
)
def test_sweep_axis_limits(pos: Vec2, delta: float, axis: str, expected: float) -> None:
allowed = sweep_axis(pos, delta, movement.RADIUS, body_blocker(grid(SWEEP_MAP)), axis)
assert allowed == pytest.approx(expected, abs=1e-12)
def test_a_huge_sweep_never_crosses_a_one_tile_wall() -> None:
"""50 tiles in one call is still stopped by the first wall: every cell on the path is tried."""
blocked = body_blocker(grid(SWEEP_MAP))
assert sweep_axis(Vec2(1.5, 2.5), 50.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx(
1.1499, abs=1e-9
)
assert sweep_axis(Vec2(6.5, 2.5), -50.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx(
-2.1499, abs=1e-9
)
def test_a_sweep_cannot_push_a_body_out_of_the_map() -> None:
"""The edge of the map stops a body the way a wall does: its trail edge at `+EPS`."""
blocked = body_blocker(grid(("...", "...", "...")))
assert sweep_axis(Vec2(1.5, 1.5), -9.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx(
CONTACT_EPS - 1.15, abs=1e-9
)
# --- terrain: walking into everything ---------------------------------------------------
@pytest.mark.parametrize(
("kind", "blocked_cell", "expected_cell"),
[
(TileKind.FLOOR, 5, (4, 1)),
(TileKind.WALL, 4, (3, 1)),
(TileKind.COUNTER, 4, (3, 1)),
(TileKind.VOID, 4, (3, 1)),
],
)
def test_walking_into_each_tile_kind(
kind: TileKind, blocked_cell: int, expected_cell: tuple[int, int]
) -> None:
"""One tile of each kind in the path: the cook stops flush against it, never inside it.
"Flush" is `blocked_cell - RADIUS` to within `CONTACT_EPS`: a sweep stops a body one
epsilon short of the blocked cell, but when the moving edge lands exactly *on* the
boundary the body is already flush and no clamp is needed at all.
"""
state = kitchen(
[cook("p0", 1.5, 1.5)],
rows=["######", "#...K#", "######"],
legend={**LEGEND, "K": kind},
)
out = walked(state, {"p0": press("p0", 1, 0)}, 60)
assert out.players[0].pos.x == pytest.approx(blocked_cell - movement.RADIUS, abs=CONTACT_EPS)
assert out.players[0].pos.y == pytest.approx(1.5)
assert occupied_cells(out) == (expected_cell,)
assert_no_body_in_terrain(out)
def test_a_walk_stops_flush_against_the_room_wall() -> None:
state = kitchen([cook("p0", 2.5, 2.5)], rows=ROOM)
out = walked(state, {"p0": press("p0", 1, 0)}, 120)
assert out.players[0].pos.x == pytest.approx(12 - movement.RADIUS, abs=CONTACT_EPS)
assert occupied_cells(out) == ((11, 2),)
def test_a_corner_catch_slides_instead_of_sticking() -> None:
"""A fixture column in the path: X is refused, Y keeps going, and the cook goes around.
The assertion that matters is per-substep: whenever the pillar is level with the
cook, the cook is never *inside* it — which is exactly the corner case a
centre-only collision test gets wrong.
"""
state = kitchen(
[cook("p0", 1 + FLUSH, 2.5)],
rows=("#######", "#.....#", "#.#...#", "#.#...#", "#.....#", "#######"),
)
current = state
for tick in range(30):
current, _events = movement.step_movement(
current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)}
)
assert_no_body_in_terrain(current)
level_with_pillar = any(y in (2, 3) for _x, y in occupied_cells(current))
if level_with_pillar:
assert current.players[0].pos.x <= 2 - movement.RADIUS + CONTACT_EPS
out = current.players[0]
# slid along the wall instead of into it, and stopped flush with the bottom
assert out.pos.y == pytest.approx(5 - movement.RADIUS, abs=CONTACT_EPS)
assert out.pos.x > 2.0 # and carried on right once the pillar was behind
@pytest.mark.parametrize(
"direction",
[(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, 1)],
ids=["west", "east", "north", "south", "north-west", "south-east"],
)
def test_out_of_bounds_containment_in_every_direction(direction: tuple[int, int]) -> None:
"""A map with no walls at all: the edge is a wall for a cook, in every direction."""
state = kitchen([cook("p0", 2.0, 1.5)], rows=("....", "....", "...."))
out = walked(state, {"p0": press("p0", *direction)}, 60).players[0]
assert out.pos.x - movement.RADIUS >= -CONTACT_EPS
assert out.pos.x + movement.RADIUS <= 4.0 + CONTACT_EPS
assert out.pos.y - movement.RADIUS >= -CONTACT_EPS
assert out.pos.y + movement.RADIUS <= 3.0 + CONTACT_EPS
assert_no_body_in_terrain(replace(state, players=(out,)))
def test_double_speed_cannot_cross_a_one_tile_wall(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ceiling doubled and the coarsest legal dt: the sweep still stops the cook flush.
The per-substep displacement (7 tiles/s x 0.1 s = 0.7) is 70 % of a tile, so a naive
"is the destination cell walkable" test would land the cook on the wall's far edge.
`sweep_axis` tests every cell between the old and the new edge instead.
"""
monkeypatch.setattr(movement, "MAX_SPEED", 2 * movement.MAX_SPEED)
state = kitchen([cook("p0", 1.5, 1.5)], rows=("######", "#..#.#", "######"))
current = state
for tick in range(30):
current, _events = movement.step_movement(
current, MAX_DT, {"p0": press("p0", 1, 0, seq=tick)}
)
assert_no_body_in_terrain(current)
assert current.players[0].pos.x == pytest.approx(3 - movement.RADIUS, abs=2 * CONTACT_EPS)
@pytest.mark.parametrize(
("kind", "blocked"), [(StationKind.STOVE, True), (StationKind.SERVE, False)]
)
def test_a_stove_stops_you_and_a_serve_window_does_not(kind: StationKind, blocked: bool) -> None:
fixture = Station(id=f"{kind.value}_0", kind=kind, x=4, y=1)
state = kitchen(
[cook("p0", 1.5, 1.5)], rows=("#######", "#.....#", "#######"), stations=[fixture]
)
out = walked(state, {"p0": press("p0", 1, 0)}, 60).players[0]
assert out.pos.x == pytest.approx((4 if blocked else 6) - movement.RADIUS, abs=CONTACT_EPS)
assert_no_body_in_terrain(replace(state, players=(out,)))
# --- speed ------------------------------------------------------------------------------
def test_intent_is_clamped_to_the_unit_disc() -> None:
assert movement.intent_direction(Vec2(0, 0)) == Vec2(0, 0)
assert movement.intent_direction(Vec2(0.5, 0)) == Vec2(0.5, 0) # analog throttle kept
diagonal = movement.intent_direction(Vec2(3, 4))
assert diagonal.x == pytest.approx(0.6)
assert diagonal.y == pytest.approx(0.8)
assert movement.intent_direction(Vec2(1, 1)).length == pytest.approx(1.0)
assert movement.intent_direction(Vec2(50, 0)).x == pytest.approx(1.0)
def test_normalisation_is_what_makes_the_diagonal_not_faster(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(movement, "DIAGONAL_NORM", False)
assert movement.intent_direction(Vec2(2, 2)) == Vec2(1, 1)
assert movement.intent_direction(Vec2(50, -50)) == Vec2(1, -1)
assert movement.intent_direction(Vec2(0.4, 0.4)) == Vec2(0.4, 0.4)
@pytest.mark.parametrize(
"intent",
[Vec2(1, 0), Vec2(-1, 0), Vec2(0, 1), Vec2(0, -1), Vec2(1, 1), Vec2(-2, -2)],
ids=["e", "w", "s", "n", "diag", "diag_overdrive"],
)
def test_a_diagonal_is_not_a_speed_boost(intent: Vec2) -> None:
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
travelled = walked(state, {"p0": press("p0", intent.x, intent.y)}, 20).players[0].pos
straight = walked(state, {"p0": press("p0", 1, 0)}, 20).players[0].pos
start = Vec2(10.5, 10.5)
assert (travelled - start).length == pytest.approx((straight - start).length, abs=1e-12)
def test_the_ramp_and_the_harder_brake() -> None:
still = cook("p0", 5.5, 2.5)
first = movement.desired_velocity(still, Vec2(1, 0), STEP_DT)
assert first == pytest.approx(Vec2(movement.ACCEL * STEP_DT, 0))
second = movement.desired_velocity(replace(still, vel=first), Vec2(1, 0), STEP_DT)
assert second.x == pytest.approx(2 * movement.ACCEL * STEP_DT)
third = movement.desired_velocity(replace(still, vel=second), Vec2(1, 0), STEP_DT)
assert third.x == pytest.approx(movement.MAX_SPEED) # the ceiling is met, not overshot
rolling = cook("p0", 5.5, 2.5, vel=Vec2(movement.MAX_SPEED, 0.0))
braking = movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT)
assert braking.x == pytest.approx(movement.MAX_SPEED - movement.DECEL * STEP_DT)
assert movement.DECEL * STEP_DT > movement.ACCEL * STEP_DT # braking is the sharper ramp
def test_speed_never_exceeds_the_ceiling() -> None:
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
current = state
peak = 0.0
for tick in range(20):
current, _events = movement.step_movement(
current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)}
)
speed = current.players[0].vel.length
assert speed <= movement.MAX_SPEED + 1e-12
peak = max(peak, speed)
assert peak == pytest.approx(movement.MAX_SPEED)
@pytest.mark.parametrize(
("modifiers", "expected"),
[
(movement.MoveModifiers(), movement.MAX_SPEED),
(movement.MoveModifiers(speed_scale=0.5), movement.MAX_SPEED / 2),
(movement.MoveModifiers(speed_scale=0.0), 0.0),
],
)
def test_speed_scale_moves_the_ceiling(modifiers: movement.MoveModifiers, expected: float) -> None:
"""`dt = 1.0` so the grip limit cannot bind: this reads the ceiling itself."""
walked = movement.desired_velocity(cook("p0", 1, 1), Vec2(1, 0), 1.0, modifiers)
assert walked.x == pytest.approx(expected)
def test_accel_scale_reaches_the_ceiling_in_half_the_time() -> None:
still = cook("p0", 1, 1)
assert movement.desired_velocity(still, Vec2(1, 0), STEP_DT).x == pytest.approx(1.5)
grippy = movement.MoveModifiers(accel_scale=2.0)
assert movement.desired_velocity(still, Vec2(1, 0), STEP_DT, grippy).x == pytest.approx(3.0)
def test_slide_removes_the_brake_but_not_the_throttle() -> None:
rolling = cook("p0", 1, 1, vel=Vec2(2.0, 0.0))
icy = movement.MoveModifiers(slide=True)
assert movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT, icy) == Vec2(2.0, 0.0)
assert movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT).x == pytest.approx(0.0)
# pushing on a spill still pushes
assert movement.desired_velocity(rolling, Vec2(1, 0), STEP_DT, icy).x > 2.0
def test_a_spill_halves_the_cruising_speed() -> None:
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
frames = {"p0": press("p0", 1, 0)}
spill = movement.MoveModifiers(speed_scale=0.5)
floor = walked(state, frames, 40).players[0]
on_spill = state
for _ in range(40):
on_spill, _events = movement.step_movement(on_spill, STEP_DT, frames, {"p0": spill})
travelled = on_spill.players[0].pos.x - 10.5
assert on_spill.players[0].vel.length == pytest.approx(movement.MAX_SPEED / 2, abs=1e-9)
assert travelled == pytest.approx(0.5 * (floor.pos.x - 10.5), rel=0.05)
def test_a_client_cannot_send_speed_fifty() -> None:
"""The clamp is server-side and it is in the kernel path, not only in the helper."""
state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD)
polite = state
rude = state
for tick in range(40):
polite, _ = step(polite, STEP_DT, [press("p0", 1, 0, seq=tick)])
rude, _ = step(rude, STEP_DT, [press("p0", 50, 0, seq=tick)])
assert rude.players[0].pos == pytest.approx(polite.players[0].pos)
assert rude.players[0].vel.length <= movement.MAX_SPEED + 1e-12
# --- other cooks ------------------------------------------------------------------------
def test_one_walked_cook_moves_the_other_and_never_throws_them() -> None:
corridor = ("#" * 21, "#" + "." * 19 + "#", "#" * 21)
state = kitchen([cook("p0", 2.5, 1.5), cook("p1", 3.6, 1.5, seat=1)], rows=corridor)
before = state.players[1]
current = state
for tick in range(30):
current, _events = movement.step_movement(
current, STEP_DT, {"p0": press("p0", 1, 0, seq=tick)}
)
assert_no_body_in_terrain(current)
pusher, pushed = current.players
assert pushed.pos.x > before.pos.x # it moved
assert pushed.vel == before.vel == Vec2(0.0, 0.0) # positional correction, no velocity injected
assert pushed.state is PlayerState.IDLE
assert pusher.pos.x < pushed.pos.x
assert pushed.pos.x - pusher.pos.x >= TWO_TILES - movement.PUSH_SLOP
def test_a_cook_pinned_by_a_wall_takes_the_push_backstage() -> None:
"""B is flush against the wall and A walks into B: A absorbs the whole separation."""
wall = 6 - movement.RADIUS
state = kitchen(
[cook("p0", 2.0, 1.5), cook("p1", wall - CONTACT_EPS, 1.5, seat=1)],
rows=("#######", "#.....#", "#######"),
)
current = state
for tick in range(40):
current, _events = movement.step_movement(
current, STEP_DT, {"p0": press("p0", 1, 0, seq=tick)}
)
assert_no_body_in_terrain(current)
pusher, pinned = current.players
assert pinned.pos.x == pytest.approx(wall, abs=CONTACT_EPS) # it never moved
# A stopped behind B rather than walking through it
assert pusher.pos.x == pytest.approx(wall - TWO_TILES, abs=2 * CONTACT_EPS)
def test_a_chain_of_four_cooks_drains_apart() -> None:
"""A queue of overlapping cooks separates over a few ticks; nothing reverts forever."""
packed = [cook(f"p{i}", 2.0 + 0.6 * i, 1.5, seat=i) for i in range(4)]
state = kitchen(packed, rows=("#############", "#...........#", "#############"))
current = state
for _ in range(40):
current, _events = movement.step_movement(current, STEP_DT, {})
assert_no_body_in_terrain(current)
xs = [player.pos.x for player in current.players]
assert xs == sorted(xs)
for first, second in pairwise(xs):
assert second - first == pytest.approx(TWO_TILES, abs=1e-3)
def test_a_bump_separates_on_the_easy_axis() -> None:
"""Two cooks offset more in x than in y separate vertically, not horizontally."""
room = tuple(["#" * 11] + ["#" + "." * 9 + "#"] * 11)
state = kitchen([cook("p0", 2.5, 2.5), cook("p1", 2.7, 3.0, seat=1)], rows=room)
before = state.players[1].pos
current = state
for _ in range(20):
current, _events = movement.step_movement(current, STEP_DT, {})
assert_no_body_in_terrain(current)
moved = current.players[1].pos
assert moved.y > before.y
assert moved.x == pytest.approx(before.x)
def test_a_terrain_deadlock_reverts_the_later_input() -> None:
"""Two cooks stacked in a 1x1 alcove: terrain wins, so the later frame is the one dropped."""
state = kitchen(
[cook("p0", 1.5, 1.5, vel=Vec2(0.2, 0.0)), cook("p1", 1.5, 1.5, seat=1)],
rows=("###", "#.#", "###"),
)
players, events = movement.resolve_moves(
state,
STEP_DT,
{
"p0": press("p0", 1, 0, seq=9, received=5), # received later: loses
"p1": press("p1", 1, 0, seq=1, received=1), # received earlier: keeps its tick
},
)
late, early = players
assert late.pos == state.players[0].pos # exactly where the tick started
assert late.vel == Vec2(0.2, 0.0) # and exactly the velocity it started with
assert early.vel.x == pytest.approx(movement.ACCEL * STEP_DT)
# still touching, never through: the overlap never exceeds one body diameter
assert abs(early.pos.x - late.pos.x) <= TWO_TILES + movement.PUSH_SLOP
assert events == ()
def test_a_pair_that_can_move_is_never_reverted() -> None:
"""Two cooks walking abreast into a wall queue up; the revert is only for real deadlocks."""
state = kitchen(
[cook("p0", 2.5, 1.5), cook("p1", 2.9, 1.5, seat=1)],
rows=("#######", "#.....#", "#######"),
)
current = state
for tick in range(20):
current, _events = movement.step_movement(
current,
STEP_DT,
{"p0": press("p0", 1, 0, seq=tick), "p1": press("p1", 1, 0, seq=tick)},
)
assert_no_body_in_terrain(current)
first, second = current.players
assert second.pos.x - first.pos.x >= TWO_TILES - movement.PUSH_SLOP
def test_the_cook_asking_for_the_move_loses_a_deadlock() -> None:
"""A cook with no frame this tick is never the loser, because it wants nothing.
The revert is not free — it puts a cook back where the substep started — so who gets it
matters. Only one cook sent input, and only that cook can be told "not this tick": the
idle one is not asking to be anywhere and has no input to discard, so it keeps the
position the push left it in and the cook with the joystick is the one refused.
"""
state = kitchen(
[cook("p0", 1.5, 1.5), cook("p1", 1.5, 1.5, seat=1)],
rows=("###", "#.#", "###"),
)
players, events = movement.resolve_moves(state, STEP_DT, {"p0": press("p0", 1, 0)})
framed, frameless = players
assert framed.pos == state.players[0].pos # the one that asked is the one that's refused
# The idle cook keeps the shove it never asked for — and it was shoved *away* from the
# cook that walked into it, which is the only reason the two are not stacked again.
assert frameless.pos != state.players[1].pos
assert frameless.pos.x < state.players[1].pos.x
assert events == ()
assert_no_body_in_terrain(state.with_players(players))
def test_a_jam_drains_over_a_few_ticks_instead_of_fusing_forever() -> None:
"""Four cooks packed into an open kitchen are a queue, not a deadlock.
A jam of overlapping bodies unties itself over a handful of ticks as the pushes
propagate, so each of those ticks must be legal: nobody inside terrain, nobody's
position reverted, because there *is* a way out and reverting would refuse a move that
was perfectly possible.
"""
state = kitchen(
[
cook("p0", 5.5, 2.5),
cook("p1", 5.7, 2.5, seat=1),
cook("p2", 5.5, 2.7, seat=2),
cook("p3", 5.7, 2.7, seat=3),
],
rows=FIELD,
)
current = state
for tick in range(6):
current, _events = movement.step_movement(
current,
STEP_DT,
{
player.id: press(player.id, 1, 1, seq=tick)
for player in current.players
},
)
assert_no_body_in_terrain(current)
positions = [player.pos for player in current.players]
assert positions != [player.pos for player in state.players]
for index, first in enumerate(current.players):
for other in current.players[index + 1 :]:
overlapped_x = TWO_TILES - abs(other.pos.x - first.pos.x)
overlapped_y = TWO_TILES - abs(other.pos.y - first.pos.y)
if overlapped_x > 0.0 and overlapped_y > 0.0:
assert min(overlapped_x, overlapped_y) < TWO_TILES
# --- stun -------------------------------------------------------------------------------
def test_a_stunned_cook_cannot_move_and_reports_the_hit_once() -> None:
state = kitchen([cook("p0", 5.5, 2.5, stun=movement.STUN_S)], rows=ROOM)
current = state
seen: list[str] = []
for tick in range(11):
current, events = movement.step_movement(
current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)}
)
seen.extend(event.kind for event in events)
pinned = current.players[0]
assert pinned.pos == Vec2(5.5, 2.5)
assert pinned.vel == Vec2(0.0, 0.0)
assert pinned.state is PlayerState.STUNNED
assert pinned.facing is Facing.DOWN # a hit never spins you either
assert pinned.stun == pytest.approx(movement.STUN_S - 11 * STEP_DT)
assert seen == [EventKind.STUNNED] # the entry only, not one per substep
def test_a_stun_expires_and_the_cook_walks_again() -> None:
state = kitchen([cook("p0", 5.5, 2.5, stun=0.6)], rows=ROOM)
frames = {"p0": press("p0", 1, 0)}
pinned = walked(state, frames, 11).players[0]
assert pinned.state is PlayerState.STUNNED
freed = walked(walked(state, frames, 11), frames, 1).players[0]
assert freed.stun == 0.0
assert freed.state is PlayerState.MOVING
assert freed.pos.x > 5.5
def test_apply_stun_pins_velocity_and_clears_a_stale_state() -> None:
hurt = cook("p0", 2.5, 2.5, stun=0.3, vel=Vec2(3.0, 0.0), state=PlayerState.MOVING)
bystander = cook("p1", 3.5, 2.5, seat=1)
players, events = movement.apply_stun([hurt, bystander], STEP_DT)
assert players[0].vel == Vec2(0.0, 0.0)
assert players[0].state is PlayerState.STUNNED
assert players[0].stun == pytest.approx(0.25)
assert players[1] is bystander # an unharmed cook is not even copied
reported = [(event.kind, event.payload["player_id"]) for event in events]
assert reported == [(EventKind.STUNNED, "p0")]
stuck = cook("p0", 2.5, 2.5, stun=0.0, state=PlayerState.STUNNED)
revived, later = movement.apply_stun([stuck], STEP_DT)
assert revived[0].state is PlayerState.IDLE # a hazard's half-written state is corrected
assert later == ()
# --- facing -----------------------------------------------------------------------------
@pytest.mark.parametrize(
("intent", "expected"),
[
(Vec2(1, 0), Facing.RIGHT),
(Vec2(-1, 0), Facing.LEFT),
(Vec2(0, 1), Facing.DOWN),
(Vec2(0, -1), Facing.UP),
(Vec2(1, 1), Facing.RIGHT), # a 45 degree press always turns the same way
(Vec2(-0.4, -0.9), Facing.UP),
(Vec2(0.2, -0.1), Facing.RIGHT),
],
)
def test_facing_follows_the_intent(intent: Vec2, expected: Facing) -> None:
assert movement.update_facing(cook("p0", 1, 1), intent) is expected
@pytest.mark.parametrize("action", [False, True])
def test_standing_still_keeps_the_facing(action: bool) -> None:
facing_left = cook("p0", 1, 1, facing=Facing.LEFT)
assert movement.update_facing(facing_left, Vec2(), action) is Facing.LEFT
assert movement.update_facing(facing_left, Vec2(0, 0), action, None) is Facing.LEFT
def test_an_explicit_turn_beats_the_vector() -> None:
assert movement.update_facing(cook("p0", 1, 1), Vec2(1, 0), face=Facing.UP) is Facing.UP
def test_an_action_press_with_no_intent_does_not_spin_the_cook() -> None:
"""Walk right, stop, press: the cook still faces right (US-04, one action button)."""
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
walking = walked(state, {"p0": press("p0", 1, 0)}, 10)
assert walking.players[0].facing is Facing.RIGHT
holding = walking
for seq in range(5):
holding, _events = movement.step_movement(
holding,
STEP_DT,
{"p0": PlayerInput(
player_id="p0", seq=seq, move=Vec2(), action=True, received_at_tick=9
)},
)
assert holding.players[0].facing is Facing.RIGHT
assert holding.players[0].state is PlayerState.IDLE
def test_the_player_state_tracks_the_velocity() -> None:
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
walking = walked(state, {"p0": press("p0", 0, -1)}, 3).players[0]
assert walking.state is PlayerState.MOVING
stopped = walked(replace(state, players=(walking,)), {}, 4).players[0]
assert stopped.state is PlayerState.IDLE
assert stopped.vel == Vec2(0.0, 0.0)
# --- the kernel seam --------------------------------------------------------------------
def test_resolve_moves_without_a_frame_brakes_to_a_stop() -> None:
state = kitchen([cook("p0", 5.5, 2.5, vel=Vec2(movement.MAX_SPEED, 0.0))], rows=ROOM)
players, events = movement.resolve_moves(state, STEP_DT)
assert players[0].vel.x == pytest.approx(movement.MAX_SPEED - movement.DECEL * STEP_DT)
assert players[0].pos.x > 5.5
assert players[0].state is PlayerState.MOVING
assert events == ()
def test_step_movement_is_a_pure_stage_of_the_state() -> None:
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
before = state.state_hash()
moved, events = movement.resolve_moves(state, STEP_DT, {"p0": press("p0", 1, 0)})
out, stage_events = movement.step_movement(state, STEP_DT, {"p0": press("p0", 1, 0)})
assert state.state_hash() == before # the caller's state is untouched
assert out.players == moved
assert stage_events == events
assert out.grid is state.grid
assert out.tick == state.tick
assert [player.id for player in out.players] == ["p0"]
def patch(patch_id: str, x: float, y: float) -> Hazard:
"""A spill at (x, y): inert data until phase 07 gives hazards behaviour.
It is here for the subsystem order, not for its effect — the determinism check below
must cover a tick that runs hazards *and* movement, because that is the tick a replay
has to reproduce exactly.
"""
return Hazard(id=patch_id, kind=HazardKind.SPILL, pos=Vec2(x, y))
def test_the_tick_is_deterministic_through_the_movement_stage() -> None:
"""Same seed, same input script, same hash — the replay contract, at the new stage."""
script = [
press("p0", tick % 4 - 1, tick % 3 - 1, seq=tick)
for tick in range(1, 41)
]
def run(seed: int) -> KitchenState:
current = replace(
kitchen([cook("p0", 5.5, 3.5), cook("p1", 6.5, 3.5, seat=1)], rows=FIELD),
seed=seed,
hazards=(patch("spill_0", 5.5, 3.5),),
)
for tick, frame in enumerate(script, start=1):
current, _events = step(current, STEP_DT, [frame])
assert current.tick == tick
return current
first, second = run(0xC0FFEE), run(0xC0FFEE)
assert first.state_hash() == second.state_hash()
assert first.players == second.players
def test_the_kernel_runs_the_movement_stage() -> None:
state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM)
current = state
for tick in range(10):
current, _events = step(current, STEP_DT, [press("p0", 1, 0, seq=tick)])
assert current.tick == 10
assert current.players[0].pos.x > 5.5
assert_no_body_in_terrain(current)
+170
View File
@@ -0,0 +1,170 @@
"""The movement stage through the real tick, on the levels the game actually ships with.
`test_movement.py` proves the geometry and `test_movement_stress.py` proves the model under
a random walk on a synthetic maze. This is the seam between them and the content pipeline:
`load_content` reads `levels/*.yaml`, `build_state` turns that into a kitchen, and `step()`
runs the whole tick — timers, orders, hazards, movement, in that fixed order — with four
cooks walking around inside the authored geometry.
It earns its place by being the only test that would notice any of these:
* a level whose tiles make a cook contain-able (a spawn the body footprint does not fit
in, a station placed where the body cannot clear it),
* the movement stage drifting out of the tick's subsystem order, or a whole substep of
movement going missing because a hook signature changed under the caller,
* authored content making the kernel non-deterministic — the replay contract is per-level,
not per-fixture,
* the round timer freezing movement: a walk that runs past `time_limit` stops, and the
progress assertions below would then be measuring a paused kitchen instead of a stuck
cook. TICKS therefore stays well inside the shorter level's clock.
The kitchen here comes from YAML, so nothing in this file names a tile, a station or a
wall: it says what must be true of any level that passes the validator.
"""
from __future__ import annotations
import math
from pathlib import Path
import pytest
from undercooked.sim import movement
from undercooked.sim.collision import body_blocker, body_cells
from undercooked.sim.content import load_content
from undercooked.sim.level import Level
from undercooked.sim.model import KitchenState, TileKind, Vec2
from undercooked.sim.rng import Rng
from undercooked.sim.state_build import SeatSpec, build_state
from undercooked.sim.step import STEP_DT, PlayerInput, step
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
RECIPES = "recipes.yaml"
TICKS = 1200
SEED = 0x0D1C
COOK_COUNT = 4
#: How much of the level each cook must have stood on by the end of the walk. Measured on
#: the shipped campaign at a quarter of TICKS: 16-41 cells per cook. Well clear of the
#: 2x2 the pair starts stacked in, and well short of anything that would flap on a redesign.
MIN_CELLS_VISITED = 10
DIRECTIONS: tuple[Vec2, ...] = (
Vec2(1, 0),
Vec2(1, 1),
Vec2(0, 1),
Vec2(-1, 1),
Vec2(-1, 0),
Vec2(-1, -1),
Vec2(0, -1),
Vec2(1, -1),
Vec2(0, 0),
)
#: The shipped campaign, read once at import. Content that will not load should stop
#: collection rather than fail one test with a confusing AttributeError.
CAMPAIGN = load_content(SHIPPED_LEVELS, SHIPPED_LEVELS / RECIPES)
def shipped_levels() -> list[tuple[str, Level]]:
"""Every level in `levels/`, as (id, level) pairs for parametrization."""
return [(level.id, level) for level in CAMPAIGN.levels]
def seats(count: int) -> tuple[SeatSpec, ...]:
"""Four anonymous seats, in seat order, for a full lobby on any level."""
return tuple(
SeatSpec(player_id=f"p{index}", seat=index, display_name=f"Cook {index}")
for index in range(count)
)
def tick_budget(cooks: int) -> float:
"""The most one cook may move in a substep: one tick of walking, plus every push."""
body = 2 * movement.RADIUS
return movement.MAX_SPEED * STEP_DT + movement.PUSH_ITERATIONS * (cooks - 1) * body * 2
def walk_level(
level: Level,
ticks: int = TICKS,
visited: dict[str, set[tuple[int, int]]] | None = None,
) -> KitchenState:
"""Run `ticks` real substeps of a wandering four-cook kitchen on `level`."""
state = build_state(level, seats(COOK_COUNT), SEED, CAMPAIGN.recipe_book)
blocked = body_blocker(state.grid, state.stations)
assert state.players, "a level with no cooks cannot test movement"
budget = tick_budget(len(state.players))
rng = Rng(SEED)
heading = {player.id: Vec2(0, 0) for player in state.players}
for tick in range(1, ticks + 1):
frames: list[PlayerInput] = []
for player in state.players:
if rng.next_float() < 0.2:
heading[player.id] = rng.choice(DIRECTIONS)
frames.append(PlayerInput(
player_id=player.id,
seq=tick,
move=heading[player.id],
received_at_tick=tick,
))
before = {player.id: player.pos for player in state.players}
state, _events = step(state, STEP_DT, frames)
assert state.tick == tick, "the movement stage must not own the tick counter"
assert state.result is None, "the walk outran the round clock: raise time_limit"
for player in state.players:
assert math.isfinite(player.pos.x) and math.isfinite(player.pos.y)
assert math.isfinite(player.vel.x) and math.isfinite(player.vel.y)
assert not any(
blocked(x, y) for x, y in body_cells(player.pos, movement.RADIUS)
), f"{player.id} is inside {level.id} at tick {tick}: {player.pos}"
assert player.vel.length <= movement.MAX_SPEED + 1e-9
assert (player.pos - before[player.id]).length <= budget
if visited is not None:
visited.setdefault(player.id, set()).update(
body_cells(player.pos, movement.RADIUS)
)
return state
@pytest.mark.parametrize(("level_id", "level"), shipped_levels())
def test_a_walk_on_a_shipped_level_stays_inside_it(level_id: str, level: Level) -> None:
"""Containment, bounded velocity and tick accounting, after every single substep."""
del level_id
final = walk_level(level)
assert final.tick == TICKS
assert all(player.vel.length <= movement.MAX_SPEED + 1e-9 for player in final.players)
@pytest.mark.parametrize(("level_id", "level"), shipped_levels())
def test_a_walk_on_a_shipped_level_goes_somewhere(level_id: str, level: Level) -> None:
"""Each cook explores authored geometry instead of wearing out its spawn tile."""
del level_id
visited: dict[str, set[tuple[int, int]]] = {}
walk_level(level, visited=visited)
assert len(visited) == 4
for player_id, cells in sorted(visited.items()):
assert len(cells) >= MIN_CELLS_VISITED, f"{player_id} only ever saw {sorted(cells)}"
@pytest.mark.parametrize(("level_id", "level"), shipped_levels())
def test_a_replay_of_a_shipped_level_reproduces_itself(level_id: str, level: Level) -> None:
"""Determinism is per level: same seed and script, identical hash, on every map."""
del level_id
assert walk_level(level).state_hash() == walk_level(level).state_hash()
def test_the_shipped_kitchen_has_something_to_walk_on() -> None:
"""Guard for the two tests above: the floor is floor, and it is not one cell."""
assert len(CAMPAIGN.levels) >= 2
for level in CAMPAIGN.levels:
floor = sum(1 for tile in level.grid.tiles if tile is TileKind.FLOOR)
assert floor > 4 * MIN_CELLS_VISITED, f"{level.id} is too small to walk"
+230
View File
@@ -0,0 +1,230 @@
"""`movement` under a scripted random walk: 4 cooks, 2000 ticks, invariants only.
The table-driven tests in `test_movement.py` prove the geometry for the cases someone
thought of. This file is for the cases nobody did: four cooks wandering a pillar maze for
2000 substeps, checked after *every* substep — the simulation must never be observed in an
impossible state, not even for one tick (PLAN A21).
The invariants, and why each is the load-bearing kind:
* **containment** — no body footprint ever overlaps a blocked cell. The maze's walls are
one tile thick, so this one check *is* the no-tunnelling proof: a cook that crossed a
wall would have to spend a substep inside it. It also covers containment in the void,
because out-of-bounds is blocked by definition.
* **no pass-through** — no pair ever overlaps by more than `2 * RADIUS`, the brief's
bound, which is exactly what a head-on collision at the coarsest legal dt produces.
* **no explosions** — a cook's per-tick displacement stays inside one tick of walking
plus every push correction that could legally have been applied to it. A sign error in
the soft push shows up here, and nowhere in a screenshot.
* **progress** — every cook must visit a good many distinct cells. A walk that freezes in
a corner passes every safety invariant above while testing nothing.
* **determinism** — same seed, same script, same state hash, at `STEP_DT` and at `MAX_DT`.
The RNG is the sim's own splitmix64: nothing here imports `random`, so a failing run is
reproducible to the tick and this file doubles as the movement stage's first golden.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
import pytest
from undercooked.sim import movement
from undercooked.sim.collision import body_blocker, body_cells
from undercooked.sim.grid import Grid
from undercooked.sim.model import KitchenState, Player, Station, StationKind, TileKind, Vec2
from undercooked.sim.rng import Rng
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput
pytestmark = pytest.mark.unit
LEGEND: dict[str, TileKind] = {
".": TileKind.FLOOR,
"#": TileKind.WALL,
"=": TileKind.COUNTER,
"o": TileKind.VOID,
}
#: A pillar maze with three doorways: one-tile corridors in every direction, so the walk
#: is mostly corners, doorframes and dead ends. Two tiles of terrain are punched into it
#: that a cook must treat as a wall but cannot fall through (`o` void, `=` counter), and
#: `maze_state` adds two fixtures — a stove, which must block, and a serve window, which
#: must not — because "the invariant held on empty floor" is the weakest version of this
#: test. `test_the_maze_is_one_room` keeps it a single connected kitchen.
MAZE = (
"#################",
"#...#..o#...#...#",
"#.#...#...#...#.#",
"#...#...#...#=..#",
"#################",
)
TICKS = 2000
SEED = 0x5EED
COOK_COUNT = 4
#: How much of the 32-cell kitchen each cook must have set foot in by the end of the
#: scripted walk. The maze connects its pockets through three one-tile doorways, so a
#: random heading takes a while to thread one: measured on this seed, every cook covers
#: 16-18 cells over TICKS and none covers fewer than half that in under a tenth of it.
MIN_CELLS_VISITED = 12
#: The eight compass directions plus standing still, as a fixed tuple so `Rng.choice`
#: never depends on set ordering.
DIRECTIONS: tuple[Vec2, ...] = (
Vec2(1, 0),
Vec2(1, 1),
Vec2(0, 1),
Vec2(-1, 1),
Vec2(-1, 0),
Vec2(-1, -1),
Vec2(0, -1),
Vec2(1, -1),
Vec2(0, 0),
)
STARTS: tuple[tuple[float, float], ...] = ((1.5, 1.5), (5.5, 1.5), (9.5, 1.5), (13.5, 1.5))
def maze_state() -> KitchenState:
stations = (
Station(id="stove_0", kind=StationKind.STOVE, x=6, y=1),
Station(id="serve_0", kind=StationKind.SERVE, x=10, y=1),
)
players = tuple(
Player(id=f"p{seat}", seat=seat, pos=Vec2(x, y))
for seat, (x, y) in enumerate(STARTS[:COOK_COUNT])
)
return KitchenState(
level_id="stress",
seed=SEED,
grid=Grid.from_rows(MAZE, LEGEND),
stations=stations,
players=players,
time_left=float(TICKS * STEP_DT),
)
def tick_budget(dt: float) -> float:
"""The most a cook may legitimately move in one call.
One tick of walking at the ceiling, plus `PUSH_ITERATIONS` passes over up to
`COOK_COUNT - 1` contacts, each of which can move this cook a full body (and the
remainder a neighbour could not take). Anything above this is a runaway correction.
"""
body = 2 * movement.RADIUS
return movement.MAX_SPEED * dt + movement.PUSH_ITERATIONS * (COOK_COUNT - 1) * body * 2
def assert_invariants(state: KitchenState, before: Sequence[Vec2], dt: float) -> None:
blocked = body_blocker(state.grid, state.stations)
budget = tick_budget(dt)
for player, was in zip(state.players, before, strict=True):
for value in (player.pos.x, player.pos.y, player.vel.x, player.vel.y, player.stun):
assert math.isfinite(value), f"{player.id} left the number line at tick {state.tick}"
footprint = body_cells(player.pos, movement.RADIUS)
assert not any(blocked(x, y) for x, y in footprint), (
f"{player.id} is inside terrain at tick {state.tick}: {player.pos} -> {footprint}"
)
assert player.vel.length <= movement.MAX_SPEED + 1e-9
jumped = (player.pos - was).length
assert jumped <= budget, f"{player.id} jumped {jumped:.3f} tiles at tick {state.tick}"
diameter = 2 * movement.RADIUS
for i, first in enumerate(state.players):
for second in state.players[i + 1 :]:
overlap_x = diameter - abs(second.pos.x - first.pos.x)
overlap_y = diameter - abs(second.pos.y - first.pos.y)
if overlap_x > 0.0 and overlap_y > 0.0: # the two footprints do intersect
assert min(overlap_x, overlap_y) <= diameter + 1e-9, (
f"{first.id} and {second.id} passed through each other at tick {state.tick}"
)
def run_walk(
ticks: int = TICKS,
dt: float = STEP_DT,
visited: dict[str, set[tuple[int, int]]] | None = None,
) -> KitchenState:
"""The scripted walk: `ticks` substeps of a wandering four-cook kitchen."""
current = maze_state()
rng = Rng(SEED)
heading = [Vec2(0, 0) for _ in current.players]
for tick in range(1, ticks + 1):
frames: list[PlayerInput] = []
for player in current.players:
if rng.next_float() < 0.2:
heading[player.seat] = rng.choice(DIRECTIONS)
frames.append(PlayerInput(
player_id=player.id,
seq=tick,
move=heading[player.seat],
received_at_tick=tick,
))
before = [player.pos for player in current.players]
current, _events = movement.step_movement(current, dt, frames)
assert_invariants(current, before, dt)
if visited is not None:
for player in current.players:
visited.setdefault(player.id, set()).update(
body_cells(player.pos, movement.RADIUS)
)
return current
def test_the_maze_is_one_room() -> None:
"""The progress check below only means something if the kitchen has no sealed pockets."""
state = maze_state()
blocked = body_blocker(state.grid, state.stations)
free = {
(x, y)
for y in range(state.grid.height)
for x in range(state.grid.width)
if not blocked(x, y)
}
seen = {min(free)}
stack = [min(free)]
while stack:
x, y = stack.pop()
for step in ((1, 0), (-1, 0), (0, 1), (0, -1)):
cell = (x + step[0], y + step[1])
if cell in free and cell not in seen:
seen.add(cell)
stack.append(cell)
assert seen == free
assert len(free) >= MIN_CELLS_VISITED
def test_the_walk_never_breaks_the_kitchen() -> None:
"""Containment, no pass-through and no explosions, checked after every substep."""
final = run_walk()
assert len(final.players) == COOK_COUNT
assert [player.id for player in final.players] == [f"p{seat}" for seat in range(COOK_COUNT)]
assert all(player.connected for player in final.players)
def test_the_walk_actually_walks() -> None:
"""Every cook covers real ground: a frozen cook would satisfy every invariant above."""
visited: dict[str, set[tuple[int, int]]] = {}
run_walk(TICKS, visited=visited)
for player_id, cells in sorted(visited.items()):
assert len(cells) >= MIN_CELLS_VISITED, f"{player_id} only ever saw {sorted(cells)}"
def test_the_same_script_always_produces_the_same_kitchen() -> None:
"""Same seed, same script, same hash — the replay contract, at the movement stage."""
first = run_walk(TICKS // 2)
second = run_walk(TICKS // 2)
assert first.state_hash() == second.state_hash()
assert first.state_hash() != run_walk(TICKS // 2, dt=MAX_DT).state_hash()
def test_the_walk_survives_the_coarsest_legal_tick() -> None:
"""`MAX_DT` is 4x the substep: displacement per call quadruples, containment may not."""
visited: dict[str, set[tuple[int, int]]] = {}
final = run_walk(TICKS // 2, dt=MAX_DT, visited=visited)
for player in final.players:
assert player.vel.length <= movement.MAX_SPEED + 1e-9
assert all(len(cells) >= MIN_CELLS_VISITED for cells in visited.values())
+103
View File
@@ -0,0 +1,103 @@
"""The headless perf smoke (PLAN §10, marked `slow` so the quick loop skips it).
Four cooks, 2 000 ticks on the shipped level 1, one direction per cook per tick
drawn from the sim's own splitmix64 (a fixed seed — the walk is a property of
the script, not of the machine). The budget is the task's: mean step
**< 0.2 ms** and total **< 400 ms** — 5 000+ ticks/s, two and a half orders
of magnitude more than the 20 Hz the rooms run at — and the measured numbers
are printed in the report so a slow machine shows up in the gate, not as a
silent failure.
The loop times `step()` directly (no hash recording), so the number is the
kernel's tick cost, exactly what the §10 budget (mean ≤ 2 ms/room) is about;
`run_script`'s injected `clock` seam is what a full-harness measurement goes
through instead, and `test_replay.py` pins that seam.
"""
from __future__ import annotations
import time
from pathlib import Path
import pytest
from undercooked.sim.content import load_content
from undercooked.sim.model import Vec2
from undercooked.sim.rng import Rng
from undercooked.sim.state_build import SeatSpec, build_state
from undercooked.sim.step import STEP_DT, PlayerInput, step
pytestmark = [pytest.mark.slow]
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
#: The campaign, read once at import (the same seam `test_movement_kernel` uses).
CAMPAIGN = load_content(SHIPPED_LEVELS, SHIPPED_LEVELS / "recipes.yaml")
TICKS = 2000
SEED = 0x0D1C
COOKS = 4
#: The task's budget: mean step < 0.2 ms, total < 400 ms on the dev machine.
BUDGET_MEAN_S = 0.0002
BUDGET_TOTAL_S = 0.4
#: The eight compass directions plus standing still, as a fixed tuple so
#: `Rng.choice` (the sim's own generator) is the only source of variation.
DIRECTIONS: tuple[Vec2, ...] = (
Vec2(1, 0),
Vec2(1, 1),
Vec2(0, 1),
Vec2(-1, 1),
Vec2(-1, 0),
Vec2(-1, -1),
Vec2(0, -1),
Vec2(1, -1),
Vec2(0, 0),
)
def seats(cooks: int) -> tuple[SeatSpec, ...]:
"""Four anonymous seats in seat order; spawns wrap past the second (the
level has two), with the stack offset `build_state` applies."""
return tuple(
SeatSpec(player_id=f"p{index}", seat=index, display_name=f"p{index}")
for index in range(cooks)
)
def test_four_cook_kitchen_stays_within_the_perf_budget() -> None:
level = CAMPAIGN.by_id["01_first_kitchen"]
state = build_state(level, seats(COOKS), SEED, CAMPAIGN.recipe_book)
rng = Rng(SEED)
start = time.perf_counter()
for tick in range(TICKS):
inputs = [
PlayerInput(
player_id=f"p{seat}",
seq=tick,
move=rng.choice(DIRECTIONS),
received_at_tick=tick,
)
for seat in range(COOKS)
]
state, _ = step(state, STEP_DT, inputs)
total = time.perf_counter() - start
mean = total / TICKS
print(
f"perf smoke (level 1, {COOKS} cooks, {TICKS} ticks): "
f"total {total * 1000:.1f} ms, mean {mean * 1000:.4f} ms/tick, "
f"{1 / mean:,.0f} ticks/s"
)
assert mean < BUDGET_MEAN_S, (
f"mean step {mean * 1000:.4f} ms is over the {BUDGET_MEAN_S * 1000:.1f} ms budget"
)
assert total < BUDGET_TOTAL_S, (
f"{TICKS} ticks took {total * 1000:.1f} ms, over the {BUDGET_TOTAL_S * 1000:.0f} ms budget"
)
# cheap invariants: the walk stayed inside the kitchen, the tick ran to the end
assert state.tick == TICKS
for player in state.players:
assert 0.35 < player.pos.x < level.grid.width - 0.35
assert 0.35 < player.pos.y < level.grid.height - 0.35
+585
View File
@@ -0,0 +1,585 @@
"""The replay harness against its committed scripts and goldens (A21, PLAN §9 layer 3).
`tests/sim/test_replay_carry.py` pinned the first golden by hand; this file is the
formalised contract: the two committed replay scripts (`walk_solo`, the 600-tick
movement-only tour of level 1; `pick_drop_pass`, two cooks running the whole
pick → set-down → hand-off → long-press-trash chain on the fixture kitchen) each
match their committed `tests/goldens/<name>.golden.json`, a deliberately mutated
script diverges and `compare()` names the first bad tick and the changed field,
the record cadence is stable, a replay is bit-identical within one process, and
the goldens are never rewritten by the test run.
The levels are loaded explicitly (level 1 from the shipped `levels/`, the fixture
kitchen from `tests/fixtures/levels/_replay/`), so the session fixture's
level-free directory cannot leak in — and the CLI tests point
`UNDERCOOKED_LEVELS_DIR` at `levels/` for the same reason.
"""
from __future__ import annotations
import json
import time
from dataclasses import replace
from itertools import pairwise
from pathlib import Path
from typing import Any
import pytest
from undercooked.sim.content import load_level_file
from undercooked.sim.recipe import load_recipe_book
from undercooked.sim.replay import (
EXIT_MISMATCH,
EXIT_OK,
EXIT_UNUSABLE,
Frame,
InputScript,
ReplayError,
ScriptItem,
_field_diff, # private, exercised directly below
compare,
main,
run_script,
)
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_RECIPES = SHIPPED_LEVELS / "recipes.yaml"
WALK_SOLO_SCRIPT = REPO_ROOT / "tests" / "fixtures" / "replays" / "walk_solo.json"
PICK_DROP_PASS_SCRIPT = (
REPO_ROOT / "tests" / "fixtures" / "replays" / "pick_drop_pass.json"
)
PICK_DROP_PASS_LEVEL = (
REPO_ROOT / "tests" / "fixtures" / "levels" / "_replay" / "pick_drop_pass.yaml"
)
WALK_SOLO_GOLDEN = REPO_ROOT / "tests" / "goldens" / "walk_solo.golden.json"
PICK_DROP_PASS_GOLDEN = REPO_ROOT / "tests" / "goldens" / "pick_drop_pass.golden.json"
def golden_map(path: Path) -> dict[int, str]:
"""A golden file's `hashes` as the `tick -> hash` mapping `compare` takes."""
golden = json.loads(path.read_text(encoding="utf-8"))
return {int(tick): digest for tick, digest in golden["hashes"].items()}
def golden_final(path: Path) -> str:
return json.loads(path.read_text(encoding="utf-8"))["hash"]
def load_walk_solo() -> tuple[Any, Any, InputScript]:
"""Level 1, the recipe book and the committed walk_solo script."""
level = load_level_file(SHIPPED_LEVELS / "01_first_kitchen.yaml")
book = load_recipe_book(SHIPPED_RECIPES)
return level, book, InputScript.load(WALK_SOLO_SCRIPT)
def load_pick_drop_pass() -> tuple[Any, Any, InputScript]:
"""The fixture kitchen, the recipe book and the committed pick_drop_pass script."""
level = load_level_file(PICK_DROP_PASS_LEVEL)
book = load_recipe_book(SHIPPED_RECIPES)
return level, book, InputScript.load(PICK_DROP_PASS_SCRIPT)
# -- the committed goldens ----------------------------------------------------
def test_walk_solo_matches_its_committed_golden() -> None:
"""600 ticks of movement-only walking on level 1, every tick recorded."""
level, book, script = load_walk_solo()
result = run_script(level, book, script)
assert result.state.tick == 600
assert len(result.events) == 0 # movement only: no press, no event
assert result.state.players[0].vel.length < 1e-6 # the tour ends settled
assert [tick for tick, _ in result.hashes] == list(range(601))
assert result.state.state_hash() == golden_final(WALK_SOLO_GOLDEN)
assert compare(result, golden_map(WALK_SOLO_GOLDEN)) == []
def test_pick_drop_pass_matches_its_committed_golden_and_event_sequence() -> None:
"""The whole hand chain, pinned both by hash and by the events it must cause."""
level, book, script = load_pick_drop_pass()
result = run_script(level, book, script)
assert result.state.tick == 139
expected: tuple[tuple[str, dict[str, object]], ...] = (
(
"picked",
{"player_id": "p0", "station": "spawn_shelf_0", "item": "tomato", "form": "raw"},
),
(
"dropped",
{"player_id": "p0", "station": "prep_0", "item": "tomato", "form": "raw"},
),
("handed_off", {"from": "p0", "to": "p1", "item": "tomato", "form": "raw"}),
("trashed", {"player_id": "p1", "item": "tomato", "score_delta": -2}),
)
assert len(result.events) == len(expected)
for event, (want_kind, want_payload) in zip(result.events, expected, strict=True):
assert event.kind == want_kind
for key, value in want_payload.items():
assert event.payload.get(key) == value, (event.kind, event.payload)
assert result.state.counters["trashed"] == 1
for player in result.state.players: # the round ends settled, hands empty
assert player.carrying is None
assert player.vel.length < 1e-6
assert result.state.state_hash() == golden_final(PICK_DROP_PASS_GOLDEN)
assert compare(result, golden_map(PICK_DROP_PASS_GOLDEN)) == []
def test_a_mutated_coordinate_diverges_and_compare_names_the_bad_tick_and_field() -> None:
"""One coordinate edited in the fixture: the first bad tick and the moved field."""
level, book, script = load_walk_solo()
frames = list(script.frames)
frames[0] = replace(frames[0], move=(0.0, -1.0)) # up, not down
mutated = replace(script, frames=tuple(frames))
result = run_script(level, book, mutated)
mismatches = compare(result, golden_map(WALK_SOLO_GOLDEN))
assert mismatches, "a mutated script must not match its golden"
first = mismatches[0]
assert first.kind == "hash"
assert first.tick == 1, "the frame at tick 0 is consumed by substep 0 -> tick 1"
assert first.actual is not None and first.actual != first.expected
assert "players[0].pos.y" in first.changed, (
"the diff must name the field the edit moved, " + str(first.changed)
)
assert "players[0].vel.y" in first.changed
assert all(m.tick >= first.tick for m in mismatches)
# the tour re-converges on its walls, so the *final* state may agree with the
# golden again — what is pinned is the divergence itself:
assert len(mismatches) > 1
divergent = [m for m in mismatches if m.kind == "hash"]
assert divergent and all(m.actual != m.expected for m in divergent)
# -- determinism and cadence --------------------------------------------------
def test_replaying_the_same_script_twice_in_one_process_is_identical() -> None:
"""No hidden global state: two runs of each script in one process agree exactly."""
for loader in (load_walk_solo, load_pick_drop_pass):
level, book, script = loader()
first = run_script(level, book, script)
second = run_script(level, book, script)
assert first.hashes == second.hashes
assert first.events == second.events
assert first.state.state_hash() == second.state.state_hash()
assert first.wall_seconds == second.wall_seconds == 0.0 # no clock injected
def test_the_record_cadence_is_stable_and_the_final_tick_is_always_recorded() -> None:
level, book, script = load_walk_solo()
sparse = replace(script, record_every=5)
first = run_script(level, book, sparse)
second = run_script(level, book, sparse)
assert first.hashes == second.hashes
ticks = [tick for tick, _ in first.hashes]
assert ticks[0] == 0
assert ticks[-1] == 600 # the final tick is always recorded, cadence or not
assert set(ticks) == {tick for tick in range(0, 601) if tick % 5 == 0} | {600}
silent = replace(script, record_every=0)
unrecorded = run_script(level, book, silent)
assert unrecorded.hashes == ()
assert dict(unrecorded.states) == {}
assert unrecorded.state.tick == 600 # the run still runs; only the recording stops
def test_a_run_with_a_clock_measures_wall_seconds_and_a_pinned_length_clips() -> None:
"""`clock` is the seam the perf smoke times through; `max_ticks` pins the run."""
level, book, script = load_walk_solo()
measured = run_script(level, book, script, clock=time.perf_counter)
assert measured.wall_seconds > 0.0
clipped = run_script(level, book, script, max_ticks=10)
assert clipped.state.tick == 10
assert [tick for tick, _ in clipped.hashes] == list(range(11)) # cadence 1 + final
def test_compare_reports_a_cadence_mismatch_as_missing_and_extra() -> None:
"""A golden that records ticks the run did not (and vice versa) is visible."""
level, book, script = load_walk_solo()
sparse = run_script(level, book, replace(script, record_every=5))
by_tick = dict(sparse.hashes)
golden = {0: by_tick[0], 7: by_tick[5], 10: by_tick[10]}
mismatches = compare(sparse, golden)
kinds = {mismatch.tick: mismatch.kind for mismatch in mismatches}
assert kinds[7] == "missing" # the golden records a tick the run did not
assert kinds[5] == "extra" # the run records a tick the golden does not have
assert 0 not in kinds and 10 not in kinds # the matching ticks produce nothing
assert all(mismatch.changed == () for mismatch in mismatches)
# -- the goldens are read-only for the tests ----------------------------------
def test_the_field_diff_walks_mappings_and_missing_keys() -> None:
"""`counters['trashed']` appears mid-run: the diff must cross the mapping.
The `walk_solo` states all share one empty `counters` object (so the early
`a is b` return short-circuits it); the `pick_drop_pass` run is what makes
the mapping branch and a missing key on one side actually run.
"""
level, book, script = load_pick_drop_pass()
result = run_script(level, book, script)
ticks = sorted(result.states)
pair: tuple[Any, Any] | None = None
for a, b in pairwise(ticks):
if bool(result.states[a].counters) != bool(result.states[b].counters):
pair = (result.states[a], result.states[b])
break
assert pair is not None, "the trashed tick must flip counters from empty to non-empty"
changed = _field_diff(*pair)
assert "counters['trashed']" in changed, changed
def test_the_golden_files_are_never_rewritten_by_the_tests(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""The whole check flow — harness and CLI — must not touch the committed goldens."""
before = (
WALK_SOLO_GOLDEN.read_bytes(),
PICK_DROP_PASS_GOLDEN.read_bytes(),
)
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
level, book, script = load_pick_drop_pass() # the harness flow
assert compare(run_script(level, book, script), golden_map(PICK_DROP_PASS_GOLDEN)) == []
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_OK # the CLI flow
capsys.readouterr()
after = (
WALK_SOLO_GOLDEN.read_bytes(),
PICK_DROP_PASS_GOLDEN.read_bytes(),
)
assert before == after
# -- the CLI -------------------------------------------------------------------
def test_check_cli_exits_zero_and_reports_the_match(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_OK
out = capsys.readouterr().out
assert "ok: 601 hashes match walk_solo.golden.json" in out
def test_check_cli_exits_one_and_names_the_bad_tick_when_the_fixture_is_mutated(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""The task's acceptance test: edit one coordinate, `check` fails and diagnoses."""
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
original = WALK_SOLO_SCRIPT.read_bytes()
data = json.loads(original)
data["frames"][0]["move"] = [0.0, -1.0]
WALK_SOLO_SCRIPT.write_text(json.dumps(data, indent=2), encoding="utf-8")
try:
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_MISMATCH
finally:
WALK_SOLO_SCRIPT.write_bytes(original)
out = capsys.readouterr().out
assert "first divergent tick 1" in out
assert "players[0].pos.y" in out
assert WALK_SOLO_SCRIPT.read_bytes() == original # the fixture is left untouched
def test_check_cli_exits_two_without_a_golden(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
script = InputScript(
name="no_golden_here",
seed=1,
level_id="01_first_kitchen",
tick_hz=20,
frames=(Frame(tick=0, player_id="p0", move=(1.0, 0.0)),),
)
path = tmp_path / "no_golden_here.json"
script.dump(path)
code = main(["check", str(path)])
assert code == EXIT_UNUSABLE
def test_check_cli_exits_two_when_the_level_is_not_in_the_content_dir(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""The pick_drop_pass kitchen is a test fixture, not campaign content."""
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
assert main(["check", str(PICK_DROP_PASS_SCRIPT)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "99_replay_pick_drop_pass" in err
def test_record_cli_writes_the_golden_and_check_reads_it_back(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""`record` is the manual re-bless; into a temp goldens dir, the real one is untouched."""
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
committed_before = WALK_SOLO_GOLDEN.read_bytes()
script = InputScript.load(WALK_SOLO_SCRIPT)
path = tmp_path / "record_smoke.json"
replace(script, name="record_smoke").dump(path)
assert main(["record", str(path)], goldens_dir=tmp_path) == EXIT_OK
golden = tmp_path / "record_smoke.golden.json"
assert golden.exists()
assert main(["check", str(path)], goldens_dir=tmp_path) == EXIT_OK
capsys.readouterr()
assert main(["record", str(path)], goldens_dir=tmp_path) == EXIT_OK # the re-bless note
assert "overwriting" in capsys.readouterr().out
# and the committed golden never moves:
assert WALK_SOLO_GOLDEN.read_bytes() == committed_before
# while a fresh record reproduces the committed hash bit for bit:
fresh = json.loads(golden.read_text(encoding="utf-8"))
assert fresh["hash"] == json.loads(committed_before.decode("utf-8"))["hash"]
def test_check_cli_exits_two_on_a_broken_script_file(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
bad = tmp_path / "broken_script.json"
bad.write_text(json.dumps({"seed": 1, "level_id": "x", "bogus": 1}), encoding="utf-8")
assert main(["check", str(bad)]) == EXIT_UNUSABLE
assert "unknown script key(s): bogus" in capsys.readouterr().err
def test_check_cli_exits_two_when_the_content_would_not_load(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(tmp_path / "no_such_dir"))
assert main(["check", str(WALK_SOLO_SCRIPT)]) == EXIT_UNUSABLE
assert "would not load" in capsys.readouterr().err
def test_check_cli_reports_cadence_notes_and_the_missing_and_empty_diff_paths(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""The three diagnostic branches of a failed check, each named in the report."""
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(SHIPPED_LEVELS))
level, book, script = load_walk_solo()
sparse = replace(script, name="cadence_check", record_every=5)
path = tmp_path / "cadence_check.json"
sparse.dump(path)
assert main(["record", str(path)], goldens_dir=tmp_path) == EXIT_OK
# 1. the cadence note: the golden was recorded at 5, the script now says 1
replace(sparse, name="cadence_check", record_every=1).dump(path)
assert main(["check", str(path)], goldens_dir=tmp_path) == EXIT_MISMATCH
out = capsys.readouterr().out
assert "recorded at record_every=5" in out
# 2. a `missing` first mismatch: the golden records a tick the run did not
missing_path = tmp_path / "missing_first.json"
replace(sparse, name="missing_first").dump(missing_path)
first_zero = run_script(level, book, sparse).hashes[0][1]
(tmp_path / "missing_first.golden.json").write_text(
json.dumps(
{"name": "missing_first", "tick": 600, "record_every": 5,
"hashes": {"0": first_zero, "3": "x" * 64}}
),
encoding="utf-8",
)
assert main(["check", str(missing_path)], goldens_dir=tmp_path) == EXIT_MISMATCH
out = capsys.readouterr().out
assert "a golden tick this run did not record" in out
# 3. a hash mismatch at tick 0 carries no field diff (the baseline is itself)
empty_path = tmp_path / "empty_diff.json"
replace(sparse, name="empty_diff").dump(empty_path)
(tmp_path / "empty_diff.golden.json").write_text(
json.dumps(
{"name": "empty_diff", "tick": 1, "record_every": 5,
"hashes": {"0": "y" * 64}}
),
encoding="utf-8",
)
assert main(["check", str(empty_path)], goldens_dir=tmp_path) == EXIT_MISMATCH
out = capsys.readouterr().out
assert "no field diff" in out
# -- the script format ---------------------------------------------------------
def test_validate_refuses_a_broken_script_naming_every_problem() -> None:
script = InputScript(
seed=-1, # < 0
level_id="", # empty
tick_hz=10.0, # not 20
record_every=-3, # negative
frames=(Frame(tick=-5, player_id="", move=(float("nan"), 1.0), action=0, face="sideways"),), # type: ignore[arg-type] — the guard under test
items=(ScriptItem(station="", item="kale", form="soggy"),),
)
with pytest.raises(ReplayError) as excinfo:
script.validate()
message = str(excinfo.value)
assert "seed must be an integer in [0, 2**64)" in message
assert "level_id must be a non-empty string" in message
assert "tick_hz must be 20" in message
assert "record_every must be >= 0" in message
assert "tick must be a non-negative integer" in message
assert "move must be two finite numbers" in message
assert "action must be a bool" in message
assert "face must be one of" in message
assert "item must be an ItemKind value" in message
assert "form must be an ItemForm value" in message
def test_validate_refuses_a_non_integer_record_every() -> None:
script = InputScript(
seed=1,
level_id="x",
tick_hz=20,
record_every=1.5, # type: ignore[arg-type] — the guard exists for exactly this
)
with pytest.raises(ReplayError, match=r"record_every must be an integer"):
script.validate()
def test_validate_accepts_the_committed_scripts() -> None:
for path in (WALK_SOLO_SCRIPT, PICK_DROP_PASS_SCRIPT):
InputScript.load(path).validate()
def test_load_refuses_unknown_keys_at_every_level(tmp_path: Path) -> None:
bad = tmp_path / "bad.json"
bad.write_text(
json.dumps(
{
"seed": 1,
"level_id": "x",
"tick_hz": 20,
"bogus": 1,
"frames": [{"tick": 0, "player_id": "p", "move": [1, 0], "extra": 1}],
"items": [{"station": "s", "item": "tomato", "form": "raw", "mystery": 2}],
}
),
encoding="utf-8",
)
with pytest.raises(ReplayError, match=r"unknown script key\(s\): bogus"):
InputScript.load(bad)
bad_frame = tmp_path / "bad_frame.json"
bad_frame.write_text(
json.dumps(
{
"seed": 1,
"level_id": "x",
"tick_hz": 20,
"frames": [{"tick": 0, "player_id": "p", "move": [1, 0], "extra": 1}],
}
),
encoding="utf-8",
)
with pytest.raises(ReplayError, match=r"frame 0 has unknown key\(s\): extra"):
InputScript.load(bad_frame)
bad_item = tmp_path / "bad_item.json"
bad_item.write_text(
json.dumps(
{
"seed": 1,
"level_id": "x",
"tick_hz": 20,
"items": [
{"station": "s", "item": "tomato", "form": "raw", "mystery": 2}
],
}
),
encoding="utf-8",
)
with pytest.raises(ReplayError, match=r"item 0 has unknown key\(s\): mystery"):
InputScript.load(bad_item)
def test_load_refuses_every_malformed_shape(tmp_path: Path) -> None:
def writes(name: str, text: str) -> Path:
path = tmp_path / name
path.write_text(text, encoding="utf-8")
return path
with pytest.raises(ReplayError, match=r"must be a JSON object, got list"):
InputScript.load(writes("top_list.json", "[1, 2, 3]"))
with pytest.raises(ReplayError, match=r"missing required key 'seed'"):
InputScript.load(writes("no_seed.json", json.dumps({"level_id": "x", "tick_hz": 20})))
with pytest.raises(ReplayError, match=r"frame 0 must be a JSON object"):
InputScript.load(
writes("frame_scalar.json", json.dumps(
{"seed": 1, "level_id": "x", "tick_hz": 20, "frames": [42]}))
)
with pytest.raises(ReplayError, match=r"frame 0 is missing required key 'move'"):
InputScript.load(
writes("frame_no_move.json", json.dumps(
{"seed": 1, "level_id": "x", "tick_hz": 20,
"frames": [{"tick": 0, "player_id": "p"}]}))
)
with pytest.raises(ReplayError, match=r"move must be a two-element array"):
InputScript.load(
writes("move_not_list.json", json.dumps(
{"seed": 1, "level_id": "x", "tick_hz": 20,
"frames": [{"tick": 0, "player_id": "p", "move": 1}]}))
)
with pytest.raises(ReplayError, match=r"item 0 must be a JSON object"):
InputScript.load(
writes("item_scalar.json", json.dumps(
{"seed": 1, "level_id": "x", "tick_hz": 20, "items": [42]}))
)
with pytest.raises(ReplayError, match=r"item 0 is missing required key 'form'"):
InputScript.load(
writes("item_no_form.json", json.dumps(
{"seed": 1, "level_id": "x", "tick_hz": 20,
"items": [{"station": "s", "item": "tomato"}]}))
)
def test_dump_load_roundtrip_is_lossless(tmp_path: Path) -> None:
_, _, script = load_pick_drop_pass()
path = tmp_path / "roundtrip.json"
script.dump(path)
assert InputScript.load(path) == script
# an unnamed script dumps without a `name` key; `load` then takes the stem
unnamed = replace(script, name="")
unnamed_path = tmp_path / "unnamed.json"
unnamed.dump(unnamed_path)
data = json.loads(unnamed_path.read_text(encoding="utf-8"))
assert "name" not in data
assert InputScript.load(unnamed_path) == replace(unnamed, name=unnamed_path.stem)
def test_run_script_refuses_a_level_id_mismatch() -> None:
level, book, script = load_walk_solo()
other = replace(script, level_id="99_replay_pick_drop_pass")
with pytest.raises(ReplayError, match="names level"):
run_script(level, book, other)
def test_run_script_refuses_an_item_on_an_unknown_station() -> None:
level, book, script = load_walk_solo()
broken = replace(
script,
items=(ScriptItem(station="chop_board_9", item="tomato", form="raw"),),
)
with pytest.raises(ReplayError, match="does not have"):
run_script(level, book, broken)
def test_run_script_refuses_a_negative_max_ticks() -> None:
level, book, script = load_pick_drop_pass()
with pytest.raises(ReplayError, match="max_ticks"):
run_script(level, book, script, max_ticks=-1)
def test_a_frameless_script_runs_zero_ticks_and_records_the_built_state() -> None:
level, book, _ = load_pick_drop_pass()
frameless = InputScript(
name="idle", seed=0x5EED, level_id=level.id, tick_hz=20, frames=()
)
result = run_script(level, book, frameless)
assert result.state.tick == 0
assert [tick for tick, _ in result.hashes] == [0]
assert result.events == ()
+153
View File
@@ -0,0 +1,153 @@
"""The two-player carry script against its committed golden hash.
`tests/goldens/carry_two_player.golden.json` pins the state hash of a fixed 12-tick
script: one pass (a set-down picked up inside the hand-off window, twice — out and
back) and one contested pick (the full tie broken by seat, the loser told
`contested_lost`). The event sequence is asserted in code, so a golden drift is
diagnosable in one read: if the events match but the hash does not, a constant or
a subsystem order moved; if the events differ, a rule changed shape.
The golden is committed, not computed: a mismatch fails, and this test prints the
diff hint. Regeneration is deliberate — an intentional sim-contract change recorded
in the phase task, never a side effect of a failing run (task 05's replay harness
formalises the re-bless path and the input-script format this script foreshadows).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
Facing,
Item,
ItemForm,
ItemKind,
KitchenState,
Player,
Station,
StationKind,
TileKind,
Vec2,
)
from undercooked.sim.step import STEP_DT, PlayerInput, step
pytestmark = pytest.mark.unit
GOLDEN_PATH: Path = (
Path(__file__).resolve().parent.parent / "goldens" / "carry_two_player.golden.json"
)
WIDTH, HEIGHT = 13, 9
def build_state() -> KitchenState:
"""Tick 0: A at the prep holding a lettuce, B at its left diagonal, both facing it."""
grid = Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT))
prep = Station(id="prep-9-4", kind=StationKind.PREP, x=9, y=4)
a = Player(
id="cook-a",
pos=Vec2(9.5, 5.5),
facing=Facing.UP,
carrying=Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW),
seat=0,
)
b = Player(id="cook-b", pos=Vec2(8.5, 5.5), facing=Facing.UP, seat=1)
return KitchenState(
level_id="03_replay_carry",
seed=99,
grid=grid,
stations=(prep,),
players=(a, b),
target_score=40,
time_left=90.0,
)
def frame(player_id: str, seq: int, action: bool, tick: int) -> PlayerInput:
return PlayerInput(player_id=player_id, seq=seq, action=action, received_at_tick=tick)
# The 12-tick script: (tick, one or two frames). The pass is t0 -> t2 (a sets down,
# b picks: HANDED_OFF), the contest is t6 (both press the prep's item: a, seat 0,
# wins; b is told contested_lost), and the return pass is t8 -> t10.
SCRIPT: tuple[tuple[int, tuple[PlayerInput, ...]], ...] = (
(0, (frame("cook-a", 0, True, 0),)),
(1, (frame("cook-a", 1, False, 1),)),
(2, (frame("cook-b", 2, True, 2),)),
(3, (frame("cook-b", 3, False, 3),)),
(4, (frame("cook-b", 4, True, 4),)),
(5, (frame("cook-b", 5, False, 5),)),
(6, (frame("cook-a", 6, True, 6), frame("cook-b", 6, True, 6))),
(7, (frame("cook-a", 7, False, 7),)),
(8, (frame("cook-a", 8, True, 8),)),
(9, (frame("cook-a", 9, False, 9),)),
(10, (frame("cook-b", 10, True, 10),)),
(11, (frame("cook-b", 11, False, 11),)),
)
# The event the script must produce, tick by tick (kinds and the load-bearing payload).
EXPECTED_EVENTS: tuple[tuple[str, dict[str, str]], ...] = (
("dropped", {"player_id": "cook-a", "station": "prep-9-4"}),
("handed_off", {"from": "cook-a", "to": "cook-b"}),
("dropped", {"player_id": "cook-b", "station": "prep-9-4"}),
("handed_off", {"from": "cook-b", "to": "cook-a"}),
("denied", {"player_id": "cook-b", "reason": "contested_lost"}),
("dropped", {"player_id": "cook-a", "station": "prep-9-4"}),
("handed_off", {"from": "cook-a", "to": "cook-b"}),
)
def run_script() -> tuple[KitchenState, tuple[tuple[str, dict[str, object]], ...]]:
"""Run the 12-tick script; return the final state and the events, keyed by kind."""
state = build_state()
events: list[tuple[str, dict[str, object]]] = []
for _tick, frames in SCRIPT:
state, produced = step(state, STEP_DT, frames)
events.extend((event.kind, dict(event.payload)) for event in produced)
return state, tuple(events)
def test_the_script_produces_the_expected_event_sequence() -> None:
_state, events = run_script()
assert len(events) == len(EXPECTED_EVENTS)
for (got_kind, got_payload), (want_kind, want_payload) in zip(
events, EXPECTED_EVENTS, strict=True
):
assert got_kind == want_kind
for key, value in want_payload.items():
assert got_payload.get(key) == value, (got_kind, got_payload)
def test_the_script_reaches_the_committed_golden_hash() -> None:
golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
state, _events = run_script()
assert state.tick == golden["tick"]
if state.state_hash() != golden["hash"]:
# The diff hint the phase promises: name both hashes so the commit shows the drift.
print(
f"golden mismatch for {GOLDEN_PATH.name}:\n"
f" expected {golden['hash']}\n"
f" actual {state.state_hash()}\n"
"Regenerate only on a deliberate sim-contract change recorded in the phase "
"task (docs/SIMULATION.md, 'Goldens'); never to silence a failure."
)
assert state.state_hash() == golden["hash"]
def test_the_golden_file_is_committed_and_well_formed() -> None:
golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
assert golden["name"] == "carry_two_player"
assert golden["tick"] == 12
assert len(golden["hash"]) == 64 # a sha256 hex digest, nothing else
def test_rerunning_the_script_is_bit_identical() -> None:
"""No hidden global state: the same script twice in one process hashes the same."""
first, first_events = run_script()
second, second_events = run_script()
assert first.state_hash() == second.state_hash()
assert first_events == second_events
+218
View File
@@ -0,0 +1,218 @@
"""`sim/rng.py` is golden-vector locked — the replay contract starts here (A21).
The numbers below are not "whatever the code returns today": they are splitmix64
as published (Steele/Lea/Venc 2014, Algorithm 5), written down once and then
never moved. If a future change to the generator breaks them, a committed replay
golden would silently describe a different game, and that is exactly what a
replay is supposed to catch.
Beyond the vectors, the tests pin the properties the kernel relies on: the
counter wraps at 2**64, `next_float` is a half-open [0,1) draw that is actually
spread out, `range_int` hits both of its ends, `fork` is reproducible and never
disturbs its parent, and nothing in the module is shared global state.
"""
from __future__ import annotations
import pytest
from undercooked.sim.rng import GOLDEN_GAMMA, MASK64, Rng, mix64
pytestmark = pytest.mark.unit
# --- published splitmix64 vectors ---------------------------------------------------
def test_seed_zero_u64_vector() -> None:
rng = Rng(0)
assert [rng.next_u64() for _ in range(4)] == [
16294208416658607535,
7960286522194355700,
487617019471545679,
17909611376780542444,
]
def test_round_seed_vector() -> None:
# 0x5EED0401 is the `random_seed` fixture value: what a real round draws first.
rng = Rng(0x5EED_0401)
assert [rng.next_u64() for _ in range(2)] == [
966088850652082199,
13654240339074911480,
]
def test_next_float_vector() -> None:
rng = Rng(0)
assert rng.next_float() == pytest.approx(0.8833108082136426, abs=0.0)
assert rng.next_float() == pytest.approx(0.43152799704850997, abs=0.0)
assert rng.next_float() == pytest.approx(0.026433771592597743, abs=0.0)
def test_range_int_vector() -> None:
rng = Rng(0)
assert [rng.range_int(0, 5) for _ in range(8)] == [1, 0, 1, 4, 1, 0, 5, 2]
def test_choice_vector() -> None:
rng = Rng(1)
assert [rng.choice(["a", "b", "c", "d"]) for _ in range(6)] == [
"b",
"d",
"c",
"d",
"b",
"a",
]
def test_fork_vector() -> None:
assert Rng(0).fork(0).state == 5197578548964807871
assert Rng(0).fork(7).state == 8409816773569330625
assert Rng(0).fork(8).state == 15655543297350242377
assert Rng(0).fork(0).next_u64() == 6235967106033911276
def test_mix64_is_the_published_finalizer() -> None:
assert mix64(0) == 0 # the fixed point `fork` steps around
assert mix64(GOLDEN_GAMMA) == 16294208416658607535 # == Rng(0).next_u64()
# --- the counter --------------------------------------------------------------------
@pytest.mark.parametrize("seed", [0, -1, -2**64, 2**64, 2**65 + 7, MASK64])
def test_any_seed_is_reduced_to_64_bits(seed: int) -> None:
rng = Rng(seed)
assert 0 <= rng.state <= MASK64
def test_seeds_that_agree_mod_2p64_agree() -> None:
assert Rng(-1).state == MASK64
assert Rng(-1).next_u64() == Rng(MASK64).next_u64()
def test_counter_wraps_and_never_leaves_64_bits() -> None:
rng = Rng(MASK64 - 2)
for _ in range(6):
rng.next_u64()
assert 0 <= rng.state <= MASK64
def test_zero_seed_is_not_a_fixed_point() -> None:
rng = Rng(0)
first = rng.next_u64()
assert rng.state != 0
assert first != rng.next_u64()
# --- draw distributions ---------------------------------------------------------------
def test_next_float_is_half_open_over_many_draws() -> None:
rng = Rng(0xC0FFEE)
draws = [rng.next_float() for _ in range(10_000)]
assert all(0.0 <= draw < 1.0 for draw in draws)
def test_next_float_is_spread_out() -> None:
rng = Rng(0xC0FFEE)
buckets = [0] * 10
for _ in range(10_000):
buckets[int(rng.next_float() * 10)] += 1
assert all(850 <= count <= 1150 for count in buckets), buckets
mean_bucket = sum(count * (index + 0.5) for index, count in enumerate(buckets)) / 10_000
assert 4.5 <= mean_bucket <= 5.5
def test_next_float_uses_the_whole_range() -> None:
rng = Rng(424_242)
draws = [rng.next_float() for _ in range(10_000)]
assert min(draws) < 0.01
assert max(draws) > 0.99
def test_range_int_stays_in_bounds_and_reaches_both_ends() -> None:
rng = Rng(2024)
draws = [rng.range_int(3, 9) for _ in range(5_000)]
assert set(draws) == {3, 4, 5, 6, 7, 8, 9}
def test_range_int_of_a_single_value_never_moves() -> None:
rng = Rng(9)
assert [rng.range_int(-2, -2) for _ in range(4)] == [-2, -2, -2, -2]
def test_range_int_rejects_an_empty_range() -> None:
with pytest.raises(ValueError, match=r"empty integer range 5\.\.2"):
Rng(0).range_int(5, 2)
def test_choice_only_returns_members() -> None:
rng = Rng(7)
flavors = ["tomato", "onion", "lettuce"]
assert {rng.choice(flavors) for _ in range(500)} == set(flavors)
def test_choice_rejects_an_empty_sequence() -> None:
with pytest.raises(ValueError, match="empty sequence"):
Rng(0).choice([])
# --- streams are independent, and fork does not disturb the parent ----------------------
def test_two_generators_from_one_seed_agree() -> None:
left, right = Rng(1234), Rng(1234)
assert [left.next_u64() for _ in range(32)] == [right.next_u64() for _ in range(32)]
def test_a_generator_is_not_shared_state() -> None:
first, second = Rng(5), Rng(5)
for _ in range(4):
first.next_u64()
assert second.state == 5
assert second.next_u64() == Rng(5).next_u64()
def test_fork_is_reproducible_and_independent() -> None:
parent = Rng(0xC1)
child_a, child_b = parent.fork(3), parent.fork(3)
assert child_a == child_b
assert [child_a.next_u64() for _ in range(8)] == [child_b.next_u64() for _ in range(8)]
def test_fork_leaves_the_parent_stream_untouched() -> None:
parent = Rng(0xC1)
before = parent.state
for salt in range(6):
parent.fork(salt)
assert parent.state == before
assert parent.next_u64() == Rng(0xC1).next_u64()
@pytest.mark.parametrize(
("salt", "differs_from_zero"),
[(0, False), (1, True), (2, True), (7, True), (-1, True), (MASK64, True)],
)
def test_the_salt_selects_the_stream(salt: int, differs_from_zero: bool) -> None:
parent = Rng(0)
other = parent.fork(salt)
assert (other.state != parent.fork(0).state) is differs_from_zero
def test_fork_zero_is_not_the_parent_stream() -> None:
# mix64(0) == 0, so a naive `state ^ mix64(salt)` would clone the parent here.
assert Rng(0).fork(0).state != 0
assert Rng(0).fork(0).next_u64() != Rng(0).next_u64()
def test_nested_forks_are_reproducible() -> None:
outer = Rng(77)
deep_a = outer.fork(1).fork(2).next_u64()
deep_b = Rng(77).fork(1).fork(2).next_u64()
assert deep_a == deep_b
def test_fork_accepts_any_integer_salt() -> None:
assert Rng(3).fork(-42).state == Rng(3).fork(-42 + (1 << 64)).state
+587
View File
@@ -0,0 +1,587 @@
"""`build_state` + `step` — the kernel's contract: order, clamping, purity, determinism.
Phase 03 task 01 promises four things, and each has its own test below rather
than being inferred from a golden hash (that arrives with task 05):
* **Clamping** — `dt` outside `[0, MAX_DT]` buys nothing, so a stalled process
is never repaid with a burst of substeps;
* **Accumulation** — the sim advances in whole `STEP_DT` substeps and carries
the remainder, so 17 x 0.05 s is exactly 17 ticks and 0.03 s twice is one tick;
* **Rejection, not crash** — input from a player this kitchen does not have
becomes an `err` event;
* **Purity + determinism** — `step()` never touches the state it was handed,
and two states built from one seed stay bit-identical for hundreds of ticks.
"""
from __future__ import annotations
import copy
import math
from collections.abc import Sequence
from dataclasses import replace
from typing import Any
import pytest
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import (
EventKind,
Facing,
KitchenState,
PlayerState,
RoundResult,
Vec2,
)
from undercooked.sim.purity import assert_pure_imports
from undercooked.sim.recipe import RecipeBook
from undercooked.sim.state_build import (
SEAT_COLORS,
SeatSpec,
StateBuildError,
build_state,
color_for_seat,
)
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, resolve_inputs, step
pytestmark = pytest.mark.unit
# --- fixture content ------------------------------------------------------------------
TILES: dict[str, dict[str, str]] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
}
GRID = [
"#######",
"#S.C..#",
"#.....#",
"#....V#",
"#######",
]
RECIPES: dict[str, Any] = {
"soup": {
"id": "soup",
"name": "Tomato Soup",
"base_score": 40,
"ingredients": [{"kind": "tomato", "form": "raw"}],
"steps": [{"kind": "serve", "station": "serve"}],
},
}
def make_level(**overrides: Any) -> Level:
data: dict[str, Any] = {
"id": "01_kernel",
"name": "Kernel Kitchen",
"order": 1,
"time_limit": 12,
"target_score": 40,
"difficulty": 1,
"grid": copy.deepcopy(GRID),
"tiles": copy.deepcopy(TILES),
"spawns": [[1, 1]],
"orders": {"pool": ["soup"], "concurrent": 1, "spawn_interval": [4, 6], "deadline": 30},
}
data.update(overrides)
return Level.build(LevelDef.model_validate(data))
def make_book() -> RecipeBook:
return RecipeBook.model_validate({"recipes": copy.deepcopy(RECIPES)})
def seats(count: int, start: int = 0) -> tuple[SeatSpec, ...]:
return tuple(
SeatSpec(player_id=f"p{index}", seat=index, display_name=f"Cook {index}")
for index in range(start, start + count)
)
@pytest.fixture
def level() -> Level:
return make_level()
@pytest.fixture
def state(level: Level) -> KitchenState:
"""A three-cook round on the fixture level, seed 0x5EED0401."""
return build_state(level, seats(3), 0x5EED_0401, make_book())
# --- SeatSpec -------------------------------------------------------------------------
def test_seat_colours_are_a_fixed_palette_of_four() -> None:
assert len(SEAT_COLORS) == 4
assert len(set(SEAT_COLORS)) == 4
assert all(color.startswith("#") and len(color) == 7 for color in SEAT_COLORS)
@pytest.mark.parametrize(
("seat", "expected"),
[(0, SEAT_COLORS[0]), (1, SEAT_COLORS[1]), (2, SEAT_COLORS[2]), (3, SEAT_COLORS[3])],
)
def test_colour_for_seat_follows_the_palette(seat: int, expected: str) -> None:
assert color_for_seat(seat) == expected
def test_colour_for_seat_wraps_past_the_fourth_cook() -> None:
assert color_for_seat(4) == SEAT_COLORS[0]
assert color_for_seat(11) == SEAT_COLORS[3]
def test_seat_spec_defaults_to_the_palette_colour() -> None:
seat = SeatSpec(player_id="abc", seat=2, display_name="Reese")
assert seat.resolved_color == SEAT_COLORS[2]
def test_seat_spec_keeps_an_explicit_colour() -> None:
seat = SeatSpec(player_id="abc", seat=0, display_name="Reese", color="#123456")
assert seat.resolved_color == "#123456"
def test_seat_spec_is_hashable_and_a_value() -> None:
a = SeatSpec("abc", 1, "Ann", "")
assert a == SeatSpec("abc", 1, "Ann", "")
assert len({a, SeatSpec("abc", 1, "Ann", "")}) == 1
def test_negative_seat_is_refused() -> None:
with pytest.raises(StateBuildError, match="non-negative"):
SeatSpec(player_id="abc", seat=-1, display_name="Ann")
# --- build_state ------------------------------------------------------------------------
def test_build_state_starts_at_tick_zero_with_the_level_clock(
level: Level, state: KitchenState
) -> None:
assert state.tick == 0
assert state.accumulator == 0.0
assert state.time_left == level.time_limit == 12
assert state.target_score == level.target_score == 40
assert state.level_id == "01_kernel"
assert state.seed == 0x5EED_0401
assert state.rng_state == 0x5EED_0401
assert state.score == 0
assert state.streak == 0
assert state.result is None
assert dict(state.counters) == {}
def test_build_state_takes_the_grid_and_stations_from_the_level(
level: Level, state: KitchenState
) -> None:
assert state.grid is level.grid
assert state.stations == level.stations
assert {station.kind.value for station in state.stations} >= {
"spawn_shelf",
"chop_board",
"serve",
}
def test_orders_and_hazards_start_empty_for_later_phases(state: KitchenState) -> None:
assert state.orders == ()
assert state.hazards == ()
def test_players_are_built_in_seat_order(state: KitchenState) -> None:
assert [player.id for player in state.players] == ["p0", "p1", "p2"]
assert [player.seat for player in state.players] == [0, 1, 2]
def test_players_start_at_the_spawn_centre_facing_down(level: Level, state: KitchenState) -> None:
assert level.spawns == ((1, 1),)
first = state.players[0]
assert first.pos == Vec2(1.5, 1.5)
assert first.facing is Facing.DOWN
assert first.state is PlayerState.IDLE
assert first.connected is True
assert first.carrying is None
assert first.vel == Vec2(0.0, 0.0)
assert first.stun == 0.0
def test_cooks_sharing_a_spawn_are_offset_by_a_quarter_tile(state: KitchenState) -> None:
_, second, third = state.players
assert second.pos == Vec2(1.75, 1.5)
assert third.pos == Vec2(1.5, 1.75)
positions = {player.pos for player in state.players}
assert len(positions) == 3
def test_a_full_kitchen_stays_inside_its_spawn_tile(state: KitchenState) -> None:
four = build_state(make_level(), seats(4), 7, make_book())
assert [player.pos for player in four.players] == [
Vec2(1.5, 1.5),
Vec2(1.75, 1.5),
Vec2(1.5, 1.75),
Vec2(1.75, 1.75),
]
assert all(
four.grid.is_walkable(int(player.pos.x), int(player.pos.y)) for player in four.players
)
def test_seats_wrap_over_the_available_spawns() -> None:
level = make_level(spawns=[[1, 1], [5, 3]])
built = build_state(level, seats(4), 1, make_book())
assert [(player.pos.x, player.pos.y) for player in built.players] == [
(1.5, 1.5), # seat 0 -> spawn 0
(5.5, 3.5), # seat 1 -> spawn 1
(1.75, 1.5), # seat 2 -> spawn 0 again, offset
(5.75, 3.5), # seat 3 -> spawn 1 again, offset
]
def test_seat_spec_order_does_not_matter() -> None:
scrambled = (
SeatSpec("p2", 2, "C"),
SeatSpec("p0", 0, "A"),
SeatSpec("p1", 1, "B"),
)
built = build_state(make_level(), scrambled, 5, make_book())
assert [player.id for player in built.players] == ["p0", "p1", "p2"]
def test_no_seats_builds_an_empty_kitchen(level: Level) -> None:
built = build_state(level, (), 3, make_book())
assert built.players == ()
assert built.state_hash() # still a perfectly hashable round
def test_a_level_without_spawns_cannot_be_built(level: Level) -> None:
barren = replace(level, spawns=())
with pytest.raises(StateBuildError, match="defines no spawns"):
build_state(barren, seats(1), 1, make_book())
def test_build_state_is_reproducible(level: Level) -> None:
first = build_state(level, seats(3), 99, make_book())
second = build_state(level, seats(3), 99, make_book())
assert first.state_hash() == second.state_hash()
# --- step: clamping and accumulation ------------------------------------------------------
def tick(state: KitchenState, dt: float) -> int:
"""Ticks a single `step()` call buys from a fresh accumulator."""
advanced, _ = step(state, dt)
return advanced.tick
def test_one_substep_is_one_tick(state: KitchenState) -> None:
assert tick(state, STEP_DT) == 1
@pytest.mark.parametrize("dt", [0.0, -0.0, -1.0, -STEP_DT, float("-inf")])
def test_a_non_positive_clock_advance_buys_nothing(state: KitchenState, dt: float) -> None:
assert tick(state, dt) == 0
def test_a_nan_clock_advance_is_treated_as_no_time(state: KitchenState) -> None:
dt: Any = float("nan")
advanced, events = step(state, dt)
assert advanced.tick == 0
assert events == ()
def test_one_call_never_runs_more_than_max_dt(state: KitchenState) -> None:
assert tick(state, MAX_DT) == 2
assert tick(state, 60.0) == 2 # a minute of wall time is still two substeps
assert tick(state, float("inf")) == 2
def test_the_ceiling_is_exactly_max_dt_over_step_dt() -> None:
assert MAX_DT / STEP_DT == 2.0
def test_seventeen_slices_of_step_dt_are_seventeen_ticks(state: KitchenState) -> None:
current = state
for _ in range(17):
current, _events = step(current, STEP_DT)
assert current.tick == 17
assert current.accumulator == pytest.approx(0.0, abs=1e-12)
def test_time_below_one_substep_is_carried_not_dropped(state: KitchenState) -> None:
current = state
ticks_seen: list[int] = []
for _ in range(10): # 10 x 0.03 s = 0.3 s -> six substeps, remainder 0.0
current, _events = step(current, 0.03)
ticks_seen.append(current.tick)
assert ticks_seen == [0, 1, 1, 2, 3, 3, 4, 4, 5, 6]
assert current.accumulator == pytest.approx(0.0, abs=1e-9)
def test_the_accumulator_holds_back_a_partial_substep(state: KitchenState) -> None:
current, _events = step(state, 0.04)
assert current.tick == 0
assert current.accumulator == pytest.approx(0.04)
current, _events = step(current, 0.02)
assert current.tick == 1
assert current.accumulator == pytest.approx(0.01)
def test_the_accumulator_never_goes_negative(state: KitchenState) -> None:
current = state
for dt in (0.05, 0.07, 0.01, 0.001, 0.049):
current, _events = step(current, dt)
assert current.accumulator >= 0.0
assert current.accumulator < STEP_DT
def test_the_clock_counts_down_with_the_ticks(state: KitchenState) -> None:
current = state
for _ in range(10):
current, _events = step(current, STEP_DT)
assert current.time_left == pytest.approx(12 - 10 * STEP_DT)
# --- step: inputs ------------------------------------------------------------------------
def inputs(*specs: tuple[str, int, int]) -> tuple[PlayerInput, ...]:
"""`PlayerInput` from (player_id, seq, received_at_tick) triples."""
return tuple(
PlayerInput(player_id=player_id, seq=seq, received_at_tick=received)
for player_id, seq, received in specs
)
def test_inputs_for_real_players_are_accepted_without_events(state: KitchenState) -> None:
advanced, events = step(state, STEP_DT, inputs(("p0", 1, 0)))
assert events == ()
assert advanced.tick == 1
def test_an_unknown_player_is_an_error_event_not_a_crash(state: KitchenState) -> None:
advanced, events = step(state, STEP_DT, inputs(("ghost", 4, 0)))
assert advanced.tick == 1 # the round keeps running
assert len(events) == 1
assert events[0].kind is EventKind.ERROR
assert dict(events[0].payload) == {
"player_id": "ghost",
"reason": "unknown_player",
"seq": 4,
}
# Dropping the ghost must cost the real cooks nothing: compared against the same tick
# with no input at all, not against `state`, because movement now separates the two
# cooks who share a spawn tile and that is the kernel's doing, not the ghost's.
frameless, _events = step(state, STEP_DT)
assert advanced.players == frameless.players
def test_a_mixed_batch_reports_only_the_ghost(state: KitchenState) -> None:
_advanced, events = step(
state, STEP_DT, inputs(("p1", 1, 0), ("nobody", 2, 0), ("p2", 1, 0))
)
assert [event.kind for event in events] == [EventKind.ERROR]
assert events[0].payload["player_id"] == "nobody"
def test_resolve_inputs_keeps_the_earliest_frame(state: KitchenState) -> None:
resolved, events = resolve_inputs(
state, inputs(("p0", 9, 5), ("p0", 10, 2), ("p0", 11, 7))
)
assert events == ()
assert resolved["p0"].seq == 10
def test_resolve_inputs_breaks_an_arrival_tie_by_seq(state: KitchenState) -> None:
resolved, _events = resolve_inputs(state, inputs(("p0", 12, 3), ("p0", 11, 3)))
assert resolved["p0"].seq == 11
def test_resolve_inputs_is_stable_on_a_full_tie(state: KitchenState) -> None:
resolved, _events = resolve_inputs(state, inputs(("p0", 8, 3), ("p0", 8, 3)))
assert resolved["p0"].seq == 8
def test_resolve_inputs_iterates_in_seat_order(state: KitchenState) -> None:
resolved, _events = resolve_inputs(state, inputs(("p2", 1, 9), ("p0", 1, 4), ("p1", 1, 6)))
assert list(resolved) == ["p0", "p1", "p2"]
def test_resolve_inputs_drops_everything_for_players_that_are_not_here(
state: KitchenState,
) -> None:
resolved, events = resolve_inputs(state, inputs(("ghost", 1, 0), ("p0", 1, 0)))
assert list(resolved) == ["p0"]
assert len(events) == 1
def test_resolve_inputs_of_nothing_is_empty(state: KitchenState) -> None:
assert resolve_inputs(state, ()) == ({}, ())
def test_an_empty_kitchen_rejects_every_input() -> None:
barren = build_state(make_level(), (), 2, make_book())
resolved, events = resolve_inputs(barren, inputs(("p0", 1, 0)))
assert resolved == {}
assert len(events) == 1
def test_input_fields_survive_untouched(state: KitchenState) -> None:
frame = PlayerInput(
player_id="p0",
seq=3,
move=Vec2(0.6, -0.8),
action=True,
face=Facing.LEFT,
received_at_tick=11,
)
resolved, _events = resolve_inputs(state, (frame,))
kept = resolved["p0"]
assert kept is frame
assert (kept.move, kept.action, kept.face, kept.received_at_tick) == (
Vec2(0.6, -0.8),
True,
Facing.LEFT,
11,
)
def test_player_input_defaults() -> None:
frame = PlayerInput(player_id="p0")
assert frame.seq == 0
assert frame.move == Vec2(0.0, 0.0)
assert frame.action is False
assert frame.face is None
assert frame.received_at_tick == 0
def test_player_input_is_frozen(state: KitchenState) -> None:
frame = PlayerInput(player_id="p0")
with pytest.raises(AttributeError):
frame.seq = 1 # type: ignore[misc]
# --- step: purity ------------------------------------------------------------------------
def test_step_does_not_mutate_the_state_it_was_given(state: KitchenState) -> None:
before_hash = state.state_hash()
snapshot = replace(state)
advanced, _events = step(state, 0.2, inputs(("p0", 1, 0), ("ghost", 1, 0)))
assert state == snapshot
assert state.state_hash() == before_hash
assert advanced != state
assert advanced.tick == 2
def test_the_events_are_a_tuple_in_order(state: KitchenState) -> None:
_advanced, events = step(state, STEP_DT, inputs(("ghost-a", 1, 0), ("ghost-b", 1, 0)))
assert isinstance(events, tuple)
assert [event.payload["player_id"] for event in events] == ["ghost-a", "ghost-b"]
def test_the_kernel_modules_stay_free_of_forbidden_imports() -> None:
assert_pure_imports()
# --- step: determinism --------------------------------------------------------------------
def run(
state: KitchenState,
script: Sequence[tuple[float, tuple[PlayerInput, ...]]],
) -> KitchenState:
current = state
for dt, frames in script:
current, _events = step(current, dt, frames)
return current
def test_two_rounds_from_one_seed_stay_identical_over_200_steps(level: Level) -> None:
left = build_state(level, seats(3), 0x5EED_0401, make_book())
right = build_state(level, seats(3), 0x5EED_0401, make_book())
script = [
(STEP_DT, inputs(("p0", index, index), ("p1", index, index)))
for index in range(200)
]
assert run(left, script).state_hash() == run(right, script).state_hash()
def test_the_seed_reaches_the_state_so_two_seeds_differ(level: Level) -> None:
one = build_state(level, seats(2), 1, make_book())
other = build_state(level, seats(2), 2, make_book())
assert one.state_hash() != other.state_hash()
assert one.rng_state != other.rng_state
def test_tick_is_monotonic_across_200_steps(state: KitchenState) -> None:
current = state
previous = current.tick
for _ in range(200):
current, _events = step(current, STEP_DT)
assert current.tick == previous + 1
assert current.time_left <= 12.0
previous = current.tick
def test_a_step_that_runs_no_substeps_changes_nothing_but_the_accumulator(
state: KitchenState,
) -> None:
current, events = step(state, 0.01)
assert events == ()
assert current.tick == 0
assert current.time_left == state.time_left
assert current.players == state.players
def test_batched_and_sliced_time_agree(state: KitchenState) -> None:
batched, _ = step(state, MAX_DT)
sliced = step(state, 0.025)[0]
sliced = step(sliced, 0.025)[0]
sliced = step(sliced, 0.025)[0]
sliced, _ = step(sliced, 0.025)
assert batched.tick == sliced.tick == 2
assert batched.state_hash() == sliced.state_hash()
# --- step: the round timer (stage 7) --------------------------------------------------------
def test_the_round_ends_lost_when_the_clock_beats_the_score(state: KitchenState) -> None:
current = state
for _ in range(240): # 240 x 0.05 s = the whole 12 s clock
current, _events = step(current, STEP_DT)
assert current.time_left == 0.0
assert current.result is RoundResult.LOST
assert current.tick == 240
def test_the_round_is_won_on_the_buzzer_when_the_target_was_reached(
state: KitchenState,
) -> None:
winning = replace(state, score=state.target_score)
current = winning
for _ in range(240):
current, _events = step(current, STEP_DT)
assert current.result is RoundResult.WON
def test_the_clock_never_goes_negative_after_the_buzzer(state: KitchenState) -> None:
current = replace(state, time_left=STEP_DT)
current, _events = step(current, STEP_DT)
assert current.time_left == 0.0
assert current.result is RoundResult.LOST
current, _events = step(current, STEP_DT)
assert current.time_left == 0.0
assert not math.isclose(current.time_left, -1.0)
def test_an_already_decided_round_keeps_its_result(state: KitchenState) -> None:
decided = replace(state, time_left=STEP_DT, result=RoundResult.WON, score=99)
current, _events = step(decided, STEP_DT)
assert current.result is RoundResult.WON
assert current.time_left == 0.0
+495
View File
@@ -0,0 +1,495 @@
"""`sim/interact.py` targeting — what the reach zone contains (phase 03 task 03).
The reach zone is discrete first and continuous second: `candidate_cells()` names
the tiles the hand walks (front, the two diagonal-front, underfoot) and
`REACH + FORGIVE` decides whether the cook is actually standing close enough to
the tile it found. This file pins both halves with hand-computed geometry:
* **candidate cells** — the per-facing order, and the fact that the four
facings are 90° rotations of one row rather than four ad-hoc tie-breaks;
* **reach** — the forgiveness boundary (0.99 ok, 1.6 itself inclusive, 1.61
rejected), including the case the diagonal tier exists for: the same diagonal
station is out of reach from the far corner of the cook's tile and in reach
from the near corner;
* **priority** — item outranks the fixture it sits on, a full hand skips the
item, a non-ready fixture holds its contents, the serve window is its own
target kind, nothing at all is `None`;
* **the wall** — a fixture two tiles ahead (wall in between) is never a
candidate: the discrete walk has no "through the wall" mode, and the
out-of-reach skip keeps the walk going so a nearer tile still gets its turn.
The press-edge behaviour — one press, one named refusal, however long the button
stays held — is at the bottom, through the full `step()`.
"""
from __future__ import annotations
import pytest
from undercooked.sim.grid import Grid
from undercooked.sim.interact import (
FORGIVE,
REACH,
DeniedReason,
ItemTarget,
ServeTarget,
StationTarget,
Verb,
candidate_cells,
faces_target,
select_target,
select_verb,
station_cells,
target_center,
within_reach,
)
from undercooked.sim.model import (
EventKind,
Facing,
Item,
ItemForm,
ItemKind,
KitchenState,
Player,
PlayerState,
Station,
StationKind,
StationState,
TileKind,
Vec2,
)
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, step
pytestmark = pytest.mark.unit
WIDTH, HEIGHT = 13, 9
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
# --- fixtures ---------------------------------------------------------------------------
def make_state(
stations: tuple[Station, ...] = (),
players: tuple[Player, ...] = (),
tile_overrides: dict[tuple[int, int], TileKind] | None = None,
) -> KitchenState:
tiles = [TileKind.FLOOR] * (WIDTH * HEIGHT)
for (x, y), kind in (tile_overrides or {}).items():
tiles[y * WIDTH + x] = kind
return KitchenState(
level_id="03_targeting",
seed=1,
grid=Grid(width=WIDTH, height=HEIGHT, tiles=tuple(tiles)),
stations=stations,
players=players,
target_score=40,
time_left=90.0,
)
def cook(
player_id: str = "p0",
*,
pos: Vec2 = DEFAULT_POS,
facing: Facing = Facing.UP,
carrying: Item | None = None,
stun: float = 0.0,
state: PlayerState = PlayerState.IDLE,
seat: int = 0,
held_ticks: int = 0,
pressed_tick: int = 0,
) -> Player:
return Player(
id=player_id,
pos=pos,
facing=facing,
carrying=carrying,
state=state,
stun=stun,
seat=seat,
action_held_ticks=held_ticks,
action_pressed_tick=pressed_tick,
)
def fixture(
kind: StationKind = StationKind.STOVE,
*,
x: int = 5,
y: int = 4,
state: StationState = StationState.IDLE,
contents: tuple[Item, ...] = (),
worker_id: str | None = None,
dirty: bool = False,
cooldown: float = 0.0,
) -> Station:
return Station(
id=f"{kind.value}-{x}-{y}",
kind=kind,
x=x,
y=y,
state=state,
contents=contents,
worker_id=worker_id,
dirty=dirty,
cooldown=cooldown,
)
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
return Item(kind=ItemKind.TOMATO, form=form)
# --- candidate cells --------------------------------------------------------------------
@pytest.mark.parametrize(
("facing", "expected"),
[
(Facing.UP, ((2, 2), (1, 2), (3, 2), (2, 3))),
(Facing.RIGHT, ((3, 3), (3, 2), (3, 4), (2, 3))),
(Facing.DOWN, ((2, 4), (3, 4), (1, 4), (2, 3))),
(Facing.LEFT, ((1, 3), (1, 4), (1, 2), (2, 3))),
],
)
def test_candidate_cells_walk_front_then_both_diagonals_then_underfoot(
facing: Facing, expected: tuple[tuple[int, int], ...]
) -> None:
# The cook stands at the centre of cell (2, 3); every facing reports the front
# tile first, its two diagonal neighbours second (left, then right, in the
# rotated row), and the tile underfoot last.
player = cook("p0", pos=Vec2(2.5, 3.5), facing=facing)
assert player.pos == Vec2(2.5, 3.5)
assert candidate_cells(player) == expected
def test_candidate_cells_do_not_depend_on_where_in_the_tile_the_cook_stands() -> None:
positions = [Vec2(2.51, 3.99), Vec2(2.02, 3.01), Vec2(2.99, 3.51)]
for facing in Facing:
baseline = candidate_cells(cook("p0", pos=positions[0], facing=facing))
for position in positions[1:]:
assert candidate_cells(cook("p0", pos=position, facing=facing)) == baseline
def test_candidate_cells_use_the_floor_of_the_position() -> None:
# (3.0, 3.0) is in cell (3, 3), not (2, 2): the body centre decides.
assert candidate_cells(cook("p0", pos=Vec2(3.0, 3.0), facing=Facing.UP)) == (
(3, 2),
(2, 2),
(4, 2),
(3, 3),
)
def test_candidate_cells_are_ordered_and_deduplicated() -> None:
cells = candidate_cells(cook("p0", pos=Vec2(2.5, 3.5), facing=Facing.RIGHT))
assert len(cells) == 4
assert len(set(cells)) == 4
assert cells[3] == (2, 3) # underfoot is always last, whatever the facing
# --- the forgiveness boundary -----------------------------------------------------------
def test_reach_plus_forgive_is_the_number_the_boundary_tests_use() -> None:
assert abs((REACH + FORGIVE) - 1.6) < 1e-12
@pytest.mark.parametrize(
("distance", "in_reach"),
[
(0.99, True), # the task boundary: 0.99 is ok
(REACH, True), # the front tile itself
(1.6, True), # the boundary is inclusive (with REACH_EPS of slack)
(1.61, False), # the task boundary: 1.61 is rejected
],
)
def test_within_reach_forgives_up_to_the_boundary(
distance: float, in_reach: bool
) -> None:
state = make_state(stations=(fixture(),))
# The player sits on the vertical line through the station centre (5.5, 4.5),
# so the distance is exactly `distance` and no corner math gets in.
player = cook("p0", pos=Vec2(5.5, 4.5 - distance), facing=Facing.UP)
assert within_reach(state, player, StationTarget(state.stations[0])) is in_reach
def test_target_center_is_the_cell_centre_for_a_fixture() -> None:
state = make_state(stations=(fixture(kind=StationKind.SINK, x=3, y=2),))
assert target_center(state, StationTarget(state.stations[0])) == Vec2(3.5, 2.5)
def test_target_center_falls_back_to_the_stamped_position_when_the_station_is_gone() -> None:
state = make_state()
stale = ItemTarget(item=tomato(), on_station_id="gone", at=Vec2(5.5, 4.5))
assert target_center(state, stale) == Vec2(5.5, 4.5)
def test_an_item_already_in_the_hand_needs_no_reach() -> None:
state = make_state()
player = cook("p0")
in_hand = ItemTarget(item=tomato()) # no station, no world position
assert target_center(state, in_hand) is None
assert within_reach(state, player, in_hand) is True
assert faces_target(state, player, in_hand) is True
# --- select_target: reach through the cell walk ------------------------------------------
def test_a_station_at_0p99_in_front_is_targeted() -> None:
state = make_state(
stations=(fixture(),),
players=(cook("p0", pos=Vec2(5.5, 4.51), facing=Facing.DOWN),), # 0.99 below the centre
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station.id == "stove-5-4"
def test_a_diagonal_station_at_1p61_is_skipped_not_refused() -> None:
# From the far corner of the cook's own tile the diagonal centre is
# 1.15 * sqrt(2) = 1.618 away, outside REACH + FORGIVE, so the tile is
# skipped rather than becoming the answer.
state = make_state(
stations=(fixture(kind=StationKind.STOVE, x=6, y=6),),
players=(cook("p0", pos=Vec2(5.35, 5.35), facing=Facing.RIGHT),),
)
assert select_target(state, state.players[0]) is None
def test_the_same_diagonal_station_is_reachable_from_the_near_corner() -> None:
state = make_state(
stations=(fixture(kind=StationKind.STOVE, x=6, y=6),),
players=(cook("p0", pos=Vec2(5.65, 5.65), facing=Facing.RIGHT),),
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station.id == "stove-6-6"
def test_an_out_of_reach_diagonal_is_skipped_so_the_tile_underfoot_still_wins() -> None:
state = make_state(
stations=(
fixture(kind=StationKind.STOVE, x=6, y=6), # candidate 3, out of reach here
fixture(kind=StationKind.SINK, x=5, y=5), # candidate 4, underfoot, in reach
),
players=(cook("p0", pos=Vec2(5.35, 5.35), facing=Facing.RIGHT),),
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station.kind is StationKind.SINK
# --- select_target: priority ----------------------------------------------------------------
def test_a_ready_fixture_with_contents_yields_the_item_to_a_free_hand() -> None:
ready = fixture(state=StationState.READY, contents=(tomato(ItemForm.CHOPPED),))
state = make_state(stations=(ready,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, ItemTarget)
assert target.item.form is ItemForm.CHOPPED
assert target.on_station_id == ready.id
assert target.at == Vec2(5.5, 4.5)
def test_a_full_hand_skips_the_item_and_gets_the_fixture_instead() -> None:
ready = fixture(state=StationState.READY, contents=(tomato(ItemForm.CHOPPED),))
state = make_state(
stations=(ready,),
players=(
cook(
"p0",
carrying=Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW),
),
),
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station is ready
def test_a_spawn_shelf_hands_stock_even_while_idle() -> None:
shelf = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(),))
state = make_state(stations=(shelf,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, ItemTarget)
assert target.item.kind is ItemKind.TOMATO
def test_a_spawn_shelf_with_no_stock_is_a_plain_fixture() -> None:
shelf = fixture(kind=StationKind.SPAWN_SHELF)
state = make_state(stations=(shelf,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
def test_a_non_ready_fixture_holds_its_contents() -> None:
working = fixture(
kind=StationKind.CHOP_BOARD,
state=StationState.WORKING,
contents=(tomato(),),
worker_id="p0",
)
state = make_state(stations=(working,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget) # the onion is *to be chopped*, not grabbable
def test_a_ready_fixture_with_empty_contents_is_a_plain_fixture() -> None:
ready = fixture(state=StationState.READY)
state = make_state(stations=(ready,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
def test_a_serve_window_is_a_serve_target_not_a_station_target() -> None:
window = fixture(kind=StationKind.SERVE)
state = make_state(stations=(window,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, ServeTarget)
assert target.station is window
def test_nothing_in_the_zone_is_none() -> None:
state = make_state(players=(cook("p0"),))
assert select_target(state, state.players[0]) is None
def test_a_station_two_tiles_away_is_never_a_candidate_even_beside_a_wall() -> None:
# The wall fills the front tile; the stove is beyond it. The cell walk has
# no "through the wall" mode, so the stove is simply not a candidate and
# the press cannot land on it.
state = make_state(
stations=(fixture(kind=StationKind.STOVE, x=5, y=3),),
players=(cook("p0"),),
tile_overrides={(5, 4): TileKind.WALL},
)
assert select_target(state, state.players[0]) is None
def test_the_front_tile_outranks_a_diagonal_one() -> None:
state = make_state(
stations=(
fixture(kind=StationKind.STOVE, x=6, y=4), # diagonal (candidate 3 for UP)
fixture(kind=StationKind.CHOP_BOARD, x=5, y=4), # front (candidate 1)
),
players=(cook("p0"),),
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station.kind is StationKind.CHOP_BOARD
def test_station_cells_keys_fixtures_by_their_authored_tile() -> None:
state = make_state(
stations=(
fixture(kind=StationKind.STOVE, x=3, y=2),
fixture(kind=StationKind.SINK, x=7, y=1),
)
)
cells = station_cells(state)
assert set(cells) == {(3, 2), (7, 1)}
assert cells[(3, 2)].kind is StationKind.STOVE
# --- facing: the half-plane, not an angle ---------------------------------------------------
def test_faces_target_refuses_a_target_behind_the_cook() -> None:
state = make_state(stations=(fixture(kind=StationKind.SERVE, x=5, y=6),))
player = cook("p0", pos=Vec2(5.5, 5.5), facing=Facing.UP)
assert faces_target(state, player, StationTarget(state.stations[0])) is False
def test_faces_target_accepts_a_target_level_with_the_cook() -> None:
state = make_state(stations=(fixture(kind=StationKind.SERVE, x=4, y=5),))
player = cook("p0", pos=Vec2(5.5, 5.5), facing=Facing.UP)
# `along` is 0 for a target on the cook's own row, so a serve window the
# cook is standing beside is not refused.
assert faces_target(state, player, StationTarget(state.stations[0])) is True
# --- the STUNNED denial ---------------------------------------------------------------------
def test_a_stunned_cook_is_refused_before_the_target_is_even_read() -> None:
state = make_state(
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
players=(cook("p0", stun=0.3),),
)
target = select_target(state, state.players[0])
verb, denial = select_verb(state, state.players[0], target)
assert (verb, denial) == (Verb.NONE, DeniedReason.STUNNED)
def test_the_stunned_state_flag_alone_is_enough() -> None:
state = make_state(
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
players=(cook("p0", state=PlayerState.STUNNED, stun=0.0),),
)
target = select_target(state, state.players[0])
verb, denial = select_verb(state, state.players[0], target)
assert (verb, denial) == (Verb.NONE, DeniedReason.STUNNED)
# --- one press, one event (through the full kernel) -------------------------------------------
def test_a_do_nothing_press_emits_exactly_one_denied_not_one_per_tick() -> None:
state = make_state(players=(cook("p0"),))
frame = PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0)
first_before_hash = state.state_hash()
first, events = step(state, STEP_DT, (frame,))
assert len(events) == 1
assert events[0].kind is EventKind.DENIED
assert dict(events[0].payload) == {
"player_id": "p0",
"reason": "nothing_here",
"verb": "none",
}
assert (events[0].x, events[0].y) == (5.5, 5.5)
assert first.players[0].action_held_ticks == 1
assert first.players[0].action_pressed_tick == 0
# The same frame on the next tick continues the press: no new edge, no new
# event, the hold just gets longer.
second, held_events = step(first, STEP_DT, (frame,))
assert held_events == ()
assert second.players[0].action_held_ticks == 2
assert second.players[0].action_pressed_tick == 0
# One call that runs two substeps: still exactly one press.
batched, batched_events = step(state, MAX_DT, (frame,))
assert sum(event.kind is EventKind.DENIED for event in batched_events) == 1
assert batched.players[0].action_held_ticks == 2
# Releasing the button clears the counters and the next press is a new one.
released, released_events = step(
second, STEP_DT, (PlayerInput(player_id="p0", seq=1, action=False),)
)
assert released_events == ()
assert released.players[0].action_held_ticks == 0
assert released.players[0].action_pressed_tick == 0
# Purity: the state the first press was handed is still exactly what it was.
assert state.state_hash() == first_before_hash
assert state.players[0].action_held_ticks == 0
def test_a_press_at_a_station_out_of_reach_is_one_named_refusal() -> None:
state = make_state(
stations=(fixture(kind=StationKind.STOVE, x=5, y=3),),
players=(cook("p0"),),
tile_overrides={(5, 4): TileKind.WALL},
)
frame = PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0)
first, events = step(state, STEP_DT, (frame,))
assert len(events) == 1
assert events[0].kind is EventKind.DENIED
# The discrete zone has no "through the wall" mode, so the press surfaces as
# nothing_here: the one effect, named, at the cook.
assert dict(events[0].payload)["reason"] == "nothing_here"
assert first.players[0].action_held_ticks == 1
+804
View File
@@ -0,0 +1,804 @@
"""`sim/interact.py` verb ladder — what a press means, or why it means nothing.
PLAN §5 gives the cook one button and a priority order; this file pins it
table-driven as `(carrying, target) -> (verb, denial)`, covering **every**
`Verb` and **every** `DeniedReason` at least once (the coverage tests at
the bottom assert that the table and the contested tests together cannot quietly
lose a row — `CONTESTED_LOST` is produced by the gate, not by `select_verb`).
The order as
implemented:
```text
stunned -> STUNNED
nothing in the zone -> NOTHING_HERE
beyond REACH+FORGIVE -> OUT_OF_REACH
behind the cook -> NOT_FACING
item in reach: full hand -> HANDS_FULL, no holder -> INVALID_STATE,
otherwise PICK_ITEM
fixture in reach: state first (dirty -> STATION_DIRTY, working ->
HOLD_WORK/STATION_BUSY, blocked -> INVALID_STATE,
cooling -> STATION_BUSY, ready -> PICK_FROM_STATION),
then kind: serve / wash / trash / shelf / work surface
```
After the matrix: what `resolve_action` implements in *this* phase (pick,
set-down, named refusals, and the deliberate no-ops phase 04 will spend), and
the contested arbitration `action_gate` runs when two cooks press for one thing.
"""
from __future__ import annotations
from dataclasses import replace
from typing import Any
import pytest
from undercooked.sim.grid import Grid
from undercooked.sim.interact import (
DeniedReason,
ItemTarget,
ServeTarget,
StationTarget,
Target,
Verb,
action_gate,
claim_rank,
claims_entity,
resolve_action,
select_target,
select_verb,
target_key,
)
from undercooked.sim.model import (
EventKind,
Facing,
Item,
ItemForm,
ItemKind,
KitchenState,
Player,
PlayerState,
Station,
StationKind,
StationState,
TileKind,
Vec2,
)
from undercooked.sim.step import STEP_DT, PlayerInput
pytestmark = pytest.mark.unit
WIDTH, HEIGHT = 13, 9
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
# --- fixtures ---------------------------------------------------------------------------
def make_state(
stations: tuple[Station, ...] = (),
players: tuple[Player, ...] = (),
) -> KitchenState:
return KitchenState(
level_id="03_verbs",
seed=2,
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
stations=stations,
players=players,
target_score=40,
time_left=90.0,
)
def cook(
player_id: str = "p0",
*,
pos: Vec2 = DEFAULT_POS,
facing: Facing = Facing.UP,
carrying: Item | None = None,
stun: float = 0.0,
state: PlayerState = PlayerState.IDLE,
seat: int = 0,
held_ticks: int = 0,
pressed_tick: int = 0,
) -> Player:
return Player(
id=player_id,
pos=pos,
facing=facing,
carrying=carrying,
state=state,
stun=stun,
seat=seat,
action_held_ticks=held_ticks,
action_pressed_tick=pressed_tick,
)
def fixture(
kind: StationKind = StationKind.STOVE,
*,
x: int = 5,
y: int = 4,
state: StationState = StationState.IDLE,
contents: tuple[Item, ...] = (),
worker_id: str | None = None,
dirty: bool = False,
cooldown: float = 0.0,
) -> Station:
return Station(
id=f"{kind.value}-{x}-{y}",
kind=kind,
x=x,
y=y,
state=state,
contents=contents,
worker_id=worker_id,
dirty=dirty,
cooldown=cooldown,
)
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
return Item(kind=ItemKind.TOMATO, form=form)
def lettuce() -> Item:
return Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW)
def plated_dish() -> Item:
return Item(kind=ItemKind.PLATE, form=ItemForm.PLATED, dish_id=1)
def dirty_plate() -> Item:
return Item(kind=ItemKind.PLATE, form=ItemForm.DIRTY)
# --- the verb ladder, table-driven --------------------------------------------------------
#
# The cook defaults to cell (5, 5) facing UP; the fixture defaults to the front
# cell (5, 4). Rows override either. `expect` is the (Verb, DeniedReason) pair.
MATRIX: list[dict[str, Any]] = [
# the cook's own condition outranks everything
{
"name": "stun_outranks_a_valid_target",
"player": {"stun": 0.3},
"station": {"kind": StationKind.SPAWN_SHELF},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STUNNED),
},
{
"name": "the_stunned_state_flag_alone_refuses",
"player": {"state": PlayerState.STUNNED},
"station": {"kind": StationKind.SPAWN_SHELF},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STUNNED),
},
# nothing to act on
{
"name": "nothing_at_all_is_a_named_refusal",
"target": "none",
"expect": (Verb.NONE, DeniedReason.NOTHING_HERE),
},
{
"name": "a_target_beyond_reach_plus_forgive_is_out_of_reach",
"player": {"pos": Vec2(8.5, 5.5)},
"station": {"kind": StationKind.STOVE},
"target": "station",
"expect": (Verb.NONE, DeniedReason.OUT_OF_REACH),
},
{
"name": "a_target_behind_the_cook_is_not_facing",
"station": {"y": 6},
"target": "station",
"expect": (Verb.NONE, DeniedReason.NOT_FACING),
},
{
"name": "a_lateral_target_passes_the_facing_test_into_the_ladder",
"station": {"x": 4, "y": 5},
"target": "station",
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
},
# items: one hand per cook, and a holder the state can actually empty
{
"name": "a_free_hand_picks_an_item",
"station": {"state": StationState.READY, "contents": (tomato(),)},
"target": "item",
"expect": (Verb.PICK_ITEM, None),
},
{
"name": "a_full_hand_refuses_the_item",
"player": {"carrying": "lettuce"},
"target": "item",
"expect": (Verb.NONE, DeniedReason.HANDS_FULL),
},
{
"name": "an_item_with_no_holder_is_invalid_state",
"target": "item_loose",
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
},
{
"name": "an_item_whose_fixture_left_the_state_is_invalid_state",
"target": "item_ghost",
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
},
# station state outranks station kind
{
"name": "a_dirty_fixture_refuses_anything",
"station": {"dirty": True},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
},
{
"name": "the_dirty_state_refuses_anything",
"station": {"state": StationState.DIRTY},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
},
{
"name": "a_dirty_blocked_fixture_reports_the_dirt_first",
"station": {"dirty": True, "state": StationState.BLOCKED},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
},
{
"name": "another_cooks_working_fixture_is_busy",
"station": {"state": StationState.WORKING, "worker_id": "p9"},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
},
{
"name": "your_own_working_fixture_is_held",
"station": {"state": StationState.WORKING, "worker_id": "p0"},
"target": "station",
"expect": (Verb.HOLD_WORK, None),
},
{
"name": "an_unattributed_working_fixture_is_held_not_busy",
"station": {"state": StationState.WORKING},
"target": "station",
"expect": (Verb.HOLD_WORK, None),
},
{
"name": "a_blocked_fixture_admits_no_verb",
"station": {"state": StationState.BLOCKED},
"target": "station",
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
},
{
"name": "a_busy_fixture_is_busy",
"station": {"state": StationState.BUSY},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
},
{
"name": "a_cooling_fixture_is_busy",
"station": {"cooldown": 0.5},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
},
{
"name": "a_ready_fixture_hands_its_output_to_a_free_hand",
"station": {"state": StationState.READY},
"target": "station",
"expect": (Verb.PICK_FROM_STATION, None),
},
{
"name": "a_ready_fixture_refuses_a_full_hand",
"station": {"state": StationState.READY},
"player": {"carrying": "lettuce"},
"target": "station",
"expect": (Verb.NONE, DeniedReason.HANDS_FULL),
},
{
"name": "a_ready_fixture_with_stock_yields_the_item",
"station": {"state": StationState.READY, "contents": (tomato(ItemForm.CHOPPED),)},
"target": "item",
"expect": (Verb.PICK_ITEM, None),
},
# kind: the serve window
{
"name": "serving_needs_something_in_the_hand",
"station": {"kind": StationKind.SERVE},
"target": "serve",
"expect": (Verb.NONE, DeniedReason.HANDS_EMPTY),
},
{
"name": "serving_refuses_the_wrong_item",
"station": {"kind": StationKind.SERVE},
"player": {"carrying": "tomato"},
"target": "serve",
"expect": (Verb.NONE, DeniedReason.WRONG_ITEM),
},
{
"name": "a_plated_dish_is_served",
"station": {"kind": StationKind.SERVE},
"player": {"carrying": "plated"},
"target": "serve",
"expect": (Verb.SERVE, None),
},
{
"name": "a_dirty_serve_window_refuses_a_dish",
"station": {"kind": StationKind.SERVE, "dirty": True},
"player": {"carrying": "plated"},
"target": "serve",
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
},
{
"name": "a_blocked_serve_window_refuses_a_dish",
"station": {"kind": StationKind.SERVE, "state": StationState.BLOCKED},
"player": {"carrying": "plated"},
"target": "serve",
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
},
# kind: the sink
{
"name": "washing_needs_something_in_the_hand",
"station": {"kind": StationKind.SINK},
"target": "serve",
"expect": (Verb.NONE, DeniedReason.HANDS_EMPTY),
},
{
"name": "washing_refuses_the_clean",
"station": {"kind": StationKind.SINK},
"player": {"carrying": "tomato"},
"target": "serve",
"expect": (Verb.NONE, DeniedReason.WRONG_ITEM),
},
{
"name": "a_dirty_plate_is_washed",
"station": {"kind": StationKind.SINK},
"player": {"carrying": "dirty_plate"},
"target": "serve",
"expect": (Verb.WASH, None),
},
# kind: the trash
{
"name": "trashing_needs_something_in_the_hand",
"station": {"kind": StationKind.TRASH},
"target": "station",
"expect": (Verb.NONE, DeniedReason.HANDS_EMPTY),
},
{
"name": "anything_carries_to_the_trash",
"station": {"kind": StationKind.TRASH},
"player": {"carrying": "tomato"},
"target": "station",
"expect": (Verb.TRASH, None),
},
# kind: the spawn shelf is stock, and a set-down escape hatch
{
"name": "a_shelf_hands_stock_to_a_free_hand",
"station": {"kind": StationKind.SPAWN_SHELF},
"target": "station",
"expect": (Verb.PICK_FROM_STATION, None),
},
{
"name": "a_shelf_takes_a_set_down_from_a_full_hand",
"station": {"kind": StationKind.SPAWN_SHELF},
"player": {"carrying": "lettuce"},
"target": "station",
"expect": (Verb.RELEASE, None),
},
# kind: the work surfaces act on what is on them
{
"name": "a_loaded_surface_starts_work",
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
"target": "station",
"expect": (Verb.START_WORK, None),
},
{
"name": "a_loaded_surface_refuses_a_full_hand",
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
"player": {"carrying": "lettuce"},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_BUSY),
},
{
"name": "an_empty_surface_and_an_empty_hand_is_invalid_state",
"station": {"kind": StationKind.CHOP_BOARD},
"target": "station",
"expect": (Verb.NONE, DeniedReason.INVALID_STATE),
},
{
"name": "an_empty_surface_takes_a_placed_item",
"station": {"kind": StationKind.CHOP_BOARD},
"player": {"carrying": "lettuce"},
"target": "station",
"expect": (Verb.PLACE_ON_STATION, None),
},
{
"name": "an_empty_prep_counter_plates",
"station": {"kind": StationKind.PREP},
"player": {"carrying": "lettuce"},
"target": "station",
"expect": (Verb.PLATE_DISH, None),
},
]
CARRYING: dict[str, Item] = {
"tomato": tomato(),
"lettuce": lettuce(),
"plated": plated_dish(),
"dirty_plate": dirty_plate(),
}
def build(row: dict[str, Any]) -> tuple[KitchenState, Player, Target | None]:
"""Assemble one matrix row: the kitchen, the cook, and what they point at."""
station = fixture(**row["station"]) if row.get("station") else None
state = make_state(stations=(station,) if station is not None else ())
player_overrides = dict(row.get("player", {}))
if "carrying" in player_overrides:
player_overrides["carrying"] = CARRYING[player_overrides["carrying"]]
player = cook(**player_overrides)
kind = row["target"]
item = CARRYING.get("tomato", tomato())
if kind == "none":
target: Target | None = None
elif kind == "station":
target = StationTarget(station) # type: ignore[arg-type]
elif kind == "serve":
target = ServeTarget(station) # type: ignore[arg-type]
elif kind == "item":
target = ItemTarget(item=item, on_station_id=station.id if station else "nope")
elif kind == "item_loose":
target = ItemTarget(item=item)
elif kind == "item_ghost":
target = ItemTarget(item=item, on_station_id="ghost", at=Vec2(5.5, 4.5))
else:
msg = f"unknown target kind {kind!r} in row {row['name']!r}"
raise ValueError(msg)
return state, player, target
@pytest.mark.parametrize("row", MATRIX, ids=lambda row: row["name"])
def test_verb_ladder(row: dict[str, Any]) -> None:
state, player, target = build(row)
verb, denial = select_verb(state, player, target)
assert (verb, denial) == row["expect"]
def test_the_matrix_covers_every_verb() -> None:
covered = {row["expect"][0] for row in MATRIX}
assert covered == set(Verb)
def test_the_matrix_covers_every_denied_reason() -> None:
# `CONTESTED_LOST` is the one reason the ladder cannot produce: it comes
# from the action gate's arbitration of two cooks, so the matrix covers the
# other ten and the contested tests below pin this one.
covered = {row["expect"][1] for row in MATRIX if row["expect"][1] is not None}
assert covered == set(DeniedReason) - {DeniedReason.CONTESTED_LOST}
def test_every_denied_reason_appears_in_a_test() -> None:
# The set-coverage the phase demands: the matrix's reasons plus the reason a
# real contested press produces together are the whole enum.
from_matrix = {row["expect"][1] for row in MATRIX if row["expect"][1] is not None}
state, frames = _contested_kitchen()
_new_state, events = action_gate(state, frames, STEP_DT)
from_gate = {
DeniedReason(dict(event.payload)["reason"])
for event in events
if event.kind is EventKind.DENIED
}
assert from_matrix | from_gate == set(DeniedReason)
def test_no_row_holds_a_verb_and_a_denial_at_once() -> None:
for row in MATRIX:
verb, denial = row["expect"]
if verb is not Verb.NONE:
assert denial is None, row["name"]
else:
assert denial is not None, row["name"]
# --- resolve_action: what this phase implements ---------------------------------------------
def test_resolve_action_without_a_target_is_one_named_refusal() -> None:
state = make_state(players=(cook("p0"),))
new_state, events = resolve_action(state, state.players[0])
assert new_state is state # a refusal moves nothing
assert len(events) == 1
assert events[0].kind is EventKind.DENIED
assert dict(events[0].payload) == {
"player_id": "p0",
"reason": "nothing_here",
"verb": "none",
}
def test_resolve_action_for_a_stunned_press_names_the_reason() -> None:
state = make_state(
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
players=(cook("p0", stun=0.3),),
)
new_state, events = resolve_action(state, state.players[0])
assert new_state is state
assert len(events) == 1
assert dict(events[0].payload)["reason"] == "stunned"
@pytest.mark.parametrize("held_for", [STEP_DT, 0.4])
def test_held_for_above_zero_is_the_documented_no_op(held_for: float) -> None:
state = make_state(players=(cook("p0"),))
new_state, events = resolve_action(state, state.players[0], held_for=held_for)
assert new_state is state
assert events == ()
def test_pick_takes_the_item_off_the_fixture_and_into_the_hand() -> None:
chopped = tomato(ItemForm.CHOPPED)
ready = fixture(state=StationState.READY, contents=(chopped,))
state = make_state(stations=(ready,), players=(cook("p0"),))
before = state.state_hash()
new_state, events = resolve_action(state, state.players[0])
assert new_state.players[0].carrying is chopped
assert new_state.stations[0].contents == ()
assert len(events) == 1
assert events[0].kind is EventKind.PICKED
assert dict(events[0].payload) == {
"player_id": "p0",
"station": ready.id,
"item": "tomato",
"form": "chopped",
}
# purity: the input state and the cook object it held are untouched
assert state.state_hash() == before
assert state.players[0].carrying is None
assert state.stations[0].contents == (chopped,)
def test_pick_removes_only_the_first_of_duplicates() -> None:
second = Item(kind=ItemKind.TOMATO, form=ItemForm.RAW, quality=0.5)
ready = fixture(state=StationState.READY, contents=(tomato(), second))
state = make_state(stations=(ready,), players=(cook("p0"),))
new_state, _events = resolve_action(state, state.players[0])
assert len(new_state.stations[0].contents) == 1
assert new_state.stations[0].contents[0].quality == 0.5
assert new_state.players[0].carrying is ready.contents[0]
def test_release_sets_the_item_down_on_the_fixture() -> None:
shelf = fixture(kind=StationKind.SPAWN_SHELF)
held = lettuce()
state = make_state(stations=(shelf,), players=(cook("p0", carrying=held),))
before = state.state_hash()
new_state, events = resolve_action(state, state.players[0])
assert new_state.players[0].carrying is None
assert new_state.stations[0].contents == (held,)
assert len(events) == 1
assert events[0].kind is EventKind.DROPPED
assert dict(events[0].payload) == {
"player_id": "p0",
"station": shelf.id,
"item": "lettuce",
"form": "raw",
}
assert state.state_hash() == before
assert state.players[0].carrying is held
assert state.stations[0].contents == ()
def test_a_serve_window_is_not_a_set_down_target() -> None:
# A full hand at the window: `select_target` skips the item tier and the
# SERVE kind is a `ServeTarget`; the serve ladder then demands a plated dish,
# so a raw item is refused — the window is the score path, not a shelf to
# drop on (`RELEASE` exists only at a fixture, and the shelf is that hatch).
window = fixture(kind=StationKind.SERVE)
state = make_state(stations=(window,), players=(cook("p0", carrying=lettuce()),))
target = select_target(state, state.players[0])
assert isinstance(target, ServeTarget)
verb, denial = select_verb(state, state.players[0], target)
assert (verb, denial) == (Verb.NONE, DeniedReason.WRONG_ITEM)
new_state, events = resolve_action(state, state.players[0])
assert new_state is state
assert len(events) == 1
assert dict(events[0].payload)["reason"] == "wrong_item"
# --- the phase 04 verbs: named today, deliberately unspent -----------------------------
#
# `PLACE_ON_STATION` and `PLATE_DISH` left this table with task 04: a set-down now
# actually sets the item down (the hand-off's marked place, `sim/carry.py` — pinned
# by `tests/sim/test_carry.py`). What remains unspent is the work itself.
NO_OP_ROWS: list[dict[str, Any]] = [
{
"name": "start_work",
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
"target": "station",
"expect": Verb.START_WORK,
},
{
"name": "hold_work",
"station": {
"kind": StationKind.CHOP_BOARD,
"state": StationState.WORKING,
"contents": (tomato(),),
"worker_id": "p0",
},
"target": "station",
"expect": Verb.HOLD_WORK,
},
{
"name": "serve",
"station": {"kind": StationKind.SERVE},
"player": {"carrying": "plated"},
"target": "serve",
"expect": Verb.SERVE,
},
{
"name": "wash",
"station": {"kind": StationKind.SINK},
"player": {"carrying": "dirty_plate"},
"target": "station",
"expect": Verb.WASH,
},
{
"name": "trash",
"station": {"kind": StationKind.TRASH},
"player": {"carrying": "tomato"},
"target": "station",
"expect": Verb.TRASH,
},
]
@pytest.mark.parametrize("row", NO_OP_ROWS, ids=lambda row: row["name"])
def test_the_phase_04_verbs_are_named_but_deliberate_no_ops(row: dict[str, Any]) -> None:
# `select_verb` names the verb today (the ladder is pinned by the matrix);
# `resolve_action` spends nothing for the work verbs until phase 04's pipeline
# exists: no state change, and no `DENIED` — a no-op is not a refusal, the
# player did nothing wrong, and the client plays nothing rather than a nudge.
# `TRASH` is the same shape at the press: the press starts the hold, and the
# action gate spends it at the 8-tick threshold (`tests/sim/test_carry.py`).
state, player, target = build(row)
verb, denial = select_verb(state, player, target)
assert (verb, denial) == (row["expect"], None)
before = state.state_hash()
new_state, events = resolve_action(state, player)
assert new_state is state
assert events == ()
assert state.state_hash() == before
# --- contested arbitration (the action gate) --------------------------------------------
def _contested_kitchen() -> tuple[KitchenState, dict[str, PlayerInput]]:
"""Two cooks, one ready item: seat 0 in the front cell, seat 1 on the diagonal.
Both face the station's cell (5, 4) — seat 0 through its front tier, seat 1
through the diagonal tier — and both have a free hand, so both would pick
the same item out of the same fixture were there no arbitration.
"""
ready = fixture(state=StationState.READY, contents=(tomato(),))
state = make_state(
stations=(ready,),
players=(
cook("p0", seat=0), # (5, 5) facing UP: front cell (5, 4)
cook("p1", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1), # (5, 4) is its diagonal
),
)
frames = {
"p0": PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0),
"p1": PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=0),
}
return state, frames
def test_the_earliest_received_frame_wins_a_contested_item() -> None:
state, frames = _contested_kitchen()
frames["p1"] = PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=1)
new_state, events = action_gate(state, frames, STEP_DT)
picked = [event for event in events if event.kind is EventKind.PICKED]
denied = [event for event in events if event.kind is EventKind.DENIED]
assert len(picked) == 1 and len(denied) == 1 # one effect per cook, never per tick
assert dict(picked[0].payload)["player_id"] == "p0"
assert dict(denied[0].payload) == {
"player_id": "p1",
"reason": "contested_lost",
"verb": "none",
}
assert new_state.players[0].carrying is not None
assert new_state.players[1].carrying is None
assert new_state.stations[0].contents == ()
def test_a_full_tie_is_broken_by_the_seat_index() -> None:
state, frames = _contested_kitchen() # both tick 0, seq 0: the seat decides
new_state, events = action_gate(state, frames, STEP_DT)
denied = [event for event in events if event.kind is EventKind.DENIED]
assert len(denied) == 1
assert dict(denied[0].payload)["player_id"] == "p1"
assert dict(denied[0].payload)["reason"] == "contested_lost"
assert new_state.players[0].carrying is not None
assert new_state.players[1].carrying is None
def test_the_lower_seq_wins_a_same_tick_contest() -> None:
state, frames = _contested_kitchen()
frames["p0"] = PlayerInput(player_id="p0", seq=5, action=True, received_at_tick=0)
frames["p1"] = PlayerInput(player_id="p1", seq=2, action=True, received_at_tick=0)
new_state, _events = action_gate(state, frames, STEP_DT)
assert new_state.players[1].carrying is not None # seq 2 < seq 5, same tick
assert new_state.players[0].carrying is None
def test_a_stunned_cook_keeps_their_own_reason_and_is_not_a_contest_loser() -> None:
state, frames = _contested_kitchen()
state = state.with_players(
tuple(
replace(player, stun=0.3) if player.id == "p1" else player
for player in state.players
)
)
_new_state, events = action_gate(state, frames, STEP_DT)
denied = [event for event in events if event.kind is EventKind.DENIED]
# Blaming the other cook for a refusal the stun caused would be a second lie;
# the stunned cook gets the stunned reason and never enters the claim scan.
assert len(denied) == 1
assert dict(denied[0].payload) == {"player_id": "p1", "reason": "stunned", "verb": "none"}
def test_two_cooks_at_a_shelf_with_no_stock_do_not_contend() -> None:
# A shelf pick with nothing in `contents` is stock from the level's legend
# (phase 04 replenishes it), so two simultaneous taps are both legal and
# neither is in the other's way — no `CONTESTED_LOST`, no lie about a fight
# there was no fight in.
shelf = fixture(kind=StationKind.SPAWN_SHELF)
state = make_state(
stations=(shelf,),
players=(
cook("p0", seat=0),
cook("p1", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1),
),
)
frames = {
"p0": PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0),
"p1": PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=0),
}
new_state, events = action_gate(state, frames, STEP_DT)
assert events == () # both are phase 04 no-ops, and neither was refused
assert new_state.players[0].action_held_ticks == 1
assert new_state.players[1].action_held_ticks == 1
# --- the claim helpers ------------------------------------------------------------------
def test_claims_entity_distinguishes_a_stock_pick_from_an_empty_shelf() -> None:
shelf = fixture(kind=StationKind.SPAWN_SHELF)
stocked = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(),))
assert claims_entity(Verb.PICK_FROM_STATION, StationTarget(shelf)) is False
assert claims_entity(Verb.PICK_FROM_STATION, StationTarget(stocked)) is True
assert claims_entity(Verb.PICK_ITEM, ItemTarget(item=tomato(), on_station_id=shelf.id)) is True
assert claims_entity(Verb.START_WORK, StationTarget(stocked)) is True
assert claims_entity(Verb.RELEASE, StationTarget(shelf)) is False
assert claims_entity(Verb.SERVE, ServeTarget(shelf)) is False
def test_target_key_claims_shelf_stock_as_a_pile_and_floor_items_by_position() -> None:
first = ItemTarget(item=tomato(), on_station_id="shelf-1")
second = ItemTarget(item=tomato(), on_station_id="shelf-1")
assert target_key(first) == target_key(second)
assert target_key(first) == ("item", "shelf-1")
floor = ItemTarget(item=tomato(), at=Vec2(5.5, 4.5))
assert target_key(floor) == ("floor", (5.5 * 64, 4.5 * 64)) # Vec2.KEY, 1/64 tile
def test_claim_rank_orders_by_arrival_then_seq_then_seat() -> None:
p0 = cook("p0", seat=0)
p1 = cook("p1", seat=1)
high_seq = PlayerInput(player_id="p0", seq=9, received_at_tick=0)
low_seq = PlayerInput(player_id="p1", seq=1, received_at_tick=0)
later_tick = PlayerInput(player_id="p1", seq=0, received_at_tick=1)
assert claim_rank(p1, low_seq) < claim_rank(p0, high_seq) # seq before seat
assert claim_rank(p0, high_seq) < claim_rank(p1, later_tick) # arrival before seq
assert claim_rank(p0, low_seq) < claim_rank(p1, low_seq) # the seat breaks a full tie
+1
View File
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
"docs/SIMULATION.md",
"docs/TESTING.md",
}