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:
@@ -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.
|
||||
+2156
File diff suppressed because it is too large
Load Diff
+6010
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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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 == ()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user