From 5202bba2cef5dc01d7645d2548cd9c83847a187d Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Sat, 9 Mar 2024 16:11:59 -0500 Subject: [PATCH 01/41] Lockstep synchronization server-client prototype --- main.py | 4 +- robotouille/robotouille_simulator.py | 102 +++++++++++++++++++++------ 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/main.py b/main.py index bae61c4b1..90afd5ae9 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,9 @@ parser = argparse.ArgumentParser() parser.add_argument("--environment_name", help="The name of the environment to create.", default="original") parser.add_argument("--seed", help="The seed to use for the environment.", default=None) +parser.add_argument("--role", help="\"client\" if client, \"server\" if server.", default="client") +parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765") parser.add_argument("--noisy_randomization", action="store_true", help="Whether to use 'noisy randomization' for procedural generation") args = parser.parse_args() -simulator(args.environment_name, args.seed, args.noisy_randomization) +simulator(args.environment_name, args.seed, args.role, args.host, args.noisy_randomization) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 522686c6e..a86fed3d1 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -1,27 +1,85 @@ import pygame from utils.robotouille_input import create_action_from_control from robotouille.robotouille_env import create_robotouille_env +import asyncio +import json +import pickle +import base64 +import websockets +import time -def simulator(environment_name: str, seed: int=42, noisy_randomization: bool=False): - # Your code for robotouille goes here - env, json, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) - obs, info = env.reset() - renderer.render(obs, mode='human') - done = False - interactive = False # Set to True to interact with the environment through terminal REPL (ignores input) - - while not done: - # Construct action from input - pygame_events = pygame.event.get() - # Mouse clicks for movement and pick/place stack/unstack - mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) - # Keyboard events ('e' button) for cut/cook ('space' button) for noop - keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) - action, args = create_action_from_control(env, obs, mousedown_events+keydown_events, renderer) - if not interactive and action is None: - # Retry for keyboard input - continue - obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) - renderer.render(obs, mode='human') - renderer.render(obs, close=True) +def simulator(environment_name: str, seed: int=42, role="client", host="ws://localhost:8765", noisy_randomization: bool=False): + if role == "server": + server_loop(environment_name, seed, noisy_randomization) + else: + client_loop(environment_name, seed, host, noisy_randomization) + +def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False): + print("I am server") + async def simulator(websocket): + print("Hello client", websocket) + env, json_data, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) + obs, info = env.reset() + done = False + interactive = False # Adjust based on client commands later if needed + + while not done: + action_message = await websocket.recv() + encoded_action, encoded_args = json.loads(action_message) + action = pickle.loads(base64.b64decode(encoded_action)) + args = pickle.loads(base64.b64decode(encoded_args)) + #print((action, args)) + #time.sleep(0.5) + + obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) + # Convert obs to a suitable format to send over the network + obs_data = pickle.dumps(obs) + encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') + await websocket.send(json.dumps({"obs": encoded_obs_data, "reward": reward, "done": done, "info": info})) + + print("GG") + + start_server = websockets.serve(simulator, "localhost", 8765) + + asyncio.get_event_loop().run_until_complete(start_server) + asyncio.get_event_loop().run_forever() + +def client_loop(environment_name: str, seed: int=42, host="ws://localhost:8765", noisy_randomization: bool=False): + uri = host + + async def interact_with_server(): + async with websockets.connect(uri) as websocket: + env, _, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) + obs, info = env.reset() + renderer.render(obs, mode='human') + done = False + interactive = False # Set to True to interact with the environment through terminal REPL (ignores input) + + while True: + pygame_events = pygame.event.get() + # Mouse clicks for movement and pick/place stack/unstack + mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) + # Keyboard events ('e' button) for cut/cook ('space' button) for noop + keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) + # Assume the action can be created despite not updating environment + action, args = create_action_from_control(env, obs, mousedown_events + keydown_events, renderer) + + if action is None: + continue + + #print((action, args)) + encoded_action = base64.b64encode(pickle.dumps(action)).decode('utf-8') + encoded_args = base64.b64encode(pickle.dumps(args)).decode('utf-8') + await websocket.send(json.dumps((encoded_action, encoded_args))) + + response = await websocket.recv() + data = json.loads(response) + encoded_obs_data, reward, done, info = data["obs"], data["reward"], data["done"], data["info"] + obs = pickle.loads(base64.b64decode(encoded_obs_data)) + renderer.render(obs, mode='human') + if done: + break + renderer.render(obs, close=True) + + asyncio.get_event_loop().run_until_complete(interact_with_server()) From 300b9344f60e404d0861caf690a691dc8c69503a Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Wed, 13 Mar 2024 15:06:19 -0400 Subject: [PATCH 02/41] rough draft of creation effect --- assets/bowl_soup.png | Bin 0 -> 29652 bytes assets/pot.png | Bin 0 -> 32073 bytes assets/pot_soup.png | Bin 0 -> 44378 bytes assets/pot_water.png | Bin 0 -> 34250 bytes assets/pot_water_boil.png | Bin 0 -> 47551 bytes assets/sink.png | Bin 0 -> 7916 bytes backend/special_effects/creation_effect.py | 132 ++++++++++ backend/state.py | 18 ++ domain/robotouille.json | 267 ++++++++++++++++++--- 9 files changed, 388 insertions(+), 29 deletions(-) create mode 100644 assets/bowl_soup.png create mode 100644 assets/pot.png create mode 100644 assets/pot_soup.png create mode 100644 assets/pot_water.png create mode 100644 assets/pot_water_boil.png create mode 100644 assets/sink.png diff --git a/assets/bowl_soup.png b/assets/bowl_soup.png new file mode 100644 index 0000000000000000000000000000000000000000..cd0d300f59c3a0f0bda2be0ad76822310a4a2d62 GIT binary patch literal 29652 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 literal 0 HcmV?d00001 diff --git a/assets/pot.png b/assets/pot.png new file mode 100644 index 0000000000000000000000000000000000000000..d00bc9199ba1c6aafa4b627f6c42a01c7aa50bd7 GIT binary patch literal 32073 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

=no&(|GoMmY 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 literal 0 HcmV?d00001 diff --git a/assets/pot_soup.png b/assets/pot_soup.png new file mode 100644 index 0000000000000000000000000000000000000000..672a6bb174ff1529ebdee1531115aac9e813ee51 GIT binary patch literal 44378 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�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 literal 0 HcmV?d00001 diff --git a/assets/pot_water.png b/assets/pot_water.png new file mode 100644 index 0000000000000000000000000000000000000000..3aa63b30de5e380f288e38ded29aa4672dc3b628 GIT binary patch literal 34250 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 Msj7>4gSLJ8KRj$6-T(jq literal 0 HcmV?d00001 diff --git a/assets/pot_water_boil.png b/assets/pot_water_boil.png new file mode 100644 index 0000000000000000000000000000000000000000..4f7cb55d250d7073229eb8eb7cdd0ff1d3f2904e GIT binary patch literal 47551 zcmc$F1zT0o7VV~y5&@-CO1fK0kdRcm8|m(DX#@dj0RaI4=`NA-5CT%tARPKgH@wBY z@4mnA_>RCQJJz0S#+YM{9j&G!hl@pq1wjz5g1pRY2to$GB14$y;K#A|?@RFGfwR1> z8w6n!-2X!wUkJR0AR0(PMoPmwdw0Rd-C#O^=^PPYw@0p~qX5y%2e&pw6Den;$wYr< z{*zokyS=tGVcnw2w3bcJG&rM@Xg!`Q-k)(wS2C(3_I%=io;O9om%xihI9i(_nZhZ? z^EJV<=qqHia_Rh2UrndxBRM2|&)#?9gcV7fP}D@@q=08swJx2-eb}-;BOm3((*@{rPp_)!D|o&bm}@1wWH4>E&q%e zq6ybn2>&!a8B zDW`A^b*rq@_Q9lG|Ig9U7?8J?&l1w|ze}PB{MQe#AWT}zmFz1d3f7fhh4mcRw4Ysx zAbjbc9u&bE-(5^ce5@<5UtpHvph4kD$-`_{qH2B5{OmNJNXvWNujzw1xfb=6vAt>q zmNaH5-u+RoAF}?Iq_`sEkAM8{5nn>x{2S6k@n7@b-;E(d_iD+gyVS822F{?mKjZa@ zmN%7y$V+*Y0w_2M_nq5fuke!IhRsSQQ6&Cr;-atX4B>U}GA#W^^Myp0ma~IWxr>0F7L5Kmo8eO_lOR^_4xF%15-g z|M^2?l(u)k^W@`S6p%C-K3IC4(8z@fw7}ZdPK6ZQfp-QB5)H1Pw-l|p$ZBn4od5mm z&Honiq)!-u#P#hbt@wyfsrhj;R_y!Ae`3<|xM08J?L<5Ju5cvvqo)312P8lT*oGTx}9ip*DXv4pv-2dfy03>FaTU!xs?wv9H7oxMw z1$SY(zl*P@w)4m-4*_y2OCJfZeFr1$561ugzov5nO4mQKezrwLZ}zpv9|}vIM^s%$ zs7t+sF-uYJ*Ns2oBdR#M+c*=#zsPy!Gj@ms1xrB>T}G&@yreLn5#Jw^*3B}q&@F82 z;3e5SwBryCnOJPB1!Nz+-cZLM=emEIdJRJ89kg$}9#H?^18#Zx<%HqKN({|mA(7xx7T7q>2Eu&?C3piRsaOgLqGbQDz^Z&0&rzaj!*u3RAn2j%$S7|g7OYK9{>3LM zX#7wJ?uX?6fHl!Uus!4Y?m2(c@dIcSN$sbYYPpmy8G$)lbP+2RGDKDW!UgjgDH>$) z^y6MaN4nd7g`I+Jf8rtQmfp|lR%mDUlBbGtC0o0dB`hg39}2cdeG1_awboB7dd+=h z8NStK)oRE7HsViMu9tbl^py}?bh@5C z`~wJ|8ucL+#W{`X*S}Q?PoNL2E^2Sg;zIv z<^xdly7x2}{o>sAjT)2jUn65d124Hdu>~|5!L9jIVN1h9DNtJ8uu-&@p+j}%Br?Pl z%rss8zsn98(@n=9Nftfc*X*0Qeqb-?z>KYCfYCXoWO73L!7%%%a7U9wdP*7znq}Vb zQfSmz=S%VG80S6<%D#JG&K6}Rt5snOr{~MbF#6DHM{)WV8(u)89QhvuAdvn|RuI+G zYI_@nb9;JZOl$A_1drY7j2+VC|z=Mllx z*9zm3AfVV2-8jrw5G^Tgi;A6BsTLWn%|HD-(V#E}tFAe*qv|ab-oB3yOp3Z}#Ywwz z$%vzpH0#G-BqgP?3BKq^jYLwkap7pt)As22ZpX2K^h^7@ORXE4fxvw2qaRb^sF1U& z@++wUGH@gEZr9Xyw}n^gqNy5FW2uWKaW2r{&)C|oMS=vA5V_yJCFk+-N;xmJrR%Nx@EP`11FvndNtF6s>s(#3>f)5pfbcc>@ zKTp`m@sZ(L@?=PIq`N)FLQ6L#DFYvyOz{1X`gVmn_xvxD#Fk|Bcq)lGkGYccK?Y60 zzFS^OuFnSPnG$Obl1UtQpRjctH?-V#9*x#TgqlyI@LEfAQile=snug=iZw*iq=Ak| z-Qe)FuYiZz__(=~aVf*2k7Pm!&KP5SrstpS7Qx-1m54*z03bsh-$*kx%;|RGUu{() zg|Mp-KBgF>!gzZqjs0U;Zf4BTSNN!@+>Fr$%tKRsL5EC1p$!e=gj@BEjSorse!jsV zF?YLr(%CV8=Qd*4Qi_2qIcd?+edR;jv}B)J!cBA+04yMRhHp)*6j21@sT5Fi8|L|9qy9?1>rriy@}K z7#~%WNcR0>_ELZC6j-VtZr7GOU8-Wmd55L$FAvHZgMjQGB<2eX3(34q=?5ls&?5Xb2H_)_S)+-|Qxc({tWjY!h^yl6P%Ujhc)p&vv}?MjLGb7@oC%64?2? z{VHf^2lN-2}CatPM|Cg26&^)iqB#)9AO1 z6p}^g6_~!$NMd7cE{N#OBvave79MiZ(v<s~JT+n_T4W}|x|>+KyIHz3*A&#xG5T+)-`Bb_S|V%hsvrJ;-^-2s zthttzRX;5++a{E)KCrudY{5fH5{ab1a--@y*Wgf&4jr89&4%u2J3!wE`baEvpIGWT zx{2hJAY}*cH^V>ewCFMuG4d;4&FMEf2CVcvlrRV|liBhn?bk{ke4kVXfS%su&5;B* z&I|9o`sw||Ub9x^iQ~mKpEsCMJ*+u%|AgSoD%p1f$H#ns=H|Ld^bZ$Z+exwn9KMq~ zwINLy1ajuf&>@Xnw2WBm80_nr#Yw3XsjGi*Ozb~m1*Yk&3Azi+9fhv}TNe!u4lV^A z4GXdkztd4E@0h1o37-L_cH9+UN3b6W+QtDVA>LvgVQ%}m5+G)bl79}W&>3;2x+~E zy6KYQ;=r3pZR2*0)%Bl)HF^px*3xA!K}e7$iX9HXvsK`TfUCJBs`)mbqj?nX=AQ}^ zQ=Mb{Z<>4flwr&)Ah_}}2KWDFj~4e@GF!bTtbSXwYWsN4cHx3`w$X{Y(x^QqBl!EE zu-6D9W(^zmbfqDy7Xy?|UrK`+`V8~C{U@3s17nb7rF5Dd4MwSg>xWPUhE|-tDe?MW z1a{u7g4h{_AHA8iKjk#`UWr)x@pIv9osG8$3cfE|pKT~bJ--SPVFk!(P7=lxG@(jK z(%^S(3};P?P4AQL$GVKt1Cffarer%praq_$Px1Nq0!N1uf?SBSCi?tlIfo%)0f5+Fc&am zh0^f-Na`8-A+3 zja|QE&0Sb9ioUE`QaaFsG=?$hDjD#NP}5j!!mvEP10CkdXB)l1_V8 z$5dZmAD57j&XjOqC$-<%es2UeaavoyaR1(1BR3zPh4v%uq8;jr+*&qPHD)3;W;W$A zCQNCiG|N}&L?!eJWo)oo-4D}6*)Y>MDNW79va-8tr@K=p+L(gUvh9JfLb*<29CU3x z!z9CLX#$>yVAEGX<+Na?z`HAjt&1?jTn9S~iyCg%4$f;T7J97?SF$3crC5ouGAnnu zlzI8yz`p9kIw(N@203s3q6LoZHd}uu=s2&_2z~lJn2}}~ad>f^yoM(A@ih@z4O^>* zrY3`e-q}!GOu-OGKM@F&yV1bYTI02BH+7;y@Ps$&K|y!dN)h*91(Z>~@TYPK6#Kio z*XB_@)vuE2tLHI1{rvnCX7?Z(vUrXw5jX|#z2AK7MK+n1x>}gM(cb$RLAYZlZJDB8 z`x@@K%rQbPtEjj8h+D@&zo$>1f^B*=emo^zp8|s4c7hK#vv_#Vql@Gf>wk>$ny8l+ z=Bk_`$}VV=%-zQZ!!mAiUg;DGx)qaAkK3EAh5MnPpa|M(0rV@01eIoinkK&K*?Q*{ z)x)jh(AN%1^#}jmUHgk!EQ!2PYkp-|R`G0tSw!djX}2# zg(mNV)zE2n*!K$k#%}>vo~<#>Uo-OL*eiDi;YYa_(^hc|8k+o+cu{hin!O_mQTb|b zC(lUDzp=3%kBB0!kEn0|Mdg7fo$u@b)_ddBd2`A>hb1Nd0F<7c?XD1%8<1cx?Mfz^ zmn!*MSDZ<5a&qjbv<(!Uwum{6lo(PJFk*QB$r3f1pJ*8-p}Xu%ReA2L< zZc)-T*XT4Y1`SwER|#gj?dR=1`}jWHQW&`U^RhA#3*E|dVz&y&L>yLwnm6h-uye9V zK|%4`t$ALR)2QwIcsNUNS_1d!tn>YPx?N6pXYW=$TU7@=?YTM!Dh*rH+5~uYN0+mA zB2DFsZGJ-GI&8&^DT9&Zt&J1FBtH#X1m%+HvxQucm+o%Qh}*<@c+BL+v@~~$GVIhm z3?GhWg@4r6 zEvDeBkX%t=Vd2+i0vPV|p|6b2U&<~o?j1gF)T-9&b?I+6&d%#YPL^9+OkH#|=m%h^ zX8KQ~dR96;MRN4DVCPmCu$pfkJ6h(9vk8Q~sG{W@I-M@&=0nXbuIpHM)ZzvQ7a%^m zGnC)LhFpRyOoAIrX%&i}PG`ur?A7q(tZ5d8yAY)MW3nGJ+~Ou#B( zp7$@r**C;1E}bs z`1QY@=IiTvBlH6Op0U4Zu14VQUB1nQSmccGkciuB1VW*BMGLC0!^3udc6f zA3jX?XwMRK`lHwJ(ratHs0mi*kkPp?gee#!Tc3+~H=oHbZIS&++kbY2Mv6e44d`c~%XC3zQmi%tMl>H*@kb&5{fUyp6YKyR)_1cptLez^Q}~y~6ls(prRHyU~xeQ?@JXxNZPgL_|gJHn&RhZjDn0 z|4thLta8_#7MqgxX3pl%XPf7T15fkrm&ptI2TtLfkxY$SUePU^wY9ZsdHU~s%62cJ zrEH$*-;M|dDa^e*yuCtbFSPjo>&AG5S4EbnS)q#(JTjs}@!YrW?;388^V>{DOCC}f z7f!sJQA%#=Y#TB_pLaLBqhjXS!fqS-4NKvC&jD3~47!=?`3Y5t3eZE|mG-QB3%C)7 zQ$$F2XM!&>a&lh6=;G?*6KQfraVEBp@g1H&wv#Xsu@dFmcbs|Yr=J|4;=NY%jNOb-PmbXQnBm{ z2(S`|e+QYa1Ml(xuK#LjCy}aXY0Yx|({&Pgb8nA*=RE$;WuC{%cQg9Bnk(P7XhWGL#te`z)}# z*xtG*t2?&ZjzN~e2J=21lY?zX=qU?PM3$8SPh2T2efF+EX#MO8rL61^6($s1R;Fle z4L6oJ_(AxFkdW}K?eHh7*R*(S9UU3+OxH+`=-Je-U%%MXVU~XDqry=D*PWlAYtX${ zQ~!GHpsoVpC-!qcf9^0{ku`O31TW9UR(mx}05Qje%25Xp0q1BiOyQ2a571HN3H#Vy zv(+CzWIn4g>#*kOE<88n(AEdoV*nI|+u|o@H1UbZ!MK~hOW)+(4OxquU;1~T)V~Fg zw7YQZE&Y?&=rl{s`N>X-s2=sH!_N2**Vg>A_MU?Ro$&=~%)c$jO zHC52HOI>5*^xsRs9yp8Sz&f;|yHJ|i^Yof3XqCuOfy>Fs)ixVcdd`}u89^X@KZ@aK4t9K6#|U(MRIz>#OzrVm z{Nlyr3ahFf!bft-SuCAO6?GTBlcc;U{orS2MptQ*xhIM|LQmj&a(p}=0NPw}>$4LD z^L*%21jx^RdBBO4*!wPp;qQ9Q?vnvd&lly+O`e~#38%oF_}LWP#yuzjX!2~2t?v5! z*P=c4+gE_rvqe10zEWP5sjIlM!fKGKb(CU2-0CUq1K#F7*thPTFuPZqt`;(duxApB?<6Ox8A z3+?rPD?OTfd1~?X8%SRzhb(w<<>`40V=cp|60Ijd4l@+;+|}_RZIbUPgg*7&`z`T{ z{9jeGv#u-IPr+s~F)=CkaM@-N1wLN4FeIm@t}<1~TmoG(H$T57!fg3xD(eZycVvu* zR6jycFt$DKTvH5&tw~;Tk;P+G>#$jQIRzImyBZrW4FEpO#z+zyY>_O^{TBHj4#q7Z zmMpE;oiZ8oK7M>x{Q4PjaLY!rp-skbS7u0OZl|i#X!;7kXf<6juicrD7g2bV^*Ek? zU;N@@=MxvtZSYI}#2mA^x%tTli9H-S_{qQJu!p%F@LWFv0DKx8eMW?n2Q7SySpyar zFDi<&se)g=T@YvisQEX)T1()K2v&sj!oGyDwKY=$wS*DEOrx&)7hn={b=Vk{m6c`p zc5y!naxRxS5HonZK$1)mb=w$1czJl-$S2d!W!^-Z{sVMGsk_LY>5eeAEp824y1M~S zhW$XeVEktNv{Ct7y959wEen3)vg#mTE6lMh+0NuV;s(51Vto8MpYp^W4yqW{=<*xY z=c!0)zKs+%LMM0eOpitVPIqHLiddS_lp9SrQB_iMp5Sv(Ua^aMoo>$WYnJhnhnqVD z0Q9P>06;cy2?#XopBau+oA)n!w(c0>K)oM^`IqB*&0N`@)Yc11YHCgcu#+VYw)%!q zIjKwy`t=#)j05$~r9039@+uR)m#0rwEHa3zj=XMXuE+SKp4P-4>-!Z`DCqXOWywt# zBvBGFvNA934bWd_8j9XOLSkac(M6j>Gzz@}!R6J}lJEISRM0}DUGm99(nH^x8jx_i zfQRLHwJt1p)?Ew3LGvAPaBv{~-1>cseTAj1EmouBf?nel13`4;;JEkk z=9n+BX^37)&qx#|_9~eGE3wcQPpdeUHHYBFk~*>f!0BRlhsp*iu=gk4P=H$&ZeX zZZh;iAu-W7g`?kMsiEl+wEZT} zb_kbh02y4r3BcLwKpOmWYtQ(zezR(jaoq*BWjZH6w8+GyL0VR3W(CFMM{#j+zb$*a zT^sCY$MIpe8?An4jtlz^x>|2tUB`#4Y8zxmsS=s4e-Ntj?F%HLf*CCC9sqDl575IJ z*p7H{P(akzK76dZ72k1c%=Qp=OHgfBEu=V5U=m!fmUpQ%m`^DXy-(+b@-0fR-E<}Yk00`&ZLJNFrzfa>ngklEMW$Tu z0`9oNQOBQHNEN(2lTk2T!`60v2*c%Po0y%|MbNQdYYU%fIv3)829lpJyoJx9a3m#xbyx^Q|V`N(n{kAH}#e#|xUGg2=l zMHxIKhz@s1L0a-Z8qQ>De5UQ^XRaL9?7SikY8qYN$0T`r4be2Lva;>Z(IN2>z_|BF z(g{7R8(vm%&97X`$i#FMY05WA(D^#GMnh3^(t}ySt~Kz+cXGN35EC!790NN$7iB;4 zf|PTgI$KHXep=PuzU3n;6~MgTxqj-Pwu8=P+)!}se*;ewuRWQGE^};I@01t|d(F6! zBwzuS#&C zqod>Djlgee*>ut#!1&hn!{5vwEK!Y>(makyP7p8owKfJm5_60gr zK;N@|`hCTnnAN+49Iu32r4d7=1Vb7{Lu~zHrpl)vH`T%Zl5x_un|+L5C+lwnVv|wR z`!Uk{8+WfFjU)xKUUH<|GD{9vCc%o|!-~5d^c1>;t9BG0DNp=+c=zui7x`zo&je|f z^>T${@s_GOsiGeH z+KCd-v9iolNz8Xh9u!Vvl)$iM9Y*q_Qt4B+l38KibkW!d!$@`6BE~^1|66=8T(~F` zg8yyXvklV$@vl@hnvvw)6u{g0{mvJW~; z$o+2xysv4!Z)kgt-gth_97wJ#GKRa6%yt+D(M*Dlka>szXRB9 zUWL!A+@A@!>DS)KGnHuP_EPH+4FX7l^`F;}6HBz$bu8xg<7ZQC00IP_D zzKSsDNHD0DHjgwxLi6Aa1Nv_`^CQalaY8YZ2 zkR?GjQ;x2%kG?=wPb_zvG7;O`u3Ee$gP>*c1Wh%DvHwADx&s@TE|`Vvd;u-7Eq@RV zU^r4>>zTeUs9Qm$?BW}KZ0#N;o&Pvxb|5CEA%UvS<;qCe{jf0FOXu&}r&n2LP@G%oodgB2Fun zH+Q$9I3ULB+B7lSJc%3kz`3Ozd@$?X{?^!7j6e9x zv-Q{yox`{zZrV7gopFv)P*izBIzy=#;9S;`hQBAii2Hja0x}iW`K{L8;u&xwDLJ|R z2t`&WP>T8aQ%&OPhbjX-oBhr>Kr>BDUv3%-rNgk(rxSS-C8A_l-t-eRL>h2((m+l8 zJX1-}0$*9`JxMO*yY7&M$&zMaqqD(eIC7*JbAr=gW{engfd{e1%i~3_c8oU}2@?A= zCUkwwSo=r-r5Vf2flJT-+aNEo zkOsWE2*2gSOIjEfu*QMRQB`AQWAGAoRf*80g)SpUpmTdE&`>#l!3qsY)kfxGlvJm5 z_Cxu1jtmNzQ`@P?V&GMU2b-mvSzL5xv8*B3s`1D~fKZ*Bs?GpQ%{gNr#xvSyxKyVS z+P#d>yp#hprfW;3;M8Ift_;2mk1}7JCC|X^^$1HL!W^CyQgmsibqTY zSp{;~E_BqBX;~bFq_e88mTxfYJSqdm(Lr+A1F>~C}MWx z2g4}OW&?cG`~vHbG??~B8^hGCuIoSV%)MsiADuwI6M$z>e>wiUwY9}OH$eI}ZRMoV zbTmJ4ZIfvv-)RSLK+(lOBxTst!o69+24}@{8|~xhmV5UWixam({y+7|DObIb_&B$O z&EM_{{kD2k?`8X2o{QTrdEis~ZMxv>i$R(dLXV7KW^vWv7Nxe4v28}2drx#;{62CZfI=k;~34Ny<#7n`o`Ef9lx45U=K|b_*h!26F~Xnxk2@E3cP4 z)8l`!1ZTQ58$_`PJ6Ww7<8(z*UpXN*WtSQZ8Q52NaR{fK7JC0RgrHSnpcn~Jvbg@a zwCC67bl9WZ+}-b;e8`#q?XCU-d)Nz-nHJobf>7>HGc;>6Ee<644W4aYE6QJ|kvgRo&XC&g}mid4Z>iGV8LTYsJ~@ z^a93?-J-rb7 zj9JeqHToYB=YQwVcE!CZsH!@5kA&ggcZcx|uYUWA%;}39Ej8rld`yr%SWWDVa=$FZ z(o1Hr-=0I3!Z#riVS`DF=gD;>MbTioEu}0f-`Fj##UBy7_DcCGwb|M1Ub#{=hu@kze-K6#Oaf3)n)zYCvGM9i;7QcbC#cKnq1eB!s&_>y>_+r} zQWu7oBijFjiY-E?3s(a75RM&gL*32;l_iQ+<pc}l}V;d?H|PgOzKN)s~v zmxb*cDER_^k9+{0FX_>>Kj54oOjaoQw1Wl;wSDnw(VX%t&D0`YBx{QrU46#)Jbla9 zb@?SuT_Z8aM_tK>rE}i(lMy-e6aWKa;8C2PrCx_x z*UkcmWDV$QjrQ@E0&z|K!f!uf+BhrChbJ|J1{3o<_MxYydeX2hZ`NF0+W9e`7!@^o z1D-UIrIM;_ceiQg69Y%VLm%8h8H$!tlr|v@n!M4cIf^E(W)#uhF40-cgYt}u`ef6J z?uK$sXNQ9U;~5l;bZ;6KUg682&WVgo2x2NEzgI3X$?W^X)PNr1mg_1+@tEILM%}&> z%$7TATh+*cgjPT#;PN;Kv}tFle!w9CB?r`8vk>4Jou{pE5bebJQD@z2*uVV^#B=4l z_dLJvv3L%g2T;W&$eHl_kjFV1bP8IQ3y0z?6!gnv}K4!*ram74t zw#*p#{00ohxNuN_yAsI+qeME=<(pTjVm|+OZ?mB?T5lSlOSL$3=(I{3VjrC38rX)SSFY=QO_U+~t^lDeD8-|8*8( zQ4}q)(ZiE`g$C8Eb`^*0ZSF1MDO7q}|0;7CIT$Ho6{>S$@)v*T+uB2t7uWFHwc%oQ z0;a76S|Jv7NtUt9Zq@oe#sU(GnYKD@TT0@@^RT^t7zjCmlgQNj?ySM7wgbMXGkc4EM(=n z_hGez39ra^TGZ>sG+apD5(|y;O|{S;;)*Aawv*!L)=&RapJqFVy&R4DHuuW8;-^S6 ztN)`fQPR|e1xgOo%oB_bz8oV_-F))RdNlX0HuEYf3Tx6I+2deaiEknCQUd}Nh8_2IeGr}wDRltS*;eCQ1b zo2HncYXAIS$9}UZf8O=2G7~*%s7ucxX<8(j3|YWPG(YRU%C9`4$kZG2XfI$FAvK=? z(+{UVhPm-&)p=9OGHI5^3~HfAbg!reSmo>=n-^`Y_3M+qcAGj{;uF%$e!P?xlYQ)N~7<<(yw?V1s)96 zuwgzHlaNuT_|_>zP6#%&)E)VgDG6C7J(OgD7NGA{;AQf5u%R`)E)-yT8J>C?-cN5d z$r+)K46G~-dm_QZ`cW=NKmACkbz3lA7V_fsye2Bz#gxPkPa96JS!9O3bswzt`;}or zWIKa3I0{K|&DkYfsTIz4*X zfufvTC}$+b^9}_voF;F0#;(XC;6&P29I~<+$)jA>UPyR@66V~zpyOiNs5VdEzjt8j zcBeJp5{TyIxMVPDmW&*!tURJdqMu80A$$L69@Cqfz_2!7+VYYRtb! z7hn6=N-Y?mc+o%M;Fdsbt3Bw`))cGXGn`9^`luac4uDW>SIU%g4OSA{{)T)?XQ5X+ z+}QypJbheTLZ~HkVK79eHjFMA-6E5bk&gS=Jpu{h&|U+XAls4CbJ30ZvSI(n2@GLk zyXx2;TUP+kev6;GAm9`2X)vW_v-F2Fgm5QO@b~w7mJ@Cmv@_%7@lox`|3r`M8)1e* zV7RUeNQ5XGGFEi{+8&{7H?F>4vy&*Anl1-9jq1Zj8=o)Y;^oA#pzL8nXvo#?X2x%G z?Oi`zlkXP$=al%y!4yl~#}%mfs8Z(}f}K9;+*ZybDfbfjx$L9ZVw;Y`BF=y zh!@`+E)2&qBLGrtM?lEk0$<9oY!+QhrSqZoAH$ATVG#7L-@M8DmR*P3{%R_If2RFv z*;T}p1pW3P-D#>-=2xI`@)bLfiD;fGLa-WVM0>dv>IO8PeMBBtoktRR zP-1*Jp1$~{I2*`~f-U{`?2O5u$R0fFo2i97A)WdN2?2q~v9YllggFU%UUv4^t5KTY zixD|jiBeiD+XHWzds!Q-37C@<^{yh#~CC&80m}kh3`Hq96M}(Kdrd= zRVNmB@aMvpxWsl)1}A;-7Ol>{cu_WEVr;CNL4jq5R7V>6h6-1V`3%s>!*eOYzaBErSxoa}Zh8dsf|KV<1ss(}R(KF3u@OXZO~{p-d&By${;xwC?x zxk}&1-CBDqD!V|00!gMl9&!}w6aKVQ)Of3XcZG9l z({tWqWN~jezYNpMZ9^g`^#pW@F6aMNY#m{w=3c_^pwgYo&Z~u`n+4n~a{nH5XjJSd z&U<&JT22Ulm*y7uIWwaOFbvlhhx(W&6v%2hj=O(?sIR-74fl<)&!|MXr?-KUqih>8 z$B76<@A+b-OO4U>i5Tm=#Q6Gi0c*DRKR)-u;D?;;Q8OABJ+hz0uf-ptN_Dxn6&?Ot z+pl14I=#JU;q=AX+9 zZzc$1S-tP0gWcTy46-KZy)c}FlCnZ5TCGUL_maEr%J~iFo<18sjw#~StlZ@G{JD8A z71V0rdnQ&~!0LoIA;^WP3^R50UrAEC>!P`Av=8gq>MHNNenxQ6m-S&%-a!8@`8Y*dag0O9ijJ3398C0zGyYR zUL_9FiY4dE#C-($ud^CI01d||cIZE}5uZ;_&b)6m?MDj!irgAFZ^3)eWWX_Cc`EHXGPbl-#oea_ zJrTG4@n`m&2W2w?xeIbUX!b`ArIE%*NkAdinTI>jP zKeKHL&4DMqyd_MFw{l)LX65O1R43QzD28*m7|AG8<;Iy0bD z?Z}`&@r-kcfkuGtUvyOB?wXTd*>3}8e0%s2ciI7y{V{ai4dT5g`{*v+ZPCh$Oi7&x zS5_S~p7bRX4@xG4$=i9Lui$l#d-Wu1GY6{F7X>>td1r0vt3hro143#6I*@PIyoWiI?c2(1WFTN=CV2a8w143 z(PgU0hbDgEYz~Sx1bttopIH*z?#Wm{n~Gm^S1NT1aOf z3202e>VhZ$Kj4)cbbBFjk+XD>6BPeMUf?$YQkP)IK|FgheZX|h*B73KZ}3qkw%lt> zxR}9o4>R=J_8AKi7W+qkE%2`Ab)ZCX$zBdov53Kp-#MySN2Zd`myCu}E|B;pVMX=( zB*FgTiRbP(`KMfL69)g>1}w&VE3bI`B*0YQ1bLncA@?Ap*!-PR98z3}KBEw1mth?| zu^zeYOqZkgM|okPl@y4#nk_dT+RHdLVt~k4QKNJNbE@8{K$nu;2pRQoBW6i0BK!TN z(9%+c&jdxd7)=XC7Ch|JvDlEB#$%NdY@qSWQqayugg!t9kPb6Z;@!g~AIi3ugH zDeD*zkRUTq@3=OLTwY#^KwUL#&k3UE8P8Rzi|Qu)D|b>=$X4UuvVng}}@+ zK}HOHsP0_Y{GCc1yA2AaQrDs%UN;x+JY{qHayy`lFj2$2;SP$50(P)dQ~6|6Q>!&f zSj{J!KV?C3HHYdf@Y`FdwzCoaR|zg6hS;HBCBNaGQA&hN2VU_0FjFV*481(`4j`Eq zzzO2wJ8LZ55ixu7XuiplR4?Kvw#By*@H#6Cf?Ta~C=NhXDt{#Na zczJp{B6fGfZ8ELs37W;8Kc`&Ij#Aeh4K^m~%?nrq6-?Fm{5x@AeStK8@CFHyQJ;3l z;bZS#UrGL_1z2i!gBuZ`u4t>vPAAY{7LUTs0|ElR78btv=PmYiynP=Cr``{zE!7aB zA_1O!^-F+wl|;B-f;gtEL&x^ciO?Xo^fwE8Uf1xc$PhPKTv8H8{9u&^qKHP{S)NcfXYGk@4y@N$oA z2z`umiy`QZ3%HzWissgOqoY*1*AC46zc!Joi0Ab$pHdFk2rH@9zl45`&4wBJojt|J z87XS#xFyX0g6Hn#Bu#59$!Mrs71gsY1={2H2ciwCc*nl&%xA(dlAha}tMZOU(9HL* z*q>%qn*3z!F=E?KE znFK7(?i`hp$+FMrjfgr)WZ0yg@8mXQK0D z5M5q1nz=1OLctbv)n-L%IGI8kaxNQ9mM#{&TV{#D`7iL%m}_cGUUt%8_I8yJ7ut*7pH2@B7y`nIB%@{!h%OBoN(%l!O`wTRd}R#$B~Y4 zw&*3nvqiKRcr=;mQ7%B%*dOJWNF~RSF6)_bTr29ifFi4@J^=3~39BSl+?GnAw<)gI zzn+Z{ZB0@xf$!%NW0`~ocigg*w2C4da-!SLMMgf#%XN&FQM#4HY(3kHVte)Kc2U{Q zYCo2S<~LPj8jLQb)V{PDPn=;&5KhdHBAjyuslha+Qs{bls(CPRq&Wh1Y$1J`D{I151lL}Ba^33C} zuVIg5$8aK~ffm`UfRg`~E+;3|>2o2oqO2RH)q{fY1&oA78P~k%YV~Wn3p&r;#VE?w zQ^2&b2Hs8t=~N5SkS0D&AEYDN59X=O3$e+3*I)7|Q8kQPQk_DL67Ne4maZ1f(9ouJ zTu+S<8*idNZK4um#JmzG9{fIMjBgPoPfTzP7p0t8=yvz-bDp-e`Q5UEe8GQ~%d9Bm ze9nwB(WPJ2S+jdlVLQ5b15)Jd&|x$Drke(}ud7*83jy9eH7OP*{uP44k%&=gfIgd% zO;&5TRO^v84M#D@nYDlOJP!#15ai zzuwmF6q#2lN6ln`{?nAbbGya*$<|m_W@T+%vU%WbySTREE|PDvZp~&MM~jb1qrMtm z+#7jD5ebBCoAWO}41PY?uf3J(p=SunA-1S@<7Ez2OyTiHJxIseW zz;<%2{qH|>b9$#Ovb6oxuKkHG^+&nNVq;_bfwmu(cbez{WE)rpx7!*aV6SB3?arr_ z<3xWrw7kGOTag{vXP`vl1b$4M4?#v^>ebj?GPrN>3*rB7Q0GsZjcfP0wTU*Xj(XC| z4Mb|91QU@oPnsV`P4qYHj_{gjeysNr(zKbNkS4|S7{iG^jZRG9j!X`razb;-isIFtwd zdTFBkVt2*|3Ys3eZr9GM)mnwc#2)9=9bHp;k@GFaJCmBSUT6VRJY11!lNC!7Q6)|- z(&5EM$L(a08o%KqQa%X|K8%gUS^xYsMaI<+o3BBZ@lxlJ$%C% z)){q?I+7MeUwy^_(Kf9^p@r19e$xk_to57?vG8?YGh~Dm+?*eaSi1C?Z_yzllU@-uR&8n-2DW)AY1s zdL33|j-Ga1x7<@Z;t1s9U~`ID8O&@`Jp8X+M&-hGrmigKnCWTw@_QzGOfX0@S7jXH z`sve2-`LogA+{`ZCL}mmAI)$kTC!I8bfiK1x=*8Z`HSggCf~->G7B*73KZsFhQZjP zB(}$dT;9q>B*%qk>(>}^bRvOsYGx0fJ*jT~kn9~!KR<#_c1Ftm`=R+XtX~dmuHFkt z(sR-a_*s88d+@)Yi}|AcwkNRop8xhm>7hHXpmHGHs^(s_v_7)0ik62T(PN$bB**M+ zj@Z7thW=i(I>GcjR7bnNuA`2@kx$^Odg|kFgg->gpO0emy10D1vF6Ba$Vc^KcB4{) zhN+sV^V)M%6#s;T%VMVEmi_B=oBzHHE&bb5a4=tHk51>S-Khogf5z%aYo36?Q#JNJ z7+w%fC>@uYq$E?WD$_(MoBK^j25a(=jj@+aZ5?c_=J7Y5<#&qU&EPxOsA!;tsto@r?`YQh zkOzsQL{{ldKk63$c{U4=dg^dfTH_qG@tLo-OTXFe&!6jWS$18;q$2OiayGlVx_%iW zqw`Rb=idf?d0wvDa5P7N_v5Kxo8v=zet8bbsCL|)nt}s`=X<(Xs9Qz_2DVy%#8I8q zPl0ZW*EEW9PeIN!q8XFf;v0L2Ej@X7xP`t|_Oi>!;XS1gftI>Uo>Zc&WFWl~V|b)E zEuk2R>lIHZEIzZFh_?VxWkbF$eX@dye=85c7RrS1K)IH8!5pAJ8^3gkD-xn5Ic4U}9lW z+V%X-6xrYOk_VMm*VC~w`dB|d6#jI1S$4uVZ3hFKL!S40KNj@a`OB@`v?02!o|nvl zUwkZ=6nQu}#DYUYtom%&uHKA$M1nkPpT|i9Lpa~{>(U+h&m%7e>l$nNANmWO)ig9p)1nW30OzKKZ zlXo~V`|=(sKd~~5{9xr|JjvRo99!AY8l7M|^t&xM9tQ*bHa~lY@Y8hoBT1$9W7Mv6 zYw~x+S(+?aioMyothvgumT6dCcR)Y$wqQbO{#~4)^BKpt&#Mlrb)5Asr$W+B%Zmqm z>PhP?O{h>>YePd{z7%3YD#1`7txo@>tM26VJr{{PbS&pVvPlOKHMF;fZGCEltYA6? zuGF_(*H(D#{f|1~UTt0Z5u^P_+wIHD3$_&-D(ShnFs;keTotuI2_Pa_LZ3L@y29)3X^5t@Cf+Bk?6@ehj5FONQv{ zbpJY)x(3bOTwPYb4U7W;vwh_%Uvc3Oabd!DOcJAGdOwp>^Nl>LaA#6pcGAez3=p#A zGQMKW<8$9d7hhsj7C0nWbHonPC7YUN*}Q$TE&GDm5u;liC8dnSaeAP9o_Ga|fHwVa z*QwRMKnCGwF<&^;(5h40e&_eUg|qNMcxQJ6xe{xV5)0YG2aMGXZGb-&nG$H;Jz`3b zk-ON;_jnEy$4evGVnvZD0+YjqdCD(jpd8w{;$X`ALdUMs`#d4>&;W=XiCsz!0NtrjR&!^^j_Kod6wIn2v{$*S= zxmx9apE;YUbT53J_G`a+I)P_d(telS(6IdPRr~* zvto3|k@-$fPfJ5?WH~+lEH2VKp`?6zYGV-f#ndXH=M(5<7paN^RTKDBX$FFd7s)ZC z8Vs$>w)wO@eJb-}kU6}1CjcYhgP`NmaWsX+#l@$zSr^Qb{s=?7slR_c%{fL@N!OH# zE_ZqUb9}7a5UE@g=*b$fcS449S-SE&x<$sFIp`A;h7w6-RqNR>Lh40OF{7d@&$DQE z0|5py>h|sBx%>*wo+%*i`m*s^ns_OU`1zOje=F?7UyPHRgWIDhv%~Z@wM`?2)~e_C z4W1axC^df$%sok%w`M=fVf0=q`!Ro|H$(m3pqny8@Orn#j#!)kPDu9U5%(mZhEsu@Wg@eLH7#jV-%?J}NZDu+u`eKyf3E+<|9x`}F*Tum17Q7&{;6cr5) z;{iqV&2osE8W<`H1_Pwq^4Lpeh^v&$%ar@Ibk})@AA432Zsi2+X?CrjqdCmTj(!2j z)@MkfYC7pC_-)-rM3>DDTILbdiCcSHM*uHZ^Sq%UKEPUIZ-5KY${AUyAfTGJNr`+zHzS{O z=M1JCKX7>qxtFSED^@;5?^}DNTok{QKeX%FND6^O10n&5@1N)xv;zee{Bj9!q3Ec7 z_c+f|uz?7pFhz5~7FILoDxCR)w5Y;Ddrzw7kz(P-vB03RUQ9IU&36%M>gz~q zzCh#WsXt=NCVDkox_=)wLpfmWxiJO@Xtpoexm+sMOT%EwU|0Z6eyBDM#k9%%3sqaf z(8kN5hH924+NdYHZ1gA2p(}i}6jAe>uM=15??*=3Uk4Wavlj0a;c-u2;j=N!ayP)P zP5l>@9Zzv*57ude?n-+?i+O6Wc($TSvicvp$h9@o{(TE6(M2Q2yjhQy_gAjqV_OA@ty0$H(yGL_Y9C8UBC0PwI3grBs-1?3PGL-Sdwej!h z5LjX)G*G%{1<^|fCk_l>18k^l?1;mj!l39pVC;2eojHC)n|EB@qkgvwTk{ADtnE(j z&SFx~bY>hnma0v7#}2=7W;WhN&me-tkKE6$!t$^0_yZ=&1vV@j4LN(h{L<|2`eOQc z&2idcTTXB5Eei|F_ir7!y`(JjckJTawIy`ED;Jg`hodozQ@=*x6po0^os|`ef+68x zSp}b>GrJB4Xs|d;a!1QNoF$v?o#J?U^HqA70n7~#51$rssEU_V01FcRY;N9bPy7+x z*jj_xnK!lmMYworBU&m3<-c#pF`O(;fYjgr|1nnx!N=zZu7~Kkf{1@W2!ZkP z0ST3m4^fOkbD(dK(R*RxvPaNh`=bH+#qDqtM?}n)@pGk8r=1}25Am{3+0BG%q?Jf| z6L6qVz6`7-K5HA>u|}f5{2Fay3CgnhK7b&lWkya$R{pAoN{wWVLuQK&a#0XjRRXad z^!m!6_180@-?YvAbOJ3E)%1?^Mak17=XVl=}|^9bZht=hs{Nb>kKuT^#K zDC7MYYFhk=23`!`Zvb8n(4c!O+#@fegMg%(%KQ7SX3bYE2Py@2r ze+xPigslclWr)&1qf2}rVTp`css3n{avi|ycX=N0LJmZEX=MVhfLJL}>N__|i zj+zSX*qRN9bd)8&jimH|1-pEVx%Bj#o=}`S>F48w@=n|@`TD8+~!@E%# z$ayfXV+VS^4is*ePO=prpN))+l;3NxCQW`cGZUV_+`xz2v-xU3zJPPy0PSD_CQcRb zZn0h;+AC8#C6niGU+=IUGQW}c*d3{rvGG~_Z8*ooFqaIJ2ylVM!|c3vbjq+oYD1r+ zw}atAZ@55&31@I{u;snY_w~mw@6<<7{jfggll_8R+{)w~ZTGXaHP@bZgLIj(@Y*^e zA|g2>!}jys?>yd6IJBe_D_{2>pAhk_eTjbb;@83rG+v$gqtM~13UthrR<()jU+c;6 zB86LXWh0l6p{x2!CE!* za8xxl_Adx@Hk>SqvX@7q&gajdv-7#9KXezSGI<9kiU|?V$a*+;jNY1YS)P>bIGsq7Hs;7<(ZuBTwu5{LKT2ATV zc($@%#|C(1=wf>pP!8DSu*S>;X(8xh_2wttkHopeU~cWrqj9m2%Em$+LE9jXv)YuD z6cLWsp&ZYM#VrX#Y}Y9e)FA1w8bu-HTr!dZzMO=B!O8pl#oh z{=KJtgQBO#yjZ_&M+-&e%joRwWh9{H5AXimnF<#cj#eba$P*`833xyeRSM%C+ zLj@*of3y1dW-Q5v%yr*0Jnzy&Q&0bmaFV1Tzppu0ust8D{^C!#C^tSjgns;&0h355 zq98t7mx(TEa4dr`bFMc*+2L|NDiM{Q;a$hXMCWoihuJpgoJHmFtZVCWdK)Pe{f~va0lK@# z#`m6PW8!D`%N~nqToMOXmIlpk1TJjd4XBBZlWkVHa(e87Wi~6Bj!UbgB zkQYkk^^L;HU#xxFB#%YjR*&mvJQErY+XK0L$GygUpRZVyi{#>QK3m(C)3-GE8S({+ z(x8L8;^e8e_Ah>4T@52VCyVY~q3VCp&f`0hTyihj7Fwum)&z3mjX>x$-Jckc-+DP{ z=k4R-a&{f~Wh1iOluX1-9eZLEacVW%-rhd7p8p}K>qRH7GgJWz5$|1*0+s)zzr412 zVG^9BiI?0&km~VnpO5dA=^dQ*=Y<|7s7*CQi@wZ-VezdoQW;)tI=uZF=vi)QgNrGV zl^PtbS_=WqS1}k1qcNsdxoVm@6jeUm3y;@MH(<8&cFL>ymx~$h{@sDda#%#t!AX)g zrfkGHhwBDeU!J9>f^bsz_5L0Aau~z27)1AQFJn&+fv-KnHlIi7aQ|20hsa;dPSimO zWUo25z3gjc7ON8uxE2nKZdA-CJ9WD2b{m!2P7WMo~ zziqB&DOm?2$?+lv70@~dc@j)pQ#61o;c7dp(~iFXPZ(eHm^&N8qj!-})Lnfe%`59N z!oq{SwgrdO_sf(B0dJg9r2OK+D#?U~MpeB%p-zz;1n*LpjN0c@?To~n4|{Rh?6oEf zc^Rp)Rq(t~%VPxcv2FHwj>X@YDF2hvfqS#jf@yvz?J1J6aA&m<3z_g}u8Y{hHxMQf zQm>}ckU(894&+97vO+w3trlTLx~w1hnSVy^jWPYG@|>JPAAzVTxm=!4swZX@Gs&la zCV?n2kvZ)Dvsrc03iX4ywV+K(l)ZY)mrqxIlBGG-90>E0G-*oK)rr2fnNvJ z(8(D8m!DW*=Qy9ZWa8nkC@c6A5#O4a3@p(v@-~a33c?*7H;7dkj9GbRc@7w;ZTQlJ zE4sSe;ei!u+@g&aVaKud2Z@Z7k?h!0uv--I+*deQQka*p6$l9X^&*nmdaiN(7*brc z)-5pECGJv^*KLq_%rY=3MS4ml4h!5;@4-l(Os;yT()Pl50QB*mW1_@4d{5=ASm}w8 zh4LG2xJ9p|=2b%q!+pVe+OeBsN*OdI2sF)bs$DW8a9OOIEyX8V2i; zU<^neOkMq|S%e6bA@>8(0#__vtAj=t-h!V0ivB$1N7c!K#*&sS^qQLBd`!Z5K_F zJT2v%FwWMHdJ91LwL96q3`<3#O9X_tl==8ZOO|1rU zYOyoFX@2P4x;4|NBIWoBWiTSIh$~|h*d(}Xn+4C6>U9TdbysvoxmFz;^qfM&FnqHq zB<;d@!ALH`c(?EmhuLv)t1zDsa=tc9`R|EIAWUI5Qt(5tVdP`=`67jWD@h2XQWd|? znw%Bdac}GXm6A)2^Sj@^7qoi1)Jp9HXZmHe+jW3%ezGg9+4Y=E*liyPmCkXyk~QcF zi~EQIDb6=qxPQG74GNuu+B!HRXj)#T?6Ue9cU$ zb~uOu2mRv%V$(SPIchtcGty-48c^GN&0anxXHj}V?r^{E5$pJ2hHK8y%Lc>e@OS1? z+>^Y*N-Rk~VXQca}e`9>Q{NkeQx-tHEwjY5X*f zCRnuUiy`nj;I{BimX+dpK6PK zuOEG@=efs58^3Y!5*8NbmTCB8akqXQtNb%Ps;KoM4daSC0A^%K_i!g(4;{DR#zko| zlDw)Vc>V~xrhl4ABJ-CCo~t5QI$W}cfskRYJ!z>5po;y=EBk`G*`XADSdxxtbjAN? z7HLw)SOT-gzbBL+O~WgDV%CB?5Njv@FPa5;jQmCqqKLHVZ(5J+TI z+p88stb;dX5TwICyrrD6k2S4jJTVe;Qi3kF{mjLwRu(G8hma~Vp%CG9Uh7LJ^Ar|2 z1*}-XlM;g_7n@T6X2>_T3})%Ct*Q^A)2OuHvHWDX3ZoSGq>6(4fmqvF5ze-U=Ne;i za0&XqWzc?E{~se`Lim)7jiIRpQM|TG!qvsDczHZ7^SN!8$*-1ty~d%1@U@ZEWHSM3 zFbI~5ktS2Zm4qz04QB=n5+uH4CP?+s{3zg6 zkDJ`6Wvhpb#VB>BC5|4$Be5b?f9v6O6_PUU4it8h7$oO|>c5B_pr}GT^f^_l)wfr7 zIAFHnkSuNf$}e&lNWx4O>A22+8M8=GOIeu;Q{l7UJdhCAfJriZMSIJD#r~QndWZV1 zt5P~3W=Mj)*t+cHm$K9D1txha3=nW}EwtK2n(>?Cj ze`~1L>bLi)`ARYtD`dqGxN*XyzQ>?rL8r?yHhDX9MYA{xJh-)BbW6_`LSGsR0pA)6 zOK$K+-8P33J$mKt3cyZg zqtta7kz{XgRP90?n&WH84pky?8A&Nd_AI~qCUDQd=h~liwQEeG#Wb+c$Yfi_2DK%Z z1wt#u)KI0Ss`nlqg#*8Td%uK1J#iu;eE0wxeG0Iqa{d&qacGeObksT(-h{e%Xqoq` zy)I%@!;teR{ed;dTATWJP5S+p`TjJ9AYDJv{bN2RPp~7>3ZCR%h4*PmtyT(b6a?=oGA$Kaf5w!skzxznA2)b9{R_l@FBevc@}u{ zMDx35h&b(N=$$=Zq{&FHj9rX73K-(V47>CuLJ|MFg^MA*6{ay>wqjv6lrgDaefbe0y9g(7L-?o+ zMQ^#QsOBvL^_g_>wrxKlZPTchG?Kv3ZH#!Y+e;8S{{84^R(eXaaYe@6KuA63_KB&g zR&rYn*lhT@1q7PJp_e$NdAjr@I4NeJ{KO$UvCsln4JiwwmMH?{hrpi}$t?Z%0~0|r zp&!vjg){G+;PKuJfq?I%;?a`w*CcZKp9nQU2#27cwVT9&pq&OfI*^(#t@S6}OXtQX zN)h@~txmm1@2$4xAMU|!>FS%-?<*K-|_-KK^q;oc9lc80oM|v-FI9LV9CI zBseJaHs2l*@|FP%4;HuVrJ-+PprV+knoW-Y(nyb-f`akq&zIR)Vyce+LC zKhV8Xo_Pa-FjfcdjXC;SGT}=Tzgpr`Fo|}<79%;lAWk{}-1-r zBw3pp#>-Cxa%$m4Yy2Oa??!!DNcA#F;KpAVu-6UpGb z+g{3dN(F4+9}C{s6HWj#DF;XQ0KJvZZf^BF|I9?Bn85^&ITud!3Pq~dHo1Ap1VC;D z0!ohW#^=(qy>k4q!dFZBd40!%e9<18?tw5+w}<*K$}lgPz0DK+l|PBDgs1ACUHJK9 zl<@N5bJh>mH<0y>jmp+KR4*jA*Dgb0{__0nUdTLgR5yqFA`K>H2)nl-Dq={jNm08# zvttSB??>}Sth6D5FGHH)k%Vw&5FPVG_tu4&?!~}IaMXn-Kll{>3I- zXQJmRx4s<8&sbToj_Y<_%0z%Fe0Xl;$BqM`ya;j+tU3T$7nAHRO^U0BtKm;ukOXo*(Vd_cC)i$4vMKTkZfs4phrA=R*ymu0?E(3P=-F8}7 zhZr9E7X-Yt)sWU(k`OZX5~W(#ub9!W@a}BQ@o}ssUgWD@(qmY(V-Y%-lDX&wjZ&8I z?&d8I&t|l7RsNQ$UiEz+1S4s+jm{R>SmgT9#mp_ktQjCAK;roB0c9|Be=}rk@?X)$ zIqfIeJ$>c?8fYkT1yC+58Q=W<)zQ)MQY$fPnG{aDS_0am6Gli(Q4U zOoL#tv5(n`!@vmOHQmmqI3F&GGP&kc-Wn=$!{-<5BsfbzoK6#HXl$ID4Kc6#@}wKX zJQY(?iOQXQRw|ZxO1?~W4z()q4Rm9+s*Vs0sPTQ;WS^K+&+a80;rj7|GqjsHS=b#9 zfiz}LK!pU-5xvRffGOMLvh2Uf)f8dwDff~IVurR4?MjVng zG2f9m1uv>m=@?@xs~UB|LW<1Fpj@Q9*w?nkv$a)=CN7Yg~PMaB^TO?ZXO^Zpm_fnk{gE*uB z7W2Hav0C-|KG2@edz-6z#=<(!6J^n&P<>k4psVjT2CZ8+)lS+sGurk@gCuWB_rIjS z+-V;f^SDs}Zz~+^+(z#16o+oJN19Mt(B1-+d!R4~mCA*~;i9in-T>y>+H3nXK+89U z8%xav0uFiGMa#a|zIQ>73F#0v*uby03hws-*;5X#i6WEth?kvR^tHwMpFd~{%ZF}z z>-7IoeCa+GWXww}L-7-Z0D3cc`QTJkfC?`4vh(MFEBHsZ47=?|S@Efj=-_yHmE!qU z2?Q#kIp!3*y+GKJyz;8Z)UOx^UvkAXdgZ#0F3W<+aoXm+JZ0y_Pl+i_gCOlm&8H7y z6`qg);t&q7ZFR&ls;UJ@)u#HIMA9bBNfl{|vcvu1P|#hm*R4G=$W{Mu93l!z(;&~U zs;vzZzY{a5(~VUukNBSW6k7!;pQFlRWaHd$c8>%w!0fPhKs{3{%YNa8)l--%S7zRS zZi^m%`F_UbX531xM(*fx{U422bmg+a$4FWP!3scXfH&le4R0P}xV5?Y+s70X%UGD0 zmh1QOmuFF$T3QWipwk}}{UK7%2N*60{kX;wWP*5u1=$ORqXsNM<)sG|LerpYt08L0 zJM4N7?iyHHi4nyX6e=X$M|$NO1M&(A^b}D>R#yEZ1o|Z z5P?iV^0Q+W#XnDpGF4JBq1ff5Rb(ZNo%;<6bX)|K8E;%Xo+#_d=vO5pM{c|nqm~!D z7{Eh`mCu9ZV0~35cT-b|9Wsr5SD?s5>cV~fC*amCI-s<-cl{L*7{&gw&Q@g-FYEUQ z(o-v^n%{Y%2NyxIASpQbQ+uuyTYQMpOtaY0JtSVNYJa@4lYZ6cS=wQ4Dp1>@{?Ch! ztop_L9#EX<*?ydCH1U?mV%mjQ748{;at-TCcazH-rj z>KYn^KkG9HK;vl3b^bFlUAUihCEd=6u%h`0hdc(^55cn$04?MMG%NzQ^0c|%0dF)k zG{OKsx$UEx+Tl+u>ZxLjtYXVi<)4lF)@)z`A<@%5UKt#yJ9(klKNow&UURv;S?+G{ zB%d$nB1QwP>jFTZwBP@21JJX_*}dM;E3xkhXyT^D`6xh@X+e zZ#Vt;(YKkBN;R=@S zIrv9-{|KmUF5J+HEtJiil&0o&iMI7*)Q7E7H_Z+~CkT-4lAq>XUqy81e8}bYk}ymF z3JCD8*8(YJ_wW34*rL!%8`fveIl8`QaplvXN0AiiY(Z ztep}~?SuC|Ut5DX@)L1q99dhae%si64PK+hA2yDs5E?2j=CSc9*Er!6Cy|`-J{v7i zFg7%7pIC0Aw_O9+mVwd7QV#$|MYYq=KOB)&pm#LDKKrk6l`ew}0BArStpNGP!5^9a z+BtkG?Aaa{FpTN%?_bCI2gJlXlNB@N_Z1ctK!+qT&IT1PD|KLpe9;+AGG-&-GiBZ%E5#_E`#|>lbH?p~)w_NHsLniq?B$osGrM*_LMuVc37lP{+f* zijS%?hU3V2)z|ed{j&Jxwd=s%XMg&9pRTrn00Ki3^E}&Pqos&g>NzF6?QTnB?_pBT zj-S&e+<5f_QQI(sRUF~O@Q9<#5m0bluw8`=rwaQSkq%8)@|u1te1IzQTm9N`4%K0~ z3tReF>A9AsX1y9CiF^v82jY47FkQAIANlJ=205hdZl{gvTSf+J2ZKKN$x{H#73cLw zE$e`Hj*NW$U@ylBSou+9FFl@V-)1Y5tkIJGhL@0z4Cmgbi@peAc=v_jm*XP+5+6fI zkA>rf;tP1R@He!EA#n8b+1sPWD-XgFe`e0Yuj$3bgB2xs52#K6UKtYu3Thd{+nF#Pu+q( zc*I3s#l=Q$Zg65^DWOlzKfwmIr^@7v`poLN@$R{qnc1CKNbnkb2p0mW2PzOYkV)(h zL<4geN5Ci@+Gin^{0X48X=lG}16irQX%a=20%51NdlXgsCd05)rfT0>bydUV;$#mBd)X8B6bJ)kFGU#}9oWfxl*J`h-9XjR-o7=*GQ*Gz zeRC`!FU1OaLvB)K6;cu>3*nk7hF;|`>}E|ef3XbBGw^yFDuoGk$KbXJ%&dokHfI_g9BP0HT)YdgpGr_$yAXzr`)wny0CF~8V=S8OK=n0`aThREj3e(ONonHT!y z@q-J-zKr1@)Oe{@ZyX7Zk`K<#wObtSeHEit52&2%e6ztG0BQy-TiY!+w#u60O#jPe z1>oFs6v5Vdk(3TkdU6sEaa&8!pgVLEM2i~(E`V?7)&RjM0|t!4>g&y|J_3%{{){8- zuB6&{J|&^wc3>6~6QGF`!)?t|)gy>2b=nyoQHvVczmLX`RE#F}B=?IsH~g;5WL9g$ zL)m9nz^7(mb6k*i33&f64czHXw0b5c$ilmgMveV{8p=-c1AhrX5;t*2Aai(f9Tye!O5?C(bO zbz5iY_8zU}IN+qKfsl!jM4XYtvS=}i>ZYloVVX-Xi9;Rt$Z9Qx038*WbL4C}q~=ev z-p|-fOitPX5PW)IP&akE)x&sQKP5b|!zWoq^8aexR=^hfy8ec&Zce>9-}| z;6t)=OMmq8qCEdso6(V{03Wo(0yoeJJFs&pF$khKm-63)9w?n z@5*NghA9?)JTubN)^1GbUt@WB7g@ECnUZojE+5%%;|~09+d(<_PT1GSYE3g^BLNtf zgtsuDf{72SU#@rmn)bEj+c*Q1!)5Y}+=S=ovT*&>pU0}tqP-5Z5ADLz zg-64_%wJY{31WJA5}~=muB-q^9<9rr-(Rj?K+Fs;OBG2BwNZiyAQLV&C?+%FN-=zEhU1 z!WXS|ljln``E!x=DEw)HC?mx)GF>TQFK8|)aDPZp7N$u!GHUqpv<&m#uuPl&)XH!gJ#HXB$*ZBBM0r{O0nx#{TiC|sz9xSnNARy-@3>>%y1kZDlpf`pZFKJc9(*3-V_nhsO>F=b z!w+lg-4!+Ol|$19(!vmXL`N)oM^zxjJ3RG{r9g+Rln06vp&<4n{Ha1X^40q;Wg5QV zZ{m7MG6{`+DVhUiCosm6T0vR(8$(A&hmj;w?~%J-x=Y;vB^=(K!a3MqttfLiWgJtx z_Sd#XWo{o{I^O*uvTFbLz`%`m6LK8;EC{Rsfcd-C0HsGS&71c>4HZ)}G6+65e5#Dr zQUs#auG5`8o3&zcYyd$VqsvZW#1HA@cKTSdd5Ha$fxnNY5|s@d7XC(y2A=)XUKKYm zM}`Kz&Zobb@@b^i-xBzas7WTim1GKE>!?#4u!(c|F<=v{FIF-Bz0v&yaR*ZEcjy9T z%O8^-0wjC;%B`J17$AApl^L6p%8hQec$}}7U0tKF%WR2!UJ4@jE*X^AfnQ63_Y3G% zFGnM*Tn(!_XlVnfgxpEMG{ETHhj(Aud3h&KoMm3jd!5fkM|Ky(AjNUM`qH8c_zg<| zV>47uYjo@ZD;Ve!M4{PGT+B{Ob`mwzY$$P8*1n86c_dRYlg6q{$|y;ShDnMBX_(3? zj*>}Op(94Ti;Knr)T>}`U7p%)I2+(L(=abxjF4?fAU)+7)CO{WclW=eem-9rZ04F= z6-*+Zvb0L7shuW)d1<>FB^|7Z4`&GvJJ^D!lfSZi8qDbPB&ei4i;9eN`}_0&;Emy| zF1BW9nv!+`)@zNRD)bC}meC$nu+W&M|CNc>zl#klNc0LVx<3q!_8F%Cdssj!9IC_t zSH|D~j&*_Fo1W&6d*G(ub6$iOTf9;tO_?M~Q6WuHNx-S-eWYZ99lkp#Qyd5bl1^>K z^fcV^jGiR0tZ~oB=%L~>o;OmSz#YYgX}!hv&Ok?}3Cx6+rQV=q6*BgbKNyk_&hVGp zR_>!iAP;P5aTYS2N@X5RDPz2e4XoN2sixxd_4;6JGs7{>9+#Y9psAUMTJ)yH1FI=maj~KrMloGy=iZ+uIaniGHV$)<|6wWa>_6yhFNNl`FHnvj#MevU z?4U%%a&yT&oFX3qKDx8A^{fm;dJmRCjvoG{jRUPDkk>UeH6nC@o)bMfQ?c~Y-!)m* z8(93JqVWGyF3PVc7_|WVPe}m1J6vuQv?5KZTjs~#$B^2qc#cQ-xpd>XJweUb_<1M@ zLG5rTWSzx}uhuTfOI5Sju7r+~U|oR*ijN%hvN8P}V9!TZSN|)J z8B}K?oo(^>v9$v#eAM2X__(Bb-`A-s{cY9dp2d}~z1^pb0S#4GvFFQ}p6+NF8&H{f)DDf9`N7dLRN*os^=lDOGuZr-ONmf&V$9nGbH}bv-&HFQ85De! zz5Z*u0D{|x&{PUX@~cdCrGZ zA5`_v2W4zrMziG<7w2fQ(8c}ItP9-QB*U&qWc2|i7+>CU5oU-uZ>!E^Xp{^L4J|N{ zMW|a|P8vqe$>%9&dI8QuY1&8kZ>>a-*AIgqiK;Q%R@1xE_9Wzyl!Sc z-)8|w3f~uQ{9ySaAee&%%JPGkm!D%fcnnu;B00`PhQMau0&Yi%+qJwEG3Z`hhCsZZ z@QB>^_J(o+7<%oepTJm&CtcJ19b|0f4PMuZ1K<=!uM6#yVgQg!qCY{q!)$rC-hHQo z?j*U4&_tdhBM{iXZlvBeCUg%v-WZ}s*jkh9+>Cb-_l zte;n>EQA6DDGl5j@qPgx1$fcd?@ zgBa8vQ}w_>>h|BCKYyaN(IRC~&l@3AvbX-0YItvQABe^vlq_dOoEk~pH{G>ecc*8u z{qwlrjA*(;mTZ~<{i;8BXS4$r@Pty^h<}Fc0l%P3>(tocEjm%4Wc>lssWLqgNFUt8 z+01Oc&1$mVJ!rlKSI-0}0r?^KgtT4itKp2{M<*wrkfkVQ-M-*m5t>1fZV|nuv*Zrf zCXvL$!y7||ws0+Nqly_Ap7`wez>S-t5B&`b$!OgVq!-dB@8>q#JTPuOCR{JNjs7@# zw?1w$U*}zZu$&pCX_Lo>y!$$GzQ6D>Ub$%t^M#~R#&nF@?YCh81@pAatI0Oj3;+vw zO}qMi?%bs~zA^<;?|#a}P;|Q|*A!)I_QRMG)boMAOD$_qOFbXh*re_i?(d`Sp0rD3 zYTf^6E#$r4zq14Asiat>3g)H2N7rd?v-WaEPa{i57Z|e?C2zeUZO_Zg(`nHC z{jWTeqsQ9^+z*Wz?s9z-pScv)gPtdLHxeMm$FLs5g8-Bl^8@$WMH>ycPJY~UZT7xx zv}#J!7h90r$!QO#$%+Rd=@`1$50ymEzah5oz;XaXFse*I?eb$y`?|Z!t_Qe%jE^~w z%AZ?ny`1-p&7oULc7{4bh5fuZ&DbP`CB>h?dduxTMKylN#ta|)P7 z-Ybn(0n;HDH7VE+9IkHAjcZ22J!b!TfZoC-u(0{?!`}xaV)*12Ajmbgu^CLT!pFno z8aOR`7%Utu*Uf#xR}0$&uS6{3hx$o0~Pa(H6Q&K;rLRIOI?q-2rD^WTO zvgXLoEh18(B!e_`njC^(Yk;W!zHm+K1m$kdI`%roLFRGY zjh?o4$H<6sNs>k5#SYMVc@bM>NzYH};Cy?3)L8rG2icgk*L6e>+BPR*JA!#D!jm^Z z)0tMoAg=v>&jqN^9;~(UD}JP6h`umAK0bCuBwqtJTwshm4g3(cf>%&Z9^cK`1c+P& zh%5rR|2%!5;D_@T!J!h6CVMiHINqd#gT0gTuBQiceSQ5~1{DMr(G5O$&xPei@q*s6 z)$oQaSQ=&%fzB-84Upgf>cB3O-pr2D*{K%xX;IW^jtuX($4d#x`@5Z;M;CBnJWPkB zjv#PNbPp{|FjLqU2y0sRj^a2Q0(BxYgkF8jVP~dIKkvOXQLWxBx zh-^Wl*&kG!`BJS1$imd0SBlaYHeR3+HpcHE<=s#H^j@E0cz%nAOmN|Y`Ht_7-{z0F zVNhUZr`I&x5}|r)XCf`g-eZa6E#^>clc*pUKbnoy52jVU_3ZVZi$?3 z*?+^r_VOidg3Oi!PaT<0&y)?=DsCS@cnfDiUY@w3xDR>_S17H>%IaX1CLS>As0$BB zZh-Lc@;aRV(|g{D0*Q={)|L;OQLu$Hjp<--ySB>DVF{P17-X668||+EF!AF5cBRR( z9tarHVqs+kQ)>knUr8WrAmKl*k)PjH>CPu9M+d69x;jG70}RP#e>vro)Jmj&L@!^QIi8k!oxS%@ z-(h%Hp-rick&9WNIkU+1lq(#2dM9yfUC2lkTTz-iv%hVo5j3k#mLu5i8q%KceLOo? z8UFyZ^80wH@$CTg9h06_d;eTatR%KC;mp205HpEE)(oJZp<-NX^WZ+WKk)nWq zUt~iM;^%uT+SPrRWo=-KcYB_m&{$`6hgHl00YCg-X9_F&nel|*(4F+qKl90&4!A^~ z@>a6qv%tlof(*-&P201s&8nR`n>+WZ9)i7bJMTjKf@ydnY>bL5*NpezUUq-g@8O1T%edk@Mo9ox*Q zjSrqE!?gN7x2N}!up>KexhL4@EJ=iwU}_Z{g2lP#n5Zatgfmt}gK&v#Mf*=eKZZ+? zr%vB|W6p;MZ`}7o<>}ukBUw<8G1N$4O4OfOqsX{8QKkcmMB10fBKOD97i6Br7pPeQ zz)Xw~4p>vXSpQ{K}(sv7kBV<$PR3x&M zGSeYsrtDSrCYy{<_9iPMGa{ReV;16Y>=`PuIrhQ9`8>Sd-@oB|Zs+;i^RchT^|&6_ z^Sa+JfQL2WkxMTGi5NJOmD4^X*G43g`Im3jUAkV&C7G=JLo)@S2qgr&Kr|)zOIB~M z1K{S)vsqx#JmYiyAx;$pyKI$2{h4%E@x~RRP zX}$xwAv6)o0GXxOE--U+!gwZ;ybp6L``tc+HexrWVq|8TZMqw?Hd`7f(oBVV#Wx#TT~7 zZU0|q6Tp@|(AwT{iP(C*?h(>z^l*$yvwYg-pZE*DoEo#K;HvH_Tri$?$ZieoSVQo^593;(h~Nufr|I4MOB( zxZ1iIPX=03vJ=(FOul5$RzHYb_?E=Buv4k#G*xp5zBs(9e%CXQ`<0ZSuIlr_?srQj zKkkXu(-p_|y*Fi)4u0JY$jqa)rpx2_`Z81zGyl#EXgt*NH_9Jg49 z7zdllwC%mwthejNEQ0E2Ld5QOEtp-qs8CLEV8vaz_}@Fdk(w9^;Lf4{F-J(lLAm$UMl29ae0EiaR$?*M?NUK=$v>H@j;bNWfh9H8pFd-M^uJ^VbB3Jl}CQtkXUr$f5A^ zB>7bY7#CcPBvuKwcevse%}q8>u~)(o`ubke@-pOs+NJCs%j}8$%1(&zEs+cDS65f3 z9c}D+(Gc;ZK%x<^*Ny}k5dlCRgZ|Cz{I%Nud-j`1B0Db`y|HBZj^>$%Bn?M5=wc_I zfB@UQ=8C3NI=P3o7iDk$Mxt|{So%5`ZpzZjqZwwV1m>GEALJ*a?eCxWSQ{n{&+(y# z7C>1F!C>z8pN@0XL~idVxK6D_r$weNIaY9(T|FWGayJm$QUm}+$6`=ppa6I@2fifE zHYZ{?LW87nm*mJmF01;`Ab5c4rSJZAyN|hfxox$x)r9M* zKzO15>?Q2_vLlnn$jL|PK1ULW0Q7|MqBlU5-5vhOuiOGv#Og?scDwjlolmS5<#ekJ zhU0papq!~4Ni>_~ljEAu^N;S0lxC$iy^&fJfgEJk9OZBP%gN#$D@=MZKEzD~Z8oU* zS>N%Ehv|hZ9~7}R)ED(D5VY}R_Oe5V=%9ZTfm;j7m){sSFzFZ2F+$wq3e^qo7rv<$ zE$q0&fVqUO=iBr?<)uCIn4iHM5H?qB`&e9^?V#9-J=u z=%sG(ahVTzM*kf#Zr)iuU@0kaWJb>QBFyWB;^=q- zb+|UpZOmn~TAwXy(lKx)33j>Ipz!?KojqKuu!xb2r8x$kOVaWP{KCug|* z2x!IP`qI9{MiOz`O|l9bC;IHeztP;~=x-jI(}m$mr3HKS5|?x4nfN$7SD(W&&QzL+ z0~1MP?|ohHv2o+D1M49nD5p5Wo%;f2YNhF!PQH?CWWc+>8-wmHVlP7HjmaZl?6v-M zkGPu&AOT33NGz~-3)K-{_#ugQzcWLMHs0)ZCOGb~hDV|YMWFWbO}a>{k-6K_n?C~9 z8bdVKBH1Y9nv#(+FLB?(b-wEg{1qhxNNt$)4+7JBOlipqSR0OWzPVdb&rC#_Ivi&1 zLZ_Ezq~1^js`J*syBK~*62=ORCqNa>0c;;G-UVXJ{<@m!*X?MVgSYM{&KOdPz}9Ua zQhdF$8ZSCG*~(<3<^xgdl+#nP9b(y(l{3BOC)YznRsCGdglnol8%z;zSjdn;9NA~G zyhc#jH)#4QLANu0HR-(5vjH1> z2v_YDD+g?XlMZIrf1Z_%6%s#*wq5IIJDK2fIXVHPMFefVuJg9%GN$~T;xT)m8{6O! zRQ7ur+^;^PXZ}|9||GcYKfB)+28zJl6H16=pL7uJWeW z*LbNP{+6T}tS6{ZYZV9t&14QMbEy@b;sbXK!LXPAs~_{f`dywvG;(~Z6&H)|NbGlb z2LS(&hio-hf^YPxQpAt1gg}5-VqIZrf&6@9_LivXjG)hKztT`|unNy+ldP;jgTSwJ zdtcgevOwd!Ocp3gXo4ty$lEK+vu5fiq<{AU=28PQas)mvpy}_ zi8$Ix;H;68AcQjOE;)t7x8)_JXTaY~D_{oZq+&pHJeaYqh|+PwmHV*Y1(#a0J=@Pt zrwl%2GC;HR7F@3CBMB?gn?;^h81{>VpKJ=$?=vLifd8CyI>Mx4sKHl{2&f8UBIGOB zir7bKPfK)z!D_(4vK`6w?@W-<_w0ryQsTk=pU%FIDsoRO^gM1#`aHPY#=E}yGGM0ougx6RHnGbJ{003oAr72r>i7S2@hZXZ zL(I#nV(V$TA4O;TODLqeVDDRpCmOmii=B{=>!pI}Zyz%fo-XhgR#www?dYw1S4O6x|9!l zc2Yt6#+;^rfa@LDo(ww=!v&y?A%1*7Q;GlI}MB(X~t z+~CNrXQdzcQOIJqhy6?eToi>Wnc&~C@lXe}z}>O16*!nkcvNN0&vl2UI>#-$aqLVu zG-w9%xg?-^dbUE1jaXStK1%jD#?QE8(oiqwGy?oooPHhP=(Coo!2I(-Za?PjVWXn` z@nkEtU^f0@JJd#bBVTgwbYL&<$3WIkNM*ZpXSSpg6=csmJAI?(zO7?sI3_iFhKB|Fp;XYD#U;J~s`1a8LNkr4<}UH* zFy{yPP8>u0yB>&M3|7(esmNv`CCy4*DCorr=H!-k@$*6wNM0w#CO+V5wo}*jxZUtlfRQ*-rFEo(&sAGK*|686$ zo%9XLdu6*BRtCT2SxE*(NdpztAN1z_IpdeZWPeVjgCeuM9&Vlpix5EFj0*!M&wy9H zw?B!jVOu!g0+!$nRbM9LBX?j4d|_}Y=N4g(ktQj`AceVE;4g7^KlJgPGtcwa87KS6 znh&^=JM&4!V$9aCIFT39sAj=VNT2l_A&hzTf|D>#m39FyUxZK07W!FJt&wx}1ao?; z8%1YuTy>{gJf`Sg$X+RlZW0uzs6$wx%nPeAmk>3=D=kq$2=iBvMi)lLf`t=waew~! zsTzW7{K%r<$&+emAt=VU{JyJT0=I?UJ=Hx(n+F=)#&Hd&5VIDAQ3V#U)5|&||avCmp1s-}U7>WNJwS#az7q zF-&DcKV^#MrZ0g?2g=m~Rx)Or^(y(5+vULb#;YYL!a+fz3KUG1`MnutS$G(aSUpGy zQ<0(|jwr%!nfTL9bbOel`&ehjOgjXk-B47Ea7x0Pt{gSyyxO;*6}MmN1QYDg;?G=F zR{X^4fN-)H0uL4JY%A;ZSBf~r_4pqylCJ;SQ52OTIfdTP-bTx}usE1v%JP&;+BtXc z=vx;bMsiMQ@VEIecdwd~RWb9@YVF-&!9Wt5v{Z|nms!DurRba9UHEPbpeqZAAsejo z)ouXXB~!^S8aFU{G9s;K`M3>Locy?c#;8{cCQ1xhL=QFQf5Nub)h#uW74L4~WwsvN z-NR-vE*`f8CkXs&L#@1Lew(WR z-`89|Jn&#@cJ{|rs2d1A7o7BKpWvM-dh2u#-$NUUX+mIqe|}c{*@pgu6f`JIl%tom zkN?IXVZ-ZZ|MUT=Y?$Jc8e|73*@eG-z7Q*efAHx<;8&-Ig+%_5(uv!I4Vn`Jg( zZO!PX^zvref2snOd6uDXu@DRvXa|4Y?angLcf}1=Emm>J7g$L0=y61|;#UzUQVT-x|{~jb-@Lo(bNq%s1=rK?8u=?{E*f7m{gLslc4JtAZGMF*&z%aD7x%t z6Ui3(PO6Bdbf6SIT`REh`zF+Ntm{o{Xa&{G`6BlE&+m8rs+O>NjgJVchB(XSlxGO8 z^}dBKzd;m%=06j7TAut3}#3U9O^=2PXF<#iTa(M2We{pp6k$2sLu_L{n6VaM^a3 zL$i=FutY>dJ9&Wn<&UtQ?`dvd^SmqvL|L5234=t@goDrdC!#HA4d+Hs&7!ctu)(3B zqZqdjZAe9%YfQ9hJ|yHxkCziGUZr~fQRd7Qy=a^&Pa-!59>Zz7@P=IIE&fmF;N$mT%yYPVLsZ0PHiH>}!G zVStu$8JR8cm#dzH2~M;l_7iVn`7md;jTF zq1d^;u)@e1AMGcA%=KPlxvg;-ss5e(;#5drrrLwTs<3m7vs;D+M0UA0X@5aMem3qh zBwi1pDJljHzAn#q(+?@M54imA5tMT=ZbFEP1>~RhSyvLybyu;iZ}Xb^TggemfrCf9pRnP&*WtqMN>POh!gj>`a5raX!P-|N2~(+v zr@^${l@T>xsrbtqmhJ{t8_t7+&^^In-20)0;sY}42aZd(a`N5>gJIT4jv}fL1pkT` zo}HbAC%^5>?5!HS0@180Z7%)06E`F|A9DNbq{~I6yL+S_J>eFcZw|TT^FXjb5|Gz` z3KBg?iWU-i@mG_6sJ*@2Z=UY`A5;_R2h~0rDE@1&6txbvs?hGiK+Vr;O?p+T{BXC@ zGj;PxdKL9|b$|DlzH5V2VOmz-ZF?b_S-&=`VvSD0{IfGZeG?N7$Gac%g};oIOlRp; zIh4MKRDA1h^6Icas#70bqJ-Rhe3XOR1J@275Ny!rAHN@{DNl-ExmEu=+TEk1a*qi`Bht#m6sOtl-K7exQ}$=qB@j2Bv-@K6 z-}Y%9T!te*~{Tys)t7_o0S(h-EHv z>!%Da5iIZuO0v>plo^`*<)UIhd{6l)Nuc!(A*91XsXF2OQ4$+49^BZQ^r@w{?gFHN zO{|myCeP+)A&9eT!WmuFt}!K=$zf63tnDv0*rR{$S5d=K@qxOP3`&Y~UliHIiD;Nzl`3DXS zO2P!uwWHHGv>f7V6F=dvf+EY{@TX=qhf#EOcXHcJb9Rxo~2l8M64^P&g*;JNn< z6q3#4yv4WUuBkbU8e+5zIh(Zo^an+1=cZ6j}A{PewZmgKsU zm(#Y~x3gP{%-oUEn~4i`Lu219JIgBHj?hCY5?8c&uM+GVd0U;^y^+4cveobOLGyAC z2`rGk6}()g{q*39;EW#RK*u3=aI7iBbdbPXN z@RM0bgT1j7nL&YwacwY z@LTS?O>LgHe(CL|O}1#4xp&XUsUs#QNDn zLIe@ZYVlAY;MN?Qh!(m((z3O(qIeJ@o3U!6$~JFps}aV?P4WA$@z^~e0Dh(3vv^a% z8~_>{E&l@OiUCdv-+Cp2NJJLOQK6UTP{Og&{!Du9`or>Ffz1M&DhmWZOO&JI|C(KB zmt2o^bZv6T02&F9v5uD0(dru*;C@_`9^OlWXlaq#9TXDqs>DNgy&FzC_G-Gr3B-fr zUksC8LQEE;31zhLzmNgZbkd1xiXS%A%Cy-4KvG1;K(5kM0qFKC@f-uY?wcM44y=<) zhLCDeXd~+=-b0uwNCe5wr{rNZ=Se6%;aM`r=is>)5djz zNcFWnVx~(=i|0IVE7SXzxYHiE_h*L<{`vDMqH9NTCb{)}uf|if@P*o*@4NZcjQkh6{{>@`5+ ze;Iogu=KfF4AWrzxGzozPWkwuxOfjeN<@UkOghFa;NUTdYy+4w7I<$>uy*Tw5eNqe zdMYZB;R-X1jkL8lmq$1-w*9Dm?H9dki4?u-cdHS1x`n}5`KVi&>>Y1zZVE6O3-(aK z+6X46rj9{=N*?U^rpJpdT2oh^?7F#l5$zsEwI&mhi;@a}=W>!6 zHvtc}I=vxZ1re<66|>+THd3+hS9KRBCr0~oUTT^TGOGl;48r)j6!w=Z=I%FLQ(A&RVi0Y1mK%0zK3%V@lZ|rldV$77nMQ7pf*Uz#!;n$ix9&wRDjEdD$r*FV+ z&pcSKwIHxZ+iziU1x2Z~r~KWkSez{@O$zTlL&Fm@3t|VI(YV@efJpLgtDnb=oS(Nur2VKH`DhWF z29&6%ISldr*cp-bC@@+N_Z8iwcz7uD;6a+r99dAy;MUgG=IsdS z9#on^-t7qMfPi`Lg}9_2mU%|wA+ofZ91xA>Qs#8T@TNy*QB@x~xkKqO+E?GyxQiLc zORD!k*Yvc0Imp(T7`87%FUWY0KwqQYY5sfw(65;6N@7B*{MDc79k-}IqM_Goe%{3l zES?lAC{i-i}mOpn}bHPD$jT3PAw&iRgdH&2c5)WUmjU1EbF*ng79R9gxL zajvu;FJ;VQ0sqp1 z%?=K_z^V-R*6C4^5OOyW9{NZvui_CyFfenY>j$sTVvM$IT!0-C8X77xKhlEnJj7z> z&q2r}>Yg4n`UtA{ebGj@lkd#TEA-yE&p76#C3$BYN$}f|vfu^rx^&{Og;Ys2?;sqC0- zZH=7-Gv()z)ui3(Dw=q?D(L4~+2ex{c~rxcZS7oeSAaJsqO2}d>Ah8o3Si?zQbzn0t@8j3G@aHTFZEn#BA zqp1O`9#)BlcFpafD;iA`FJp5r1M@&qa#W~=P#aVZ2bKFQ{R!nbzb7K|Q>1PYu(MB^ z@$K;)R3~;EE!b%9i;=8m(C?wh;+`p_bK+}2$dSwG|2rs}eY zlehil_eaCFS^(>p;U4&~HwyI2f%S57saE-z?K@;k;Wch_wb1>WY1>ve=$>dTYiPxLcl7fw1?T}qIz2gAk$#DWc6xr^By3)%R=>cu_TdQjro=vwHQv_x+Y!oB z``i)mhtkqnzVp-#>m2to`q6s)tha+8({QPil{{+c{lUkomi}&87%NLqU5*dZvB}!Z zJ4>gxBqaa7_(MrfxVAdqXo>chp@OF=rKd5`LT+AOpI^BamrA0aW-_P!qIk)ykL2i` zye`DD4(?H|RxF5ow(gx!?BQWnm7$~t39OAA*v=<#TB!#Ztj?Dgjc%*Yc&dK_qkK;_ z;)7AavrnY#UYrvLQMqB4si~Pf{E0;7>Bv~k-b+X^iv6jcSZ820y-Y%ATdUttB?R9s z0`Rxv|8j5Ww2$Gr-|7!N$qo9`BAAF0!sSjR#WFmY&C#pZ#9zz9TMYXogoQ+~o3JQdWywugLFpv7QuwZu4z}Il0rPliy{jKA+wTw>v zyeMMeVfV)4=3Dj3wDHumM(J3wJIUk@_V$C|I`AW5wr{VwgR8V1nVJSJ5f>O-%B=*Y z5TJ9fV;?)0^^D?IS9xmn%M9N}hV6&1&;f}`i)9V{7YLailsY*&-jhQ6N}?l2ZN(bB zc3!faxc?=~I?z@L8^Q#N`BnPzM<-0EVst)IE)VJM2& zN7vWas>anF6DA|a^R%P!_3D=1o1lB*{vJFclVp05xoAR)#Md&km%Do zqxpiP)}qp~2$NS!_5b|JY-^R71dRF2b#tB- zYtRrevqB6BKNB?R_0l#{N?FApJgBVX+YyafXl-h0!oB-N99$3#L0coB3U!3cz4f

jxd$6adcJf+=#uP?%vm>U?pnD&N=IJ zo_aIg71_Qk2SHj6AS#v6@;n7)Z?T*WfDME1D458N=0Z`>q@a&9;%fJ}=Oq=^bB`tm zyOlPYApxK%PM@C-x8IS4AidK2tkL*(lzKveZ()>dAXyqLwck3}WRyg)iy7$1@t6#0 z2{jxs{KNo2ugjMOu9uP%U66fOv@wOP_|+L#gRKnOQ+@#5esBch&P{`_bLRSvoXBSr zvB(cq{Hae?rHR*~JuU`jmq}SOXDK}qBv=iS2l`L$HRC%_PW1=rW46g45K$H&Y3q!I z$5u38B4*xy64<0R1y=tsxgVid&>L9X=&GANM+RS4>b&i}jO?2#yTK1WbpypGVKXBaMH&uGAMdZPF+K#$JwI0O2H=MVpN^ZUS0|PpNMF$(;mNH z_Lc$pIL>lwncgRduwH&`C(4R{8|MEpt*macRzDi--%s4sYH)a`Y#a8>cOaHjYJl}S zE@uC3qm_(W70#r7rnV6uAcs)BQZnCw5^6;z`k0-FyU#747-mYYxGP~6N|CuRYgUx^BSHSM0WJ=s=KpNz|& zocYUO&rf*}2!G7)-@ilW0ph9?9zavdHL9oq_;7YaP^WtE5~iIKxE!$FYg$^q3NtfX ziPEI*SK2?O%tP)Ebs4%6%BWrK?o?>~oaO&_L)wccW+B#8kAwaa45I(pA(mT z>*)Ra_wjkl-q|@<2w=HQ(Z7I&C@4{(OvRedrn*T@68x;1@ijJ7YGnPufB)oQeg5_) zDdERQ4rKhJY|&k#40ZFGQACq@F zA7O>Lx1wFgM}Uf{hVNcnfpBURM1sTwkOq*0b-%9EfHm(qI{0qf`lUB0l}9wc_TJ{? zcy%#H4$|Ppts2b?d3B#02rrKnX=^w*} z*Z*GA5XtbuWqME;XQQmWgkz$gnw>wNyq?ZkS5eSQ$UIL_ka|#KwQm-GkMf}e=h}DX zJ)rR$Kst?OPz`77*ynIl8yjkpfu}*ii%IP_4Fr8CU9Q};rleP({>!pi8+-oX#4{T6 zlF_RDr)Ss-c0v679q?92b;}b}SXO4M1=3!qVH66F!k%%5uNd)b(@iGXlS;B)jmlzj zKA&H%C*^7k@N8YghgqlUb4Ue-hP_7~?BNdVRDLi$LcFqUQA4!)dwCr>nSk`oHzq6y zZggm^X$6GNC0M(hgoOF>Wm+-$ zMNj!f9ZyeBrzU=;zQ;3At6W-VsPN3a;EOvPzbFl7BEDNV*P>NqlM8L5R>3%jJu6xjo#^-Baqor277 zR(~*=oX&@Iv77RLZikk%T7eqrels5s6jp~=%tXBj-&Xkthj*Zi;Egxn<+WkslNVqotLH4_hqVplwh^ zI0^4%e|LG4nU>uxeFFV^6iuWN1W0u@eEE%=j{rv^jsmgoZ+X6lI`yD!Kp@=V`ro1U zFvaXN3HWOi>Pc7X4a+&Jr5R27S#Z@O5ow)TKQM0fKRe03qPf^O2sux5jEO@}Bj#~{ zUxb@123i~L$7aq1jH+0twr8JUJdb8&NS%%07n~Y9{>OV literal 0 HcmV?d00001 diff --git a/assets/sink.png b/assets/sink.png new file mode 100644 index 0000000000000000000000000000000000000000..5527e15a856f6c315d55570a8ccf0b9124a894da GIT binary patch literal 7916 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 literal 0 HcmV?d00001 diff --git a/backend/special_effects/creation_effect.py b/backend/special_effects/creation_effect.py index e69de29bb..0f6f87151 100644 --- a/backend/special_effects/creation_effect.py +++ b/backend/special_effects/creation_effect.py @@ -0,0 +1,132 @@ +from backend.special_effect import SpecialEffect + +class CreationEffect(SpecialEffect): + """ + This class represents creation effects in Robotouille. + + A creation effect is an effect that creates a new object in the state. + It can be immediate, or require a delay or repeated actions. + """ + + def __init__(self, param, effects, completed, created_obj, goal_time=0, goal_repetitions=0, arg=None): + """ + Initializes a creation effect. + + Args: + param (Object): The parameter of the special effect. + effects (Dictionary[Predicate, bool]): The effects of the action, + represented by a dictionary of predicates and bools. + completed (bool): Whether or not the effect has been completed. + created_obj (Object): The object that the effect creates. + goal_time (int): The number of time steps that must pass before the + effect is applied. + goal_repetitions (int): The number of times the effect must be + repeated before it is applied. + arg (Object): The object that the effect is applied to. If the + special effect is not applied to an object, arg is None. + + Requires: + goal_time == 0 if goal_repetitions > 0, + and goal_repetitions == 0 if goal_time > 0. + """ + super().__init__(param, effects, completed, arg) + self.created_obj = created_obj + self.goal_time = goal_time + self.current_time = 0 + self.goal_repetitions = goal_repetitions + self.current_repetitions = 0 + + def __eq__(self, other): + """ + Checks if two creation effects are equal. + + Args: + other (CreationEffect): The creation effect to compare to. + + Returns: + bool: True if the effects are equal, False otherwise. + """ + return self.param == other.param and self.effects == other.effects \ + and self.created_obj == other.created_obj and \ + self.goal_time == other.goal_time\ + and self.goal_repetitions == other.goal_repetitions and \ + self.arg == other.arg + + def __hash__(self): + """ + Returns the hash of the creation effect. + + Returns: + hash (int): The hash of the creation effect. + """ + return hash((self.param, tuple(self.effects), self.completed, + self.created_obj, self.goal_time, self.goal_repetitions, + self.arg)) + + def __repr__(self): + """ + Returns the string representation of the creation effect. + + Returns: + string (str): The string representation of the creation effect. + """ + return f"CreationEffect({self.param}, {self.completed}, \ + {self.current_repetitions}, {self.current_time}, {self.created_obj}, \ + {self.arg})" + + def apply_sfx_on_arg(self, arg, param_arg_dict): + """ + Returns a copy of the special effect definition, but applied to an + argument. + + Args: + arg (Object): The argument that the special effect is applied to. + param_arg_dict (Dictionary[Object, Object]): A dictionary mapping + parameters to arguments. + + Returns: + CreationEffect: A copy of the special effect definition, but applied + to an argument. + """ + return CreationEffect(param_arg_dict[self.param], self.effects, + self.completed, self.created_obj, self.goal_time, + self.goal_repetitions, arg) + + def increment_time(self): + """ + Increments the time of the effect. + """ + self.current_time += 1 + + def increment_repetitions(self): + """ + Increments the number of repetitions of the effect. + """ + self.current_repetitions += 1 + + def update(self, state, active=False): + """ + Updates the state with the effect. + + Args: + state (State): The state to update. + active (bool): Whether or not the update is due to an action being + performed. + """ + if self.completed: + return + if self.goal_time > 0: + if active: return + self.increment_time() + if self.current_time == self.goal_time: + state.add_object(self.created_obj) + self.completed = True + elif self.goal_repetitions > 0: + if not active: return + self.increment_repetitions() + if self.current_repetitions == self.goal_repetitions: + state.add_object(self.created_obj) + self.completed = True + if self.completed: + for effect, value in self.effects.items(): + state.update_predicate(effect, value) \ No newline at end of file diff --git a/backend/state.py b/backend/state.py index 9ab74177e..b2055108a 100644 --- a/backend/state.py +++ b/backend/state.py @@ -62,6 +62,12 @@ def _build_predicates(self, domain, objects, true_predicates): objects (List[Object]): The objects in the state. true_predicates (Set[Predicate]): The predicates that are true in the state, as defined by the problem file. + + Returns: + predicates (Dictionary[Predicate, bool]): A dictionary of predicates + in the state. The keys are the predicates, and the values are + boolean, where True means that the predicate is true in the state, + and False means that the predicate is false in the state. """ object_dict = self._build_object_dictionary(domain, objects) @@ -232,6 +238,18 @@ def update_special_effect(self, special_effect, arg, param_arg_dict): current = self.special_effects[self.special_effects.index(replaced_effect)] current.update(self, active=True) + def add_object(self, obj): + """ + Adds an object to the state. + + Args: + obj (Object): The object to add to the state. + """ + self.objects.append(obj) + true_predicates = {predicate for predicate, value in self.predicates.items() if value} + self.predicates = self._build_predicates(self.domain, self.objects, true_predicates) + self.actions = self._build_actions(self.domain, self.objects) + def is_goal_reached(self): """ Returns whether the goal is satisfied in the current state. diff --git a/domain/robotouille.json b/domain/robotouille.json index 9be65b7f2..a071b66c5 100644 --- a/domain/robotouille.json +++ b/domain/robotouille.json @@ -4,7 +4,7 @@ "input_json": "domain/input.json", - "object_types": ["station", "item", "player"], + "object_types": ["station", "item", "player", "container", "meal"], "predicate_defs": [ { @@ -23,6 +23,10 @@ "name": "isfryer", "param_types": ["station"] }, + { + "name": "issink", + "param_types": ["station"] + }, { "name": "isrobot", "param_types": ["player"] @@ -95,12 +99,24 @@ "name": "iscut", "param_types": ["item"] }, + { + "name": "ispot", + "param_types": ["container"] + }, + { + "name": "isbowl", + "param_types": ["container"] + }, + { + "name": "issoup", + "param_types": ["meal"] + }, { "name": "loc", "param_types": ["player", "station"] }, { - "name": "at", + "name": "item_at", "param_types": ["item", "station"] }, { @@ -108,11 +124,11 @@ "param_types": ["player"] }, { - "name": "empty", + "name": "station_empty", "param_types": ["station"] }, { - "name": "on", + "name": "item_on", "param_types": ["item", "station"] }, { @@ -128,8 +144,32 @@ "param_types": ["item", "item"] }, { - "name": "has", + "name": "has_item", "param_types": ["player", "item"] + }, + { + "name": "has_container", + "param_types": ["player", "container"] + }, + { + "name": "in", + "param_types": ["meal", "container"] + }, + { + "name": "addedto", + "param_types": ["item", "meal"] + }, + { + "name": "haswater", + "param_types": ["container"] + }, + { + "name": "container_on", + "param_types": ["container", "station"] + }, + { + "name": "container_empty", + "param_types": ["container"] } ], @@ -173,7 +213,7 @@ "special_fx": [] }, { - "name": "pick-up", + "name": "pick-up-item", "precons": [ { "predicate": "nothing", @@ -181,7 +221,7 @@ "is_true": true }, { - "predicate": "on", + "predicate": "item_on", "params": ["i1", "s1"], "is_true": true }, @@ -198,12 +238,12 @@ ], "immediate_fx": [ { - "predicate": "has", + "predicate": "has_item", "params": ["p1", "i1"], "is_true": true }, { - "predicate": "empty", + "predicate": "station_empty", "params": ["s1"], "is_true": true }, @@ -213,7 +253,7 @@ "is_true": false }, { - "predicate": "at", + "predicate": "item_at", "params": ["i1", "s1"], "is_true": false }, @@ -223,7 +263,7 @@ "is_true": false }, { - "predicate": "on", + "predicate": "item_on", "params": ["i1", "s1"], "is_true": false } @@ -231,10 +271,10 @@ "special_fx": [] }, { - "name": "place", + "name": "place-item", "precons": [ { - "predicate": "has", + "predicate": "has_item", "params": ["p1", "i1"], "is_true": true }, @@ -244,7 +284,7 @@ "is_true": true }, { - "predicate": "empty", + "predicate": "station_empty", "params": ["s1"], "is_true": true } @@ -256,7 +296,7 @@ "is_true": true }, { - "predicate": "at", + "predicate": "item_at", "params": ["i1", "s1"], "is_true": true }, @@ -266,17 +306,98 @@ "is_true": true }, { - "predicate": "on", + "predicate": "item_on", "params": ["i1", "s1"], "is_true": true }, { - "predicate": "has", + "predicate": "has_item", "params": ["p1", "i1"], "is_true": false }, { - "predicate": "empty", + "predicate": "station_empty", + "params": ["s1"], + "is_true": false + } + ], + "special_fx": [] + }, + { + "name": "pick-up-container", + "precons": [ + { + "predicate": "nothing", + "params": ["p1"], + "is_true": true + }, + { + "predicate": "container_on", + "params": ["c1", "s1"], + "is_true": true + }, + { + "predicate": "loc", + "params": ["p1", "s1"], + "is_true": true + } + ], + "immediate_fx": [ + { + "predicate": "has_container", + "params": ["p1", "c1"], + "is_true": true + }, + { + "predicate": "container_on", + "params": ["c1", "s1"], + "is_true": false + }, + { + "predicate": "nothing", + "params": ["p1"], + "is_true": false + } + ], + "special_fx": [] + }, + { + "name": "place-container", + "precons": [ + { + "predicate": "has_container", + "params": ["p1", "c1"], + "is_true": true + }, + { + "predicate": "loc", + "params": ["p1", "s1"], + "is_true": true + }, + { + "predicate": "station_empty", + "params": ["s1"], + "is_true": true + } + ], + "immediate_fx": [ + { + "predicate": "nothing", + "params": ["p1"], + "is_true": true + }, + { + "predicate": "container_on", + "params": ["c1", "s1"], + "is_true": true + }, + { + "predicate": "has_container", + "params": ["p1", "c1"], + "is_true": false + }, + { + "predicate": "station_empty", "params": ["s1"], "is_true": false } @@ -297,7 +418,7 @@ "is_true": true }, { - "predicate": "on", + "predicate": "item_on", "params": ["i1", "s1"], "is_true": true }, @@ -341,7 +462,7 @@ "is_true": true }, { - "predicate": "on", + "predicate": "item_on", "params": ["i1", "s1"], "is_true": true }, @@ -409,7 +530,7 @@ "is_true": true }, { - "predicate": "on", + "predicate": "item_on", "params": ["i1", "s1"], "is_true": true }, @@ -439,11 +560,99 @@ } ] }, + { + "name": "fill-pot", + "precons": [ + { + "predicate": "ispot", + "params": ["c1"], + "is_true": true + }, + { + "predicate": "haswater", + "params": ["c1"], + "is_true": false + }, + { + "predicate": "container_empty", + "params": ["c1"], + "is_true": true + }, + { + "predicate": "issink", + "params": ["s1"], + "is_true": true + }, + { + "predicate": "loc", + "params": ["p1", "s1"], + "is_true": true + }, + { + "predicate": "container_on", + "params": ["c1", "s1"], + "is_true": true + } + ], + "immediate_fx":[], + "special_fx": [ + { + "type": "delayed", + "param": "c1", + "fx": [ + { + "predicate": "haswater", + "params": ["c1"], + "is_true": true + }, + { + "predicate": "container_empty", + "params": ["c1"], + "is_true": false + } + ] + } + ] + }, + { + "name": "boil-water", + "precons": [ + { + "predicate": "ispot", + "params": ["c1"], + "is_true": true + }, + { + "predicate": "haswater", + "params": ["c1"], + "is_true": true + }, + { + "predicate": "isstove", + "params": ["s1"], + "is_true": true + }, + { + "predicate": "container_on", + "params": ["c1", "s1"], + "is_true": true + }, + { + "predicate": "loc", + "params": ["p1", "s1"], + "is_true": true + } + ], + "immediate_fx": [], + "special_fx": [ + {} + ] + }, { "name": "stack", "precons": [ { - "predicate": "has", + "predicate": "has_item", "params": ["p1", "i1"], "is_true": true }, @@ -458,7 +667,7 @@ "is_true": true }, { - "predicate": "at", + "predicate": "item_at", "params": ["i2", "s1"], "is_true": true } @@ -470,7 +679,7 @@ "is_true": true }, { - "predicate": "at", + "predicate": "item_at", "params": ["i1", "s1"], "is_true": true }, @@ -490,7 +699,7 @@ "is_true": false }, { - "predicate": "has", + "predicate": "has_item", "params": ["p1", "i1"], "is_true": false } @@ -521,19 +730,19 @@ "is_true": true }, { - "predicate": "at", + "predicate": "item_at", "params": ["i1", "s1"], "is_true": true }, { - "predicate": "at", + "predicate": "item_at", "params": ["i2", "s1"], "is_true": true } ], "immediate_fx": [ { - "predicate": "has", + "predicate": "has_item", "params": ["p1", "i1"], "is_true": true }, @@ -558,7 +767,7 @@ "is_true": false }, { - "predicate": "at", + "predicate": "item_at", "params": ["i1", "s1"], "is_true": false } From c6e6227dfdbfeb943ea49b07e56d44b2fd7320e5 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 18 Mar 2024 21:04:31 -0400 Subject: [PATCH 03/41] implemented pickup and place container --- assets/bowl.png | Bin 0 -> 25444 bytes backend/special_effects/creation_effect.py | 11 +- backend/special_effects/deletion_effect.py | 124 ++++++++++++++++++ backend/state.py | 12 ++ domain/domain_builder.py | 32 +++-- domain/input.json | 18 ++- domain/robotouille.json | 90 +++++++------ environments/env_generator/builder.py | 42 ++++-- .../examples/base_fill_water.json | 47 +++++++ .../env_generator/examples/base_pickup.json | 3 +- .../examples/base_pickup_container.json | 48 +++++++ .../env_generator/examples/base_place.json | 4 +- .../examples/base_place_container.json | 48 +++++++ .../env_generator/examples/original.json | 1 + environments/env_generator/object_enums.py | 12 +- renderer/canvas.py | 77 ++++++++++- .../configuration/robotouille_config.json | 41 ++++++ robotouille/env.py | 18 ++- 18 files changed, 543 insertions(+), 85 deletions(-) create mode 100644 assets/bowl.png create mode 100644 backend/special_effects/deletion_effect.py create mode 100644 environments/env_generator/examples/base_fill_water.json create mode 100644 environments/env_generator/examples/base_pickup_container.json create mode 100644 environments/env_generator/examples/base_place_container.json diff --git a/assets/bowl.png b/assets/bowl.png new file mode 100644 index 0000000000000000000000000000000000000000..a5f8dc64d9499e03cabd67c1032996b34c63f358 GIT binary patch literal 25444 zcmeFY^;gsHA2&W42?0U6L{N}cVl>DA0cj*gi%6H0jrc=^e&($Z0#NLggTrr7Dk3fY|iAZpM?PaH4FmXu>>*11J#fwmVM`hpV zBK=ynF-p{pW38abvaGzNwv5N&)Kc?p-&Or}rh+Bptf&YI7QWdqX2%{4s>~n+9yr~KOptDWNd|oq9pZ;SHeF!4D z)J~CJ#l^ze0a>7fmY6#UE32u(TXX_6uO^Cxj<|*j!yA{G%imf%O}hDHHr|i;9T0@lc`r zTEw@!9%rbvdXCP$KbyAK3Cxi#LwXrG2Xk=l*VFs)7HL0;Wai8vtIhcdZ}9T6a#bH7cj4r`F3ihgukv>uWCS{tlGU;MP0N#n zUqOvFKh%0Yp`Ob&s`UQ6XiV|+&q97p3xQZXI@i1|0LIV>pg zkF^qP+u1v%Xio1IEooo1Mi*!PPs?HAZJY?9a zgF~$&_>&trL#I$$2(D--5UKtuMgPR~{;$Q-CC zK>y~ht_-N~Q}>ND7E0(Ag3Z&GPNT+-7n;ItUoLdYFeiBCA#+an^zklVM5v^hzA?=p zA%C4zM2|nsZns3*b5>`d_A_Jw8U%H|`G>$=t#%A<5zJ5#gi3i~Z#t|hCKe9a`j!xm z@L_yXU}<`&@#=_E`lII9Y?r{ql{@SM%YM>X(VPQMznc4-?pA1*E0HxeA`zg&Ek-Fo8hWTEsKKKnS=E+*QZmQ%@{jMzU8elEs@x_XjpyfK`M z5D=Q|Q0BXSQ1mwt^arwHDbJDnc*MVw0DWpr5w*SL;F`$&oo`HF*qFL{Ry`3FAI+`t zdjoXKQ7eFzJfA<$!MTrZK-41eIz-X`b|=2}_YdLdq3WjV;c z#W`i$G6eG)}co0U7I zS?S^suTW3V0o(pz4~vxHW#<^5dOdM^wofd zmQG>EvlRCwe<9h$h#rcdg6?E7m5lGvUl>i_Hl)i9X^#kKIUjOc!$Mp!`HLAR(<9bz z*Pmp{Tz-@uN_2n&=X~Y+Fv--!5(~lN@zOOrdYm@3BG>jaH!$5wX@h+ZN;xm1?Q8yE zx9t@ImkYC=d<~%h75}K7oaOs%7b0na$H{wS3g}ou?C4e*x4Bly1#^t zp1e3xPha$5r5rw8zLrPN(+)OrtJCy~j)|$JK~1KoN1VnW+_YWKxXw2ATK4mCu45a| z_KVkQzEbJP9{wD&@S8td4ZY0MH_7^68$$1xrO`kSOx%1^|& z+Ib#K*p9N4r=b*7*N5nR04LZGef=X8Gj@M8ch`G`LpJbicl}OQXEn;Gfc0uUT{tKg zc@>B^=WiH~&?n1D7asPqIDcXJCHh}%G-rY>L@!QEEAX0~_0lwm{WHvSzl{ppO=x*D z^%^7+u9+?$oUVV0W9mfXZNKoHGtlTdm;0dfD=wHz`rYK)O5!aZWM(~Xe z?&NeIwp)#oNI2mFFX$%kdG9{E&Lo@KXV4w%Z2* z%|w^ghcqVe^~9P#mz78)a+r0noLzweM0WMh9sBm!R^coOr_Ogf&$ay%Y^6yk@@P>G zagTT;Lp=y0c3UH$+%i7sMUr5x6VVE(F(*DCC@-hNTG<9y+eYJcKFA5V+L|q!SYp7t z4Ap1cfm{z=-GCB$RCCWJ5#+frqedwuXN2Wf{mUptCA^pSimEkh>9VyMJhNDTTM~1< zBdSuUZ@wDQNx1DzxU)cXR^8r?wNKH2De(nufzf;XMN86m%O=Ruci5D<=uIEJNM=-b z$`@Z^BrytLX^q~!6%U|n1>uL0=B1>_|16^mKoNuDgFdRCwi}PM{Zhiyg)05HvG)fh z!Hk-5&*sU+=m;p9mC{VTo7o_v7iz>u8Td!f{u_@-=x+w8udjpB7uJr}V_~l#A^5uI z3Hx04!3hx}q|4W8$ucjZbX%B_Ara}Y_-sTN1_M1#q%ci`WlvL>3M)6`zM4*Txtgcw zkKBeTIrE;#pTjD}>&8X`Ha5gKfJrQ$S7j|>JU;5KjK1Cc?{4Avhb0JHZK>QSO1Hdl zXHyGt$U45(;3I605~29^$M`97o?t?^jhF`)a=wv`Xb40|ipx$yzdMGDX$2G+(0+Gp zzO7ZKSC`tW+an@u>&TyEZst%t32}RgmHfW?Ac3}S*7dt>kkzLb+{w2qGc!GlR0*VW z)_Vk@n&~;$Y8p}sAZr^|)IP)Jk5~K^EefF+)fTkDoLeihJ>Lf1wohW?`VpLPY;(!I zhiO|dTXmW49LBe$NYNL8Dzi`)9y|9qw8<58>P1dJ;M!EBk;V)$?ohli=DTbP-19%^ zAR)j59kYNo)2%|VsESvMMJZ)*t3+3Aw;Dy=ETimK@eQIvalmcdl)qv5QA~>(HL4YV zJBqL<{yhTiI;rF@yk93FQVg4CY8Ngs%&>Dn&b&x|BYRG*|MFjc?c6wl#frcr0p6S9+gFIpBBP6C89$ z_H?_CrR=%bTtaKZ(0`9BCBy{S}LJhv+ZY83(8|ngUyAEh7I-kCVW(G|`S%}7mVIzRLusP_b zQ`Rs;MF=^bT)*SJM054Rh(KOra9FJV7B!)$%SBmA35UdABl2aQ-f&D};8ThbI< zF@08YUjNztSB}!`ub?j znC4CInN8o>=z_hUQHRHNA*P71hN^ETlV`Ktlo^xEIs4sya*dVbD4!*1A#=1M%QL^8 z!4?9dS@M*l{by4cyR^2>u9;7}%90eQ-vqX3PwTj_RgXGr^P{aWA?bOy9|Nw(CPwnu zZ^N?Jt{gW@B=+tOo{Ffw?LXufZ#wen_V~_!UC!KU@O;={-$!2GF0|R2Guj84k;j^N zw^(?$xOz*oLG(6$p@(8gff8+?27|nnsGZ%pCK4-n8UCL++z#1#6bBl(_6ZTD1r+gewbUk>jkrO#W*);NNuJ_(c^VqU3n?Mk(X|Ysm&t` zo~(bJUV%{+%fnwXgTO=YS%Nrq7Ph>h4h?3T-cYqMgMk`4N2eq^z8bNe)4iyU%k$b^ zxnX4_6c~c7RuoeT31M5FwJ{G$VBirHIqEdGQq!^=?p8@#y2(}hG z&KGJs49D3){?}-69 z)VZ;;g`abYv`eIUUI>cubu2f6tbNmd9E=0F@S?y*^cTr5c%6~t3{E*EFg3oQSN?72 z)rkY(ngX-4O73qZ)~36|RZ7O6mrp=i=2^{zt++d+9;?R)46@ul4RD)fc{=gL1PcAI zQ}dBv{m3ygh>ma2C1n2eNF^a=73^U_;^y8TDn%IKh@9Q@ZQ&upPsZOmXH6ds{nnB! zWEFBIqcWGPM^@xB5GPuSE_(b6*N$L5}u$@uR&q#@G*nE_0_Ez|SCr?F!3N!&~| zmQ)>anO4}qr==er&@(Vle^9z(uX)U^1Nrm$t*J0?Ra>ZcYgm{*S>;amxp`ydz-U-_ zzoonb{HDuZ{&@G=KDem!4#=X&#lUe~4 zV7TQIiY4Phr&G5%M)yiy)Dk-CFSNRJbWuf6lWY1Ic8@tL(cpn9QiGm2q3iyF)yP>< zVH-^ca{DH6<_TsQZyoA0UL?D$VUtMfYhDJ=e|u(+t;(vE33x^WGH>=?zQ#LV)yS{o zm!%*usCC50GDxU!JUl?Nr0(N5Nd1#Eu#Hqb_Nl!#m%91mFa;?z~)|@Yovb zwZcEZlzs5)+#NbDZ*AC*6tUgKY-GB)y~T#@-st0_n$hAE8hoO~bLtCrSu!;TlTn?97+A|+~*5=WZl-qd+Do_7*&UQ)jihWy+KJtcHn;CVjw)u zi#h5CDabP9vO5Ysp&)wo874o+@kckpCXQU8w9~WiS(YG|1Cn`aW=64LmI-5GG_vPC zg7HQ@Eh|WQzI1J3YWh6&+qKw6jX=+=abgltuN=w9lNGg^($toG%-iHdQ z)jttN@S&H8MNirHAw&t!a=5d2w_n??GDG2gq-uIVfdw6W7kj%~j8qEFkNNW0=ep|kGaJqFvK>#;;;=#UA>=G9XjSHaIxB>O zNkT_G{y9+q#q^kF9|e?(u8DFM2QlWz1w`e>*)~4K_@ORoPY}e~NYw<@1&xg#geMtE ze)_IZ*L-!Lz;{@u`fd%c^M7~x`FukMsC2xLwFLTuGb@aFuac#c>WS_2iU9;T&Nje! zliwWh1l`__&%z~=xtV4?a*fC)p!Z9(vjy6g?u^Q6j?uB8C912NOnIqCpzC3w3g*a8 z5mM@p$84l7ZsMQ0NpyO5)gYV&uy?MQKT(?U@O)NBkO%hW@{6V>(x<3OY*(~y(!``x zC%CUn^lo?+v28!c_Am>USVE$b!skJZiMKRO0dIJmC7G91!m72M{~gVNtl7uRB?1E; z!M3z4Xi4&OUAbWuYC;cUOK7?7j}N2-FgGv48O+_8>}54Gm9;{ESOwXCDiKS0c)b(9 za6ujlE zXoUO9)oFaUlpK`*gAM60hl{Zu%2T)8XyaXg@Swhn8~s4fR4j69xB@PB)5eo)WMfMO z%6Y%?X=$&;9d}dZg@y772nxp8-E9*{Smh$!v6Vt`fAU=K&s28pU{RrI&K9b1X$?#X ztc*2i$bR#hV>Cs5oX!|wftm!xr%W;{F?Mo@P?vtd>lVpsaSYQ-CODQAxLJK>&wjBi znl+Q(hfQzx-dbtack&t8_(Y1osN81BfpScJ&KJ*F(d{#^e+bQ7-kxXxjy2_pER7Of z@1}3F?R*p~h}G6(<>ym+dWL^{S~_ByE8wnFe&bOWdW2g^ifYk43d2w1j1f*qeD|R< zW@;K2m%A;WG873H+ncXCJD%MByem4{a#p0tfYaqWUGI^VL~Kg7DgUnAc}VJPYsxX} z)^Kn(i=YY;^=TPR61#yqS5yetI{GKI#L^6Y_AQA34yClN4T~+slT_+GkR~aCf@0CV zp$T~cuJA#)*7q%ubE_l)O$Cp|V0&1@39U@$6jpj?NBAau@*=QD*3Z!sFX35sW;<^Y zjnTI4kEL`we?-SLYXv&@r|1mEjnC|Y9l{s6zRm+YR{J!Cf(D}lsX|V4rt+hSo9!Db zV>QHi%?ExuWbgleT6BKoIXyK6k)C^@m{AO`wI9j~%s=@ugEGMnJv}8~EJj{hkp$;V z5^Khen+>5ogFo1ApZZC=ww_Mz{HihY!=Kej=5}b1vDE=cbZ@V;c9zMv7RhI$o~d?B zmF8_92jjb>S(P@a#L!kh4F9?PUivplpZxuzEhruCCK-rQrjq+BaGiCge`U3{(yP!s z?8A_2Oh({C!-nb_TX{ZRVsN*JniyFvh-nGAC6kbdac4}mqtEsY+#R?Km953q&uVv{ zmjn_K(@>U-Z>IJSlN6>Dk15+1;Ob3CZX-qZSzz*H!ILw-P>b$x9I~~qdB)#9U9bT zClI74Pmk^TFQs0_@&&d!5D(1Nqe_c5=zEKn=uWC2!zZcFy>9y){yHCv^N8HM2)(*! zJI}e)-FobOT%O`zb$S}xQGyJ>aSo3{ZT%fRpF~B)trqB;GHU*1R?@Cqy7>ZD zDxU~Lu5#>ISz7-5M9Xg=b6**ZgZ42abSD3mu8q(K3)3_84Y7gY^@U8Mm4fTpIw^o2}iqK2KU!9BPUfqUC& zK)z4YqX7(Ov`YV|9-ixH2fWYC^>`B-|)^>$~=#Oe2{>!u$G;$S5`c(uTR{(<;*u9 zrK%U_8|JDo06>SwHCuydOKvo1tqBj&f&Hi0X8a$d7?aPVdqlowzFVGznlJL!+l;$$ zz4!My#RdX{;Vn8n4L(+<7`Y(m92;9Pl)&~imKydM1eH3hx<89mE&Me3wc#JeOsYCg zX0J6nUV+Ayya%)&fR@o?cTcC=QdV1sl49A7a2H-&c#)W zX6c9A=uvqbuJoR_2!*(Rwz+W>7>uC)zgYnI4kG0_vR*drW0k`yETt`^h)z3KJ~A?L z3ts;M@}^_citC3jHDHZR*Fe3=>XvdAM@@kv>BO@q}Y0K9Gr3$3daSk8VvYE9xbt<|)c^ z`Fed=O+3>qN)kI+P(1;mqii2v6amuC=-kCia`Z)t10Q#O}ex_Zp9V1@4Ljf8|!sPYRu+c?HfUjwL5R zh#k{2l;i6Ecrlhu$iT2cM4vleog7x~Xk%IxJ`dlfW8e)7;HqCb9fus}L&2RxLAbmg zZ3rY#Ph}pmh|?a-o$%<%`}nuELdp09i!4>P#ech>Uwo>ugq5c0@EuFW%GX;W zv@jzIAh26g+lrW$$LY2iiG1;>!vnc+wwp5KlLb$7w#dhw>rW1{MrrAuM{A$D6BAbn z;s3hErvNrH{w?u)XF!|8$TnHg-*k&ckLB2b_n0uVe*iS$2*~JfMf*DW8_oWH2N%yB zw*;JG2p*z+TO!1maI9#6H|@2qik+}lzKN>7tVm~3{AlOy(b9ibxsBo;?~w`0 zvp8hI)X`#F8-}j5m_A>uKsty)l_mRCa!OLV+5EEl`az$hAP_q#pCdy0?OtONF%~#M z;H6{618MFTU?Kd)C^L_r=6t+GP7f3DDgIu)da{s6?-&g^8ni3U7F{Reop&Rd-@_gQ zf(HpfQQ%$yQeNz-p~iB8j836^@11mr_szWT)taL7KFzgFW$4HmCSj_!l(rW-?i7Gav>CnJ}Y5v=uwx_nDRGTMbk*;qaX zF6o83$;RWMS@QF@@cUvc*9eL#qYz4{T?>6=XL#{>-5I{t|67#K( ztVVI2-?x%K;sC+nI@Au9?d#`qd`Q-r?YAI0l3)RWtiFls0#LE-AXr$-z*}ZM7}qxM zHmvpBnvpQl*v%2X2iq#L$97jC#1+XM$j}e&2Eo31NxicZ?~m{M*N%JZ`ti+6Q=A6g z{(Dq*=}@OPIn5x$@VUoTy+I_$qMP4cft*QsPDQXA@wfh0{kPLLR`#ETJo0&M=|5r{ zQt3cqEN0hOv%hRs_ycSWxvE9dEQNv)F&I7l4#E9!XYq3@p8Kf7xj1}qU#C|rvPffD zuIC><9T_MGhP{*l(P>~PBBnQQ2^gjM+#3W2s2^WT9z_XyF->mGYS&aM&}g;IYUe)5 zqWWCC6?Xn-FKZ1SqzGTCtJK#J6f%N&e?{}sfiBu_<_mh(v2heQf>6N+AaLoLdq%PC z568CF*67}%-myeYh@k{r+)W|aY#HTumvK9B<-MB`p@ZuZJu4Atm*itW{PU>#?VT9e zA1Ow^&-b6hO!;$#@jQ4iUT#svyH#M zQ@f`zp5C2x+js_*sCe6#%Wd9qN&74(7g6k&|KV_obP(2x@W9@sWm-T>m0f#AxX0>e zTJy3_``o^-+paR?*>A=OgOZqd%RZ8dERB`R7lgayWd9}{;}sM(tEnhK?EY(A9XXN; zB+pcb-wcassq&B1E~d3he9?z-0CXzr#|;@*L8hRv7j@ zmTVKgUs?=54GmktuY{s^oxIy21_)H@&V6SB$nG{LO7JPZu0w0zx8=1uKn=*c4^ul&ZK!pyud>!Fhv3iY{EH_z{K@-arY17Guefv2&5{d78Eb!LZPyE-W zct2NFxP=vY)vY_(SfFxhx?1w0NNT@YG34(V`W8lFC_MUN-4?2$r1SuGeh!ZZ`uMcaAthR40ty?TquF3)gJFPeP1rD=`|3c zJZ)Yc1`#h(jkxR~nLdY4UUjD8lSp~KwkzGxyF4PE;~D_$_Fol|gM>yd3nOOaN()oz z2TnY!tHlrdhwP{CN@&2o1v_R7(@a8@%VkG&XB3?$t}S~Qd#r7SP6)CW%Vq z*RTaBfqS4o6Nu*V1)#1n2X`U#f2?dzwBZI%%^}JzmdpO^Pg1AL%D_7{0*I~8c1(bo zP;0vIWw=KFEbgoQE$2m5rm@0{|Cw%Z%D71pZR|I^?nAvdF&b=*C$8Aa$O3eS{&qqE zN>&U+tz^tcs6qwasX%Vlv6LFd@+>x0k_;c83*2G-*rkitc|=Bw>r;XNj~Nr&@7;(G zmhiiyGG2x||6M~d_3&|ng8mXCfvs4bs7IW^`*<;}M6Q90xaki6F)v1nyPrvPD@O3i z)0;evCas~6%-h|6tW`Ju?3x7(l-?>aepBQx z@iw&-7feq=Q7WuRn>YfrT&s&6+-UI`2y`@`RWEVgPcyY)a=NX^lzS|s(>ULO zuK30AS8g#x1u`1mmLJlpPnG~YNyr=(wv+Mj$E;HCZbOuYmHbRCZgJp`jcW?tOk_8e zz=7ExEZqnd+e!3lKrP0XxsCjA>ARvWt6{lM9W>qyJLm~mBq{;Fh!%})_hD9(WHkcw zZoA{$g2=TavF(lqU-`uYL>r_mJ10y(9CJCkkbe9%UYP4eGM##I!T9wz(M#-h5iE^luI6{BI8VSvnXSA=Y_IOHp)ijgayc|e z==~6419K38G&T7Hfk3Y=+^DLl+H_OAJhe%^0sI+B@0h`b!BFOWXIcyG<{bat;bd)p z$J%n@Pyf(U#VtV`ZA;)_`w8>$A$)n!^vZAbX~E4wHH zT5kC?H(Y-_wGllmyvgMrlrnZ;PLi|H?PHP9%9ihTz9eUIcLs0RGpi|Ug^7EA`R4MN z|39hv-PCH#2fbYU17#8b=pwnbmYQmjY#ubXoOw_)>4SpfTTrUTeaso$E;4?U^f|}; zP?w%NNnNG{1H1XZB0YgrS>3mfGp0YMhJzvKQ z6J;(-$K;Zb{`>WDyH-HC!!;*xu?cs-W2{V|+c25%hY{c^dtAYQ62?(wiRyIHH*KiXQuQ(&tKtEvH^rUUD29Ubj}dHO|8(3F!&q8WIQsPrcD@itHN z7htb*vRU7G^X-UOUOB_`jpIUuPOS?zCYpoxnuw&h;Re_`7)~2j$`AJeeJZ+uQSju5 zL^cYqE3|oj?<{3t(CKtLj9U^qE^ zhcuj77B}e>tORJg{D4m!DBl5vTR)qgd6tpcTO>HEe5D=^RtM^&r26U_x$e&xy3220 zy5;?q8=lEDwAACYrx#aSG)SiIP!#aG8$t;Ce#a6?Ro1dGrm)dPPX*)SH!x6(G`~NcP?$dVS5LNfuJs(ya&*Pyd}Cbbe>&@Yu=< z-`c#ycw$Hq$W;U?&jpl>WskM}Y{D>KN$Zrdh&b;3v%tSr@d3Ou_lO0T^Rk)!|zfFD2d3U^xAl~sNn~wMXg37e5b(-~|mwY&04853)x}E(RT^VHLRAUtCoJNwpwp*uz!vD2TuPQNes?&0N z!c6R&fi%Q}R6@Nh&V<$K^P}2|jy;9nRjga-5{!* z{o#qAv%b?oGwcF%XQ35`?XZW_IYkkY9_J}Qjn|S5``h(S8Y2lphlBo2=LVdN*5Y=n z97BCeo*CnTMh3_o+`EtQFO+h6o#MKF7?d3T%S2$VTcPleeMYsq7Q2O2y2-Yjdh*@sCOZWfkig?U)UIOc zy;JirMCSx?%dH-eN-*J@oV$~nxrUB^?Og~)P_2-V;^R!LG}SX!`nAl8o#1*_r5w`t z4eTAgL5g&!h!ngF?@^Mw-V+w>L5GQWr#vKbs*tM2MiJvtj2o2Y0}xQSTxwVhv0eND z;!NC?kUaX{IWZBK(8{Ti)B3GoHa zW48j7|1SgmU9FUg4-Xnt_W7i7R3P<=$+gYDp)HGiyE`RtGhNu}x7j^?}k5KL~ zFl=9rEV7P6&yDUlv|ep*P?_hX*)f@;di(n?@E<1y3DvNu6b*O8Oa&dMf^%oH4eJ|M z83~mZY7s{K6^HU9V`3@|<0>Vz`QsSQn7-WF8nYa#m2QmPO=3VB6I>5&^j7{5KJBuM zMEe3Z=xFJLbCh-oIH9Y%yIT>tpIED15^EH=Z?`U4I~fuH7^K320lQ%gIkCTl`HMo9 zs!-w?f)#%*c|(!(n8`=xXcVSoF$`@9vs+~OB1&u|Fq7@JwPOJv904*VzJ>bkaWcmZ zTaaVMZcas{kqTVmT2hNSYZVMUw)8Y0Zj!87kDc0EhOKTkyPBAgOOf?)bRbbXE9#_} ze9p3F&HAZx^@KyW53Avm`;B|M6C|Gs8Qoi}YgL*>iBAJ4uRhzrYyq#*<;Kr>-@xFl z@Z?GfvD8^-$2y7wHWvt-EkQ-LfLBK4sh9JO%a1hpOvugKd{0ME|0#4x>35<@VD*tL zZ>ssgmQw89V9%__u3Ane9SVj}_$y$hqMCn?-Be8$u&4kAz-Ha-CTk6!@wYl7kH7)k z$LuE_77VE(U%$~zhz%a}^IJ`^+j(1%Oh7Rsj*@V3dWIbluf_*6Av0~DdQuukM5VmQ z2Hx10-QI2YK|&Tcu^*g4A#l+D*q=pZyb|0#y?!+ z($batMtG4i-U%54ZHobdbnN()gV4-H6Ejz_49r7w&phf6^5LBvP8pp(Y#)@^NnbiA z0E>fYJz_54<#46o@4a^kSboro;dQst>)%gfcUS`VGpiVqcGHcFIj#aDTKja3M-6o3 ze4K0^1R30KB$FcZDChoE-Q@G08nov8kw3LRUy_1GNiqhQDzzM#&X+pL?@T&%N13aP zhR(14I=}j014c^9Ww_3}eRx|NX;k~kAvajM|9eXK{xxa$GCrOyMR=#0h~4k-Nhq+R zS%9pOE?McMQ>5?Svp#E^{JTBo*(~NQ?Rpnjey-pAh}LRdZ>rGtd_)eHCjPZ{g@DdP zq%Aq8DFuW%0dfRQA%w_%5WnUYHc)S zLP7^vd&EV2)TwTC>#nyjTljP80Mc-e(pP-`HP${)>QVFa7c7N(@Ch=lL8*kU=eGv| zpyT3cr$U*|Q+xQ$36MBkl-%5*2==qEg6$DNWjX*rIj&bz)C1t6d{ki0V?UKe*IUCz z7!ch)9XmOE+1+Z%iG{*r0^lE)7~MX-tmXSJu_q|T45YPs|Jg)MST(&fNuIN0MX4a4 zHnD~Y3><7bBMN6f0kM&?361>D`D63HPU6}d^V|bZ%@?Phk7@;{B$uDh9e4h=v2^~v zcvGY)Y;H_Oq9i#N~b@}L%tkRN{=R6cNgz_t3r9vr_)kWbjX0=0z-{7$< z)oFnEu-Dhx{v@%$F)`)ZiRZPWkdkpWGkh0nLSTKi4c}MB_CILZP8C$Y_uu-w7$;1{ zQ+?PivU^|t^(w6PzXS{A$b;C`i#YJwQ$=>w}Ln^uIiU98_L@8aO4Qj zOC17ik&!(*jw4Z3D#QDNR{U(P=!dz43I`2Q=Os2%$qtOBY5CI*ubs=K>K(&nO+^^X z#@7P<7e*QzxDyIUtuDG*{tI(k|ejA)GTz3}sv@et{rvvr; zYZBc^Yz7i~A1^TQI_3+YJ(C*|GbJ!Y?{@H80z0Fj<%K4c3|z{2pjU3JhnTQ`C->~X zK)8x+c^F*Pi=qqJ|K;}MOL_`?j#LrWfnUxgr#Z_25Vt&;y+dFAQYi1U&@8y)De-L5wh3GyYDA{~s zx$7#PF*P>_p{C>Lro!i#&=}PVzkJB`pDC61gNNoi3b58g)_D;!M^>v}a&NxF7K8WQ zc#Fv9xBllQc9#kV?y7jk8I_BBsJ6doSMjVJl^*-qEKVYSpQW;*IgR zy?VqkL-_H2zFmm1cLT|$%fJU(O(ENGtCI(hR@{;$^VLc^P6{Lh=1#xw+Mf6 zHUNw?7*&d}n0z1iAA1u7MWV0Z0>X#gynx#!W`+slKLi3`7)W%1o1ylVEzeYtx z=lHAVl$x>R<~3?!_|8P}ssw0+E2j{jDB2Tny~(oh)RwnN1iA*lM9C23+A?00dQ^5$ zI}292yW9ou3;(KF+ZoF8AAURlj?%|zqzfzHxbpV-uio!n%7XdBOJ`MD#+e#L8EAg$ znKznyP+ystoB#1UTw%#572^swYKeC?Mo`K2HgF7k>HYXfFfooWdj4Tq8;q*e8uCAASZ3EP9A$jJc!$Q(-K9=DTgI>Q0^1qI+h*3#9&%VxXS8T` zK?pQyjN$u*F(*go$*e_#J|h@`hKFbq0U-hFB$;KjlU z-6iPEBM8#qGkk)FpF43wEdIVa*F2-Ii1PM4eqGoPpk%{%+p%Q1AM{NuU;kLrJ0S?u zZjd>7D<1|#{RrNk&}!+n%=ZDGs(FVHxFm|24cOnG2OW!0Ht3T9W*|i{Z}0VKUb9~N z@!Hbvhex`*1YU0f-V&dzggkNpy%2lfiKx3QH7s~cDN1e)rq zwKwwWXMMeF0??7rz>+WXS`M^$eq`P0 zE1{Cem?&?0F`tA4iP;Vnh>L5on4@vOy|zQ<<2b&^KR`4QvItn#N$s|XkVoj>(VIrf zMyfvMie6>Rb`$f^%by3p&77fq*gABxbRPKPslq>e<){G8dJ=LXM`gq4L08Z1zq$w# zc|XiQa4-z?6=7k9SW^t+b@nwNGCfXDE3?wopfp$N^RW;R`it^}dI@vf-z;G1eTZ?b zG+bCcFi&hFDtMUumI*rer~qu$C)0ne%@;3nKttGrk$w@#Qolh3nw(iQHw!#q(UdPT zWBomUHCD92PLKsd&=Ff&6(jrZr5DG?M{{c5gUpwv^uMXxISI#0yz>FVQ$5M0uFtI= z&Z|vaN!-jZ>_u}SuXfmH{Xq(5!mopX%N_LccGgS;Qnu~s!x%GX8WquIJ+TwA@S8`e1ru&P4%vx!-wUxuNir1_KnMX5 zMP2`=y8a9nPjzGZrX96_ZKTM=-3bjMrT z0U1@k$;)M2_$y=-h^L6ToD}I-3H;wIfJoKD{p7ZjvB-VVzMI{uVL#T0$bjt89iT<~ zyJHM*3{7w0IysQT3tw#nbizs?(hMGq4^mF2aLh

97{;D7uJXkzU;KmGwmGYN`* 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 TT=9kOzgvQ889uDkun+$qoLYvo literal 0 HcmV?d00001 diff --git a/backend/special_effects/creation_effect.py b/backend/special_effects/creation_effect.py index 0f6f87151..a97f6cad4 100644 --- a/backend/special_effects/creation_effect.py +++ b/backend/special_effects/creation_effect.py @@ -8,14 +8,12 @@ class CreationEffect(SpecialEffect): It can be immediate, or require a delay or repeated actions. """ - def __init__(self, param, effects, completed, created_obj, goal_time=0, goal_repetitions=0, arg=None): + def __init__(self, param, completed, created_obj, goal_time=4, goal_repetitions=0, arg=None): """ Initializes a creation effect. Args: param (Object): The parameter of the special effect. - effects (Dictionary[Predicate, bool]): The effects of the action, - represented by a dictionary of predicates and bools. completed (bool): Whether or not the effect has been completed. created_obj (Object): The object that the effect creates. goal_time (int): The number of time steps that must pass before the @@ -29,7 +27,7 @@ def __init__(self, param, effects, completed, created_obj, goal_time=0, goal_rep goal_time == 0 if goal_repetitions > 0, and goal_repetitions == 0 if goal_time > 0. """ - super().__init__(param, effects, completed, arg) + super().__init__(param, {}, completed, arg) self.created_obj = created_obj self.goal_time = goal_time self.current_time = 0 @@ -126,7 +124,4 @@ def update(self, state, active=False): self.increment_repetitions() if self.current_repetitions == self.goal_repetitions: state.add_object(self.created_obj) - self.completed = True - if self.completed: - for effect, value in self.effects.items(): - state.update_predicate(effect, value) \ No newline at end of file + self.completed = True \ No newline at end of file diff --git a/backend/special_effects/deletion_effect.py b/backend/special_effects/deletion_effect.py new file mode 100644 index 000000000..376c6df1f --- /dev/null +++ b/backend/special_effects/deletion_effect.py @@ -0,0 +1,124 @@ +from backend.special_effect import SpecialEffect + +class DeletionEffect(SpecialEffect): + """ + This class represents deletion effects in Robotouille. + + A creation effect is an effect that delets an object in the state. + It can be immediate, or require a delay or repeated actions. + """ + + def __init__(self, param, completed, goal_time=4, goal_repetitions=0, arg=None): + """ + Initializes a deletion effect. + + Args: + param (Object): The parameter of the object to be deleted. + completed (bool): Whether or not the effect has been completed. + goal_time (int): The number of time steps that must pass before the + effect is applied. + goal_repetitions (int): The number of times the effect must be + repeated before it is applied. + arg (Object): The object that the effect is applied to. If the + special effect is not applied to an object, arg is None. + + Requires: + goal_time == 0 if goal_repetitions > 0, + and goal_repetitions == 0 if goal_time > 0. + """ + super().__init__(param, {}, completed, arg) + self.goal_time = goal_time + self.current_time = 0 + self.goal_repetitions = goal_repetitions + self.current_repetitions = 0 + + def __eq__(self, other): + """ + Checks if two creation effects are equal. + + Args: + other (CreationEffect): The creation effect to compare to. + + Returns: + bool: True if the effects are equal, False otherwise. + """ + return self.param == other.param and self.effects == other.effects \ + and self.goal_time == other.goal_time\ + and self.goal_repetitions == other.goal_repetitions and \ + self.arg == other.arg + + def __hash__(self): + """ + Returns the hash of the creation effect. + + Returns: + hash (int): The hash of the creation effect. + """ + return hash((self.param, tuple(self.effects), self.completed, + self.goal_time, self.goal_repetitions, + self.arg)) + + def __repr__(self): + """ + Returns the string representation of the creation effect. + + Returns: + string (str): The string representation of the creation effect. + """ + return f"CreationEffect({self.param}, {self.completed}, \ + {self.current_repetitions}, {self.current_time}, \ + {self.arg})" + + def apply_sfx_on_arg(self, arg, param_arg_dict): + """ + Returns a copy of the special effect definition, but applied to an + argument. + + Args: + arg (Object): The argument that the special effect is applied to. + param_arg_dict (Dictionary[Object, Object]): A dictionary mapping + parameters to arguments. + + Returns: + CreationEffect: A copy of the special effect definition, but applied + to an argument. + """ + return DeletionEffect(param_arg_dict[self.param], self.effects, + self.completed, self.goal_time, + self.goal_repetitions, arg) + + def increment_time(self): + """ + Increments the time of the effect. + """ + self.current_time += 1 + + def increment_repetitions(self): + """ + Increments the number of repetitions of the effect. + """ + self.current_repetitions += 1 + + def update(self, state, active=False): + """ + Updates the state with the effect. + + Args: + state (State): The state to update. + active (bool): Whether or not the update is due to an action being + performed. + """ + if self.completed: + return + if self.goal_time > 0: + if active: return + self.increment_time() + if self.current_time == self.goal_time: + state.delete_obj(self.arg) + self.completed = True + elif self.goal_repetitions > 0: + if not active: return + self.increment_repetitions() + if self.current_repetitions == self.goal_repetitions: + state.delet_obj(self.arg) + self.completed = True \ No newline at end of file diff --git a/backend/state.py b/backend/state.py index b2055108a..dc2dc7a49 100644 --- a/backend/state.py +++ b/backend/state.py @@ -250,6 +250,18 @@ def add_object(self, obj): self.predicates = self._build_predicates(self.domain, self.objects, true_predicates) self.actions = self._build_actions(self.domain, self.objects) + def delete_obj(self, obj): + """ + Deletes an object from the state. + + Args: + obj (Object): The object to delete from the state. + """ + self.objects.remove(obj) + true_predicates = {predicate for predicate, value in self.predicates.items() if value} + self.predicates = self._build_predicates(self.domain, self.objects, true_predicates) + self.actions = self._build_actions(self.domain, self.objects) + def is_goal_reached(self): """ Returns whether the goal is satisfied in the current state. diff --git a/domain/domain_builder.py b/domain/domain_builder.py index cb6af667e..46533824b 100644 --- a/domain/domain_builder.py +++ b/domain/domain_builder.py @@ -5,6 +5,8 @@ from backend.special_effects.delayed_effect import DelayedEffect from backend.special_effects.repetitive_effect import RepetitiveEffect from backend.special_effects.conditional_effect import ConditionalEffect +from backend.special_effects.creation_effect import CreationEffect +from backend.special_effects.deletion_effect import DeletionEffect def _build_predicate_defs(input_json): @@ -83,17 +85,25 @@ def _build_special_effects(action, param_objs, predicate_dict): for special_effect in action["special_fx"]: param_name = special_effect["param"] param_obj = param_objs[param_name] - effects = _build_pred_list( - "fx", param_objs, special_effect, predicate_dict) - if special_effect["type"] == "delayed": - # TODO: The values for goal repetitions/time should be decided by the problem json - sfx = DelayedEffect(param_obj, effects, False) - elif special_effect["type"] == "repetitive": - sfx = RepetitiveEffect(param_obj, effects, False) - elif special_effect["type"] == "conditional": - conditions = _build_pred_list( - "conditions", param_objs, special_effect, predicate_dict) - sfx = ConditionalEffect(param_obj, effects, False, conditions) + if special_effect["type"] == "creation": + created_obj_name = special_effect["created_obj"]["name"] + created_obj_type = special_effect["created_obj"]["type"] + created_obj = Object(created_obj_name, created_obj_type) + sfx = CreationEffect(param_obj, False, created_obj) + elif special_effect["type"] == "deletion": + sfx = DeletionEffect(param_obj, False) + else: + effects = _build_pred_list( + "fx", param_objs, special_effect, predicate_dict) + if special_effect["type"] == "delayed": + # TODO: The values for goal repetitions/time should be decided by the problem json + sfx = DelayedEffect(param_obj, effects, False) + elif special_effect["type"] == "repetitive": + sfx = RepetitiveEffect(param_obj, effects, False) + elif special_effect["type"] == "conditional": + conditions = _build_pred_list( + "conditions", param_objs, special_effect, predicate_dict) + sfx = ConditionalEffect(param_obj, effects, False, conditions) special_effects.append(sfx) return special_effects diff --git a/domain/input.json b/domain/input.json index ca7cd13e6..ef3f36a01 100644 --- a/domain/input.json +++ b/domain/input.json @@ -8,14 +8,28 @@ } }, { - "name": "pick-up", + "name": "pick-up-item", "input_instructions": { "button": "left", "click_on": "s1" } }, { - "name": "place", + "name": "place-item", + "input_instructions": { + "button": "left", + "click_on": "s1" + } + }, + { + "name": "pick-up-container", + "input_instructions": { + "button": "left", + "click_on": "s1" + } + }, + { + "name": "place-container", "input_instructions": { "button": "left", "click_on": "s1" diff --git a/domain/robotouille.json b/domain/robotouille.json index a071b66c5..5bacb4e2d 100644 --- a/domain/robotouille.json +++ b/domain/robotouille.json @@ -111,6 +111,14 @@ "name": "issoup", "param_types": ["meal"] }, + { + "name": "iswater", + "param_types": ["meal"] + }, + { + "name": "isboilingwater", + "param_types": ["meal"] + }, { "name": "loc", "param_types": ["player", "station"] @@ -124,7 +132,7 @@ "param_types": ["player"] }, { - "name": "station_empty", + "name": "empty", "param_types": ["station"] }, { @@ -160,16 +168,8 @@ "param_types": ["item", "meal"] }, { - "name": "haswater", - "param_types": ["container"] - }, - { - "name": "container_on", + "name": "container_at", "param_types": ["container", "station"] - }, - { - "name": "container_empty", - "param_types": ["container"] } ], @@ -243,7 +243,7 @@ "is_true": true }, { - "predicate": "station_empty", + "predicate": "empty", "params": ["s1"], "is_true": true }, @@ -284,7 +284,7 @@ "is_true": true }, { - "predicate": "station_empty", + "predicate": "empty", "params": ["s1"], "is_true": true } @@ -316,7 +316,7 @@ "is_true": false }, { - "predicate": "station_empty", + "predicate": "empty", "params": ["s1"], "is_true": false } @@ -332,7 +332,7 @@ "is_true": true }, { - "predicate": "container_on", + "predicate": "container_at", "params": ["c1", "s1"], "is_true": true }, @@ -349,7 +349,7 @@ "is_true": true }, { - "predicate": "container_on", + "predicate": "container_at", "params": ["c1", "s1"], "is_true": false }, @@ -375,7 +375,7 @@ "is_true": true }, { - "predicate": "station_empty", + "predicate": "empty", "params": ["s1"], "is_true": true } @@ -387,7 +387,7 @@ "is_true": true }, { - "predicate": "container_on", + "predicate": "container_at", "params": ["c1", "s1"], "is_true": true }, @@ -397,7 +397,7 @@ "is_true": false }, { - "predicate": "station_empty", + "predicate": "empty", "params": ["s1"], "is_true": false } @@ -568,16 +568,6 @@ "params": ["c1"], "is_true": true }, - { - "predicate": "haswater", - "params": ["c1"], - "is_true": false - }, - { - "predicate": "container_empty", - "params": ["c1"], - "is_true": true - }, { "predicate": "issink", "params": ["s1"], @@ -589,26 +579,30 @@ "is_true": true }, { - "predicate": "container_on", + "predicate": "container_at", "params": ["c1", "s1"], "is_true": true } ], "immediate_fx":[], "special_fx": [ + { + "type": "creation", + "param": "c1", + "created_obj": { + "name": "water", + "type": "item", + "param": "m1" + } + }, { "type": "delayed", "param": "c1", "fx": [ { - "predicate": "haswater", - "params": ["c1"], + "predicate": "in", + "params": ["m1", "c1"], "is_true": true - }, - { - "predicate": "container_empty", - "params": ["c1"], - "is_true": false } ] } @@ -623,8 +617,13 @@ "is_true": true }, { - "predicate": "haswater", - "params": ["c1"], + "predicate": "iswater", + "params": ["m1"], + "is_true": true + }, + { + "predicate": "in", + "params": ["m1", "c1"], "is_true": true }, { @@ -633,7 +632,7 @@ "is_true": true }, { - "predicate": "container_on", + "predicate": "container_at", "params": ["c1", "s1"], "is_true": true }, @@ -645,7 +644,18 @@ ], "immediate_fx": [], "special_fx": [ - {} + { + "type": "creation", + "param": "c1", + "created_obj": { + "name": "boiling-water", + "type": "meal" + } + }, + { + "type": "deletion", + "param": "m1" + } ] }, { diff --git a/environments/env_generator/builder.py b/environments/env_generator/builder.py index 0ded084c8..f19c80ea9 100644 --- a/environments/env_generator/builder.py +++ b/environments/env_generator/builder.py @@ -3,7 +3,7 @@ import os import copy import itertools -from .object_enums import Item, Player, Station, str_to_typed_enum +from .object_enums import Item, Player, Station, Container, Meal, str_to_typed_enum from .procedural_generator import randomize_environment import random @@ -13,8 +13,10 @@ STATION_FIELD = "stations" ITEM_FIELD = "items" PLAYER_FIELD = "players" +MEAL_FIELD = "meals" +CONTAINER_FIELD = "containers" -ENTITY_FIELDS = [STATION_FIELD, ITEM_FIELD, PLAYER_FIELD] +ENTITY_FIELDS = [STATION_FIELD, ITEM_FIELD, PLAYER_FIELD, CONTAINER_FIELD, MEAL_FIELD] def entity_to_entity_field(entity): """ @@ -40,11 +42,15 @@ def entity_to_entity_field(entity): if isinstance(typed_enum, Station): return STATION_FIELD elif isinstance(typed_enum, Item): return ITEM_FIELD elif isinstance(typed_enum, Player): return PLAYER_FIELD + elif isinstance(typed_enum, Meal): return MEAL_FIELD + elif isinstance(typed_enum, Container): return CONTAINER_FIELD except ValueError: # Convert wild card entities into entity fields if entity == STATION_FIELD[:-1]: return STATION_FIELD elif entity == ITEM_FIELD[:-1]: return ITEM_FIELD elif entity == PLAYER_FIELD[:-1]: return PLAYER_FIELD + elif entity == MEAL_FIELD[:-1]: return MEAL_FIELD + elif entity == CONTAINER_FIELD[:-1]: return CONTAINER_FIELD raise ValueError(f"Cannot convert {entity} into an entity field.") def load_environment(json_filename, seed=None): @@ -77,6 +83,14 @@ def load_environment(json_filename, seed=None): if item["name"] == "item": item["name"] = random.choice(list(Item)).value environment_json["players"].sort(key=sorting_key) + for container in environment_json["containers"]: + if container["name"] == "container": + container["name"] = random.choice(list(Container)).value + environment_json["containers"].sort(key=sorting_key) + for meal in environment_json["meals"]: + if meal["name"] == "meal": + meal["name"] = random.choice(list(Meal)).value + environment_json["meals"].sort(key=sorting_key) return environment_json def build_objects(environment_dict): @@ -396,19 +410,19 @@ def build_problem(environment_dict): new_environment_dict (dict): Dictionary containing IDed stations, items, and player location. """ problem = "(define (problem robotouille)\n" - problem += "(:domain robotouille)\n" - problem += "(:objects\n" + # problem += "(:domain robotouille)\n" + # problem += "(:objects\n" objects_str, new_environment_dict = build_objects(environment_dict) - problem += objects_str - problem += ")\n" - problem += "(:init\n" - problem += build_identity_predicates(new_environment_dict) - problem += build_location_predicates(new_environment_dict) - problem += build_stacking_predicates(new_environment_dict) - problem += ")\n" - problem += "(:goal\n" - problem += build_goal(new_environment_dict) - problem += ")\n" + # problem += objects_str + # problem += ")\n" + # problem += "(:init\n" + # problem += build_identity_predicates(new_environment_dict) + # problem += build_location_predicates(new_environment_dict) + # problem += build_stacking_predicates(new_environment_dict) + # problem += ")\n" + # problem += "(:goal\n" + # problem += build_goal(new_environment_dict) + # problem += ")\n" return problem, new_environment_dict def write_problem_file(problem, filename): diff --git a/environments/env_generator/examples/base_fill_water.json b/environments/env_generator/examples/base_fill_water.json new file mode 100644 index 000000000..461c5033a --- /dev/null +++ b/environments/env_generator/examples/base_fill_water.json @@ -0,0 +1,47 @@ +{ + "width": 3, + "height": 3, + "config": { + "num_cuts": { + "lettuce": 3, + "default": 3 + }, + "cook_time": { + "patty": 3, + "default": 3 + } + }, + "stations": [ + { + "name": "sink", + "x": 0, + "y": 1, + "id": "A" + } + ], + "items": [], + "players": [ + { + "name": "robot", + "x": 0, + "y": 0, + "direction": [0, 1] + } + ], + "meals": [], + "containers": [ + { + "name": "pot", + "x": 0, + "y": 1, + "id": "a", + "meal_id": [] + } + ], + "goal": [ + { + "predicate": "in", + "args": ["water", "pot"] + } + ] +} \ No newline at end of file diff --git a/environments/env_generator/examples/base_pickup.json b/environments/env_generator/examples/base_pickup.json index 701b0fbe4..b9cb46c17 100644 --- a/environments/env_generator/examples/base_pickup.json +++ b/environments/env_generator/examples/base_pickup.json @@ -36,9 +36,10 @@ "direction": [0, 1] } ], + "containers": [], "goal": [ { - "predicate": "has", + "predicate": "has_item", "args": ["robot", "item"], "ids": [1, "a"] } diff --git a/environments/env_generator/examples/base_pickup_container.json b/environments/env_generator/examples/base_pickup_container.json new file mode 100644 index 000000000..6c6ed5b30 --- /dev/null +++ b/environments/env_generator/examples/base_pickup_container.json @@ -0,0 +1,48 @@ +{ + "width": 3, + "height": 3, + "config": { + "num_cuts": { + "lettuce": 3, + "default": 3 + }, + "cook_time": { + "patty": 3, + "default": 3 + } + }, + "stations": [ + { + "name": "station", + "x": 0, + "y": 1, + "id": "A" + } + ], + "items": [], + "players": [ + { + "name": "robot", + "x": 0, + "y": 0, + "direction": [0, 1] + } + ], + "meals": [], + "containers": [ + { + "name": "container", + "x": 0, + "y": 1, + "id": "a", + "meal_id": [] + } + ], + "goal": [ + { + "predicate": "has_container", + "args": ["robot", "container"], + "ids": [1, "a"] + } + ] +} \ No newline at end of file diff --git a/environments/env_generator/examples/base_place.json b/environments/env_generator/examples/base_place.json index f171d4198..0f991ead8 100644 --- a/environments/env_generator/examples/base_place.json +++ b/environments/env_generator/examples/base_place.json @@ -36,9 +36,11 @@ "direction": [0, 1] } ], + "meals": [], + "containers": [], "goal": [ { - "predicate": "on", + "predicate": "item_on", "args": ["item", "station"], "ids": ["a", "A"] } diff --git a/environments/env_generator/examples/base_place_container.json b/environments/env_generator/examples/base_place_container.json new file mode 100644 index 000000000..b178f8260 --- /dev/null +++ b/environments/env_generator/examples/base_place_container.json @@ -0,0 +1,48 @@ +{ + "width": 3, + "height": 3, + "config": { + "num_cuts": { + "lettuce": 3, + "default": 3 + }, + "cook_time": { + "patty": 3, + "default": 3 + } + }, + "stations": [ + { + "name": "station", + "x": 0, + "y": 1, + "id": "A" + } + ], + "items": [], + "players": [ + { + "name": "robot", + "x": 0, + "y": 0, + "direction": [0, 1] + } + ], + "meals": [], + "containers": [ + { + "name": "container", + "x": 0, + "y": 0, + "id": "a", + "meal_id": [] + } + ], + "goal": [ + { + "predicate": "container_at", + "args": ["container", "station"], + "ids": ["a", "A"] + } + ] +} \ No newline at end of file diff --git a/environments/env_generator/examples/original.json b/environments/env_generator/examples/original.json index 7c6a277f8..caa6cf90c 100644 --- a/environments/env_generator/examples/original.json +++ b/environments/env_generator/examples/original.json @@ -79,6 +79,7 @@ "direction": [0, 1] } ], + "containers": [], "goal_description": "Make a lettuce burger with lettuce on top of the patty.", "goal": [ { diff --git a/environments/env_generator/object_enums.py b/environments/env_generator/object_enums.py index 13b67d3f7..8d183b7ad 100644 --- a/environments/env_generator/object_enums.py +++ b/environments/env_generator/object_enums.py @@ -20,6 +20,16 @@ class Station(Enum): STOVE = "stove" TABLE = "table" FRYER = "fryer" + SINK = "sink" + +class Container(Enum): + POT = "pot" + BOWL = "bowl" + +class Meal(Enum): + WATER = "water" + BOILING_WATER = "boiling_water" + SOUP = "soup" def str_to_typed_enum(s): """ @@ -34,7 +44,7 @@ def str_to_typed_enum(s): Returns: typed_enum (Enum): Enum of the string. """ - for typed_enum in [Item, Player, Station]: + for typed_enum in [Item, Player, Station, Container, Meal]: try: return typed_enum(s) except ValueError: diff --git a/renderer/canvas.py b/renderer/canvas.py index 5c4353645..e1248f37b 100644 --- a/renderer/canvas.py +++ b/renderer/canvas.py @@ -134,6 +134,57 @@ def _draw_item_image(self, surface, item_name, obs, position): self._draw_image(surface, f"{item_image_name}", position + self.pix_square_size * x_scale_factor, self.pix_square_size * y_scale_factor) + def _choose_container_asset(self, container_image_name, obs): + """ + Helper function to choose the container asset based on the current + true predicates in the state. + + Meals can only be in containers, and cannot be drawn on their own. In the + state, the predicate "in" determines whether a meal is in a container. + Only one meal can be in each container at any point in time, so this + helper function chooses the container asset based on the current + state of the meal. + + Args: + container_image_name (str): Name of the container + obs (List[Literal]): Game state predicates + + Returns: + chosen_asset (str): Name of the chosen asset + """ + container_image_name, container_id = trim_item_ID(container_image_name) + container_config = self.config["container"]["entities"][container_image_name] + + # Get the name of the meal in the container + meal_name = None + for literal, is_true in obs.predicates.items(): + if is_true and literal.name == "in" and literal.params[1].name == container_image_name + container_id: + meal_name = literal.params[0].name + break + + # If there is no meal in the container, use the default asset + if meal_name is None: + return container_config["assets"]["default"] + + # If there is a meal in the container, choose the asset based on the meal + return container_config["assets"][meal_name] + + def _draw_container_image(self, surface, container_name, obs, position): + """ + Helper to draw a container image on the canvas. + + Args: + surface (pygame.Surface): Surface to draw on + container_name (str): Name of the container + obs (List[Literal]): Game state predicates + position (np.array): (x, y) position of the container (with pix_square_size factor accounted for) + """ + container_image_name = self._choose_container_asset(container_name, obs) + x_scale_factor = self.config["container"]["constants"]["X_SCALE_FACTOR"] + y_scale_factor = self.config["container"]["constants"]["Y_SCALE_FACTOR"] + + self._draw_image(surface, f"{container_image_name}", position + self.pix_square_size * x_scale_factor, self.pix_square_size * y_scale_factor) + def _draw_floor(self, surface): """ Draw the floor on the canvas. @@ -275,7 +326,7 @@ def _draw_player(self, surface, obs): #player_pos = pos robot_image_name = self._get_player_image_name(player_direction) self._draw_image(surface, robot_image_name, player_pos * self.pix_square_size, self.pix_square_size) - if is_true and literal.name == "has": + if is_true and literal.name == "has_item": player_pos = self.player_pose["position"] held_item_name = literal.params[1].name if held_item_name: @@ -297,7 +348,7 @@ def _draw_item(self, surface, obs): stack_number = {} # Stores the item item and current stack number station_item_offset = self.config["item"]["constants"]["STATION_ITEM_OFFSET"] for literal, is_true in obs.predicates.items(): - if is_true and literal.name == "on": + if is_true and literal.name == "item_on": item = literal.params[0].name stack_number[item] = 1 item_station = literal.params[1].name @@ -319,7 +370,7 @@ def _draw_item(self, surface, obs): stack_number[item_above] = stack_number[item_below] + 1 # Get location of station for literal, is_true in obs.predicates.items(): - if is_true and literal.name == "at" and literal.params[0].name == item_below: + if is_true and literal.name == "item_at" and literal.params[0].name == item_below: station_pos = self._get_station_position(literal.params[1].name) break item_name, _ = trim_item_ID(item_above) @@ -330,6 +381,25 @@ def _draw_item(self, surface, obs): else: i += 1 + def _draw_container(self, surface, obs): + """ + This helper draws containers on the canvas. + + Args: + surface (pygame.Surface): Surface to draw on + obs (State): Game state predicates + """ + for literal, is_true in obs.predicates.items(): + if is_true and literal.name == "container_at": + container = literal.params[0].name + station = literal.params[1].name + container_pos = self._get_station_position(station) + self._draw_container_image(surface, container, obs, container_pos * self.pix_square_size) + if is_true and literal.name == "has_container": + container = literal.params[1].name + container_pos = self.player_pose["position"] + self._draw_container_image(surface, container, obs, container_pos * self.pix_square_size) + def draw_to_surface(self, surface, obs): """ Draws the game state to the surface. @@ -342,3 +412,4 @@ def draw_to_surface(self, surface, obs): self._draw_stations(surface) self._draw_player(surface, obs) self._draw_item(surface, obs) + self._draw_container(surface, obs) diff --git a/renderer/configuration/robotouille_config.json b/renderer/configuration/robotouille_config.json index 8d86b1f2f..91791c5c0 100644 --- a/renderer/configuration/robotouille_config.json +++ b/renderer/configuration/robotouille_config.json @@ -121,6 +121,7 @@ } } }, + "station": { "constants": {}, "entities": { @@ -149,5 +150,45 @@ "constants": {} } } + }, + + "container": { + "constants": { + "STATION_ITEM_OFFSET" : 0.25, + "X_SCALE_FACTOR": 0.125, + "Y_SCALE_FACTOR": 0.75 + }, + "entities": { + "pot": { + "assets": { + "default": "pot.png", + "water": { + "asset": "pot_water.png", + "meal": "water" + }, + "boiling_water": { + "asset": "pot_water_boil.png", + "meal": "boiling_water" + }, + "soup": { + "asset": "pot_soup.png", + "meal": "soup" + } + }, + "constants": {} + }, + "bowl": { + "assets": { + "default": "bowl.png", + "soup": { + "asset": "bowl_soup.png", + "meal": "soup" + } + }, + "constants": {} + } + } } + + } \ No newline at end of file diff --git a/robotouille/env.py b/robotouille/env.py index f6afe3726..10454e1d6 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -52,10 +52,10 @@ def build_station_location_predicates(environment_dict): predicates = [] for station in environment_dict["stations"]: station_obj = Object(station["name"], "station") - for field in ["items", "players"]: + for field in ["items", "players", "containers"]: match = False no_match_predicate = "empty" if field == "items" else "vacant" - predicate = "at" if field == "items" else "loc" + predicate = "item_at" if field == "items" else "container_at" if field == "containers" else "loc" for entity in environment_dict[field]: x = entity["x"] + entity["direction"][0] if field == "players" else entity["x"] y = entity["y"] + entity["direction"][1] if field == "players" else entity["y"] @@ -90,10 +90,18 @@ def build_player_location_predicates(environment_dict): for item in environment_dict["items"]: if player["x"] == item["x"] and player["y"] == item["y"]: obj = Object(item["name"], "item") - pred = Predicate().initialize("has", ["player", "item"], [player_obj, obj]) + pred = Predicate().initialize("has_item", ["player", "item"], [player_obj, obj]) predicates.append(pred) match = True break + if not match: + for container in environment_dict["containers"]: + if player["x"] == container["x"] and player["y"] == container["y"]: + obj = Object(container["name"], "container") + pred = Predicate().initialize("has_container", ["player", "container"], [player_obj, obj]) + predicates.append(pred) + match = True + break if not match: pred = Predicate().initialize("nothing", ["player"], [player_obj]) predicates.append(pred) @@ -147,7 +155,7 @@ def build_stacking_predicates(environment_dict): for station_name, items in stacks.items(): station_obj = Object(station_name, "station") first_item_obj = Object(items[0]["name"], "item") - pred = Predicate().initialize("on", ["item", "station"], [first_item_obj, station_obj]) + pred = Predicate().initialize("item_on", ["item", "station"], [first_item_obj, station_obj]) stacking_predicates.append(pred) for i in range(1, len(items)): obj = Object(items[i]["name"], "item") @@ -274,6 +282,8 @@ def __init__(self, domain_json, environment_json, render_fn, render_mode=None, s self.initial_state = initial_state + print(initial_state.predicates) + self.observation_space = initial_state self.action_space = initial_state.domain.actions From c25ce3605e250713d3b9fd70b375ef684d35aa9e Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Wed, 20 Mar 2024 15:05:44 -0400 Subject: [PATCH 04/41] implemented fill water --- backend/special_effect.py | 7 +- backend/special_effects/conditional_effect.py | 26 ++++-- backend/special_effects/creation_effect.py | 93 +++++++------------ backend/special_effects/delayed_effect.py | 26 ++++-- backend/special_effects/deletion_effect.py | 90 +++++------------- backend/special_effects/repetitive_effect.py | 27 ++++-- backend/state.py | 5 + domain/domain_builder.py | 41 ++++---- domain/input.json | 7 ++ domain/robotouille.json | 91 +++++++++--------- .../examples/base_fill_water.json | 5 +- renderer/canvas.py | 2 +- robotouille/env.py | 12 ++- 13 files changed, 204 insertions(+), 228 deletions(-) diff --git a/backend/special_effect.py b/backend/special_effect.py index 77099f151..729a1357a 100644 --- a/backend/special_effect.py +++ b/backend/special_effect.py @@ -12,14 +12,16 @@ class SpecialEffect(object): should be inherited by one of its subclasses. """ - def __init__(self, param, effects, completed, arg): + def __init__(self, param, effects, special_effects, completed, arg): """ Initializes a special effect. Args: param (Object): The parameter of the special effect. effects (Dictionary[Predicate, bool]): The effects of the action, - represented by a dictionary of predicates and bools. + represented by a dictionary of predicates and bools. + special_effects (List[SpecialEffect]): The nested special effects of + the action. completed (bool): Whether or not the effect has been completed. arg (Object): The object that the effect is applied to. If the special effect is not applied to an object, arg is None. @@ -27,6 +29,7 @@ def __init__(self, param, effects, completed, arg): self.arg = arg self.param = param self.effects = effects + self.special_effects = special_effects self.completed = completed def update(self, state, active=False): diff --git a/backend/special_effects/conditional_effect.py b/backend/special_effects/conditional_effect.py index 005653c10..57d02810e 100644 --- a/backend/special_effects/conditional_effect.py +++ b/backend/special_effects/conditional_effect.py @@ -14,21 +14,22 @@ class ConditionalEffect(SpecialEffect): - "isfryable" """ - def __init__(self, param, effects, completed, conditions, arg=None): + def __init__(self, param, effects, special_effects, conditions, arg=None): """ Initializes a conditional effect. Args: param (Object): The parameter of the special effect. effects (Dictionary[Predicate, bool]): The effects of the action, - represented by a dictionary of predicates and bools. - completed (bool): Whether or not the effect has been completed. + represented by a dictionary of predicates and bools. + special_effects (List[SpecialEffect]): The nested special effects of + the action. conditions (Dictionary[Predicate, bool]): The conditions of the - effect, represented by a dictionary of predicates and bools. + effect, represented by a dictionary of predicates and bools. arg (Object): The object that the effect is applied to. If the special effect is not applied to an object, arg is None. """ - super().__init__(param, effects, completed, arg) + super().__init__(param, effects, special_effects, False, arg) self.condition = conditions def __eq__(self, other): @@ -42,8 +43,8 @@ def __eq__(self, other): bool: True if the effects are equal, False otherwise. """ return self.param == other.param and self.effects == other.effects \ - and self.condition == other.condition \ - and self.arg == other.arg + and self.special_effects == other.special_effects \ + and self.condition == other.condition and self.arg == other.arg def __hash__(self): """ @@ -52,8 +53,8 @@ def __hash__(self): Returns: hash (int): The hash of the conditional effect. """ - return hash((self.param, tuple(self.effects), self.completed, - tuple(self.condition), self.arg)) + return hash((self.param, tuple(self.effects), tuple(self.special_effects), + tuple(self.condition), self.completed, self.arg)) def __repr__(self): """ @@ -79,10 +80,13 @@ def apply_sfx_on_arg(self, arg, param_arg_dict): new_effects = {} for effect, value in self.effects.items(): new_effects[effect.replace_pred_params_with_args(param_arg_dict)] = value + new_special_effects = [] + for special_effect in self.special_effects: + new_special_effects.append(special_effect.apply_sfx_on_arg(arg, param_arg_dict)) new_conditions = {} for condition, value in self.condition.items(): new_conditions[condition.replace_pred_params_with_args(param_arg_dict)] = value - return ConditionalEffect(self.param, new_effects, self.completed, new_conditions, arg) + return ConditionalEffect(self.param, new_effects, new_special_effects, self.completed, new_conditions, arg) def update(self, state, active=False): """ @@ -104,4 +108,6 @@ def update(self, state, active=False): return for effect, value in self.effects.items(): state.update_predicate(effect, value) + for special_effect in self.special_effects: + special_effect.update(state, active) self.completed = True \ No newline at end of file diff --git a/backend/special_effects/creation_effect.py b/backend/special_effects/creation_effect.py index a97f6cad4..2cc8550d1 100644 --- a/backend/special_effects/creation_effect.py +++ b/backend/special_effects/creation_effect.py @@ -5,34 +5,28 @@ class CreationEffect(SpecialEffect): This class represents creation effects in Robotouille. A creation effect is an effect that creates a new object in the state. - It can be immediate, or require a delay or repeated actions. """ - def __init__(self, param, completed, created_obj, goal_time=4, goal_repetitions=0, arg=None): + def __init__(self, param, created_obj, effects, special_effects, arg=None): """ Initializes a creation effect. Args: - param (Object): The parameter of the special effect. - completed (bool): Whether or not the effect has been completed. - created_obj (Object): The object that the effect creates. - goal_time (int): The number of time steps that must pass before the - effect is applied. - goal_repetitions (int): The number of times the effect must be - repeated before it is applied. + param (Object): The parameter of the creation effect. This is the + object that the action is performed on, not the object that is + created. + created_obj (Tuple[Str, Object]): A tuple representing the object + that is created. The first element is the param name of the + object, and the second element is the object itself. + effects (Dictionary[Predicate, bool]): The effects of the action, + represented by a dictionary of predicates and bools. + special_effects (List[SpecialEffect]): The nested special effects of + the action. arg (Object): The object that the effect is applied to. If the special effect is not applied to an object, arg is None. - - Requires: - goal_time == 0 if goal_repetitions > 0, - and goal_repetitions == 0 if goal_time > 0. """ - super().__init__(param, {}, completed, arg) + super().__init__(param, effects, special_effects, False, arg) self.created_obj = created_obj - self.goal_time = goal_time - self.current_time = 0 - self.goal_repetitions = goal_repetitions - self.current_repetitions = 0 def __eq__(self, other): """ @@ -44,11 +38,11 @@ def __eq__(self, other): Returns: bool: True if the effects are equal, False otherwise. """ - return self.param == other.param and self.effects == other.effects \ - and self.created_obj == other.created_obj and \ - self.goal_time == other.goal_time\ - and self.goal_repetitions == other.goal_repetitions and \ - self.arg == other.arg + return self.param == other.param and \ + self.effects == other.effects and \ + self.special_effects == other.special_effects and \ + self.created_obj == other.created_obj and \ + self.arg == other.arg def __hash__(self): """ @@ -57,9 +51,8 @@ def __hash__(self): Returns: hash (int): The hash of the creation effect. """ - return hash((self.param, tuple(self.effects), self.completed, - self.created_obj, self.goal_time, self.goal_repetitions, - self.arg)) + return hash((self.param, tuple(self.effects), tuple(self.special_effects), + self.completed, self.created_obj, self.arg)) def __repr__(self): """ @@ -68,9 +61,7 @@ def __repr__(self): Returns: string (str): The string representation of the creation effect. """ - return f"CreationEffect({self.param}, {self.completed}, \ - {self.current_repetitions}, {self.current_time}, {self.created_obj}, \ - {self.arg})" + return f"CreationEffect({self.param}, {self.completed}, {self.created_obj}, {self.arg})" def apply_sfx_on_arg(self, arg, param_arg_dict): """ @@ -80,27 +71,21 @@ def apply_sfx_on_arg(self, arg, param_arg_dict): Args: arg (Object): The argument that the special effect is applied to. param_arg_dict (Dictionary[Object, Object]): A dictionary mapping - parameters to arguments. + parameters to arguments. Since the creation effect does not have + any immediate effects, this dictionary is not used. Returns: CreationEffect: A copy of the special effect definition, but applied to an argument. """ - return CreationEffect(param_arg_dict[self.param], self.effects, - self.completed, self.created_obj, self.goal_time, - self.goal_repetitions, arg) - - def increment_time(self): - """ - Increments the time of the effect. - """ - self.current_time += 1 - - def increment_repetitions(self): - """ - Increments the number of repetitions of the effect. - """ - self.current_repetitions += 1 + param_arg_dict[self.created_obj[0]] = self.created_obj[1] + new_effects = {} + for effect, value in self.effects.items(): + new_effects[effect.replace_pred_params_with_args(param_arg_dict)] = value + new_special_effects = [] + for special_effect in self.special_effects: + new_special_effects.append(special_effect.apply_sfx_on_arg(arg, param_arg_dict)) + return CreationEffect(self.param, self.created_obj, new_effects, new_special_effects, arg) def update(self, state, active=False): """ @@ -113,15 +98,9 @@ def update(self, state, active=False): """ if self.completed: return - if self.goal_time > 0: - if active: return - self.increment_time() - if self.current_time == self.goal_time: - state.add_object(self.created_obj) - self.completed = True - elif self.goal_repetitions > 0: - if not active: return - self.increment_repetitions() - if self.current_repetitions == self.goal_repetitions: - state.add_object(self.created_obj) - self.completed = True \ No newline at end of file + state.add_object(self.created_obj[1]) + for effect, value in self.effects.items(): + state.update_predicate(effect, value) + for special_effect in self.special_effects: + special_effect.update(state, active) + self.completed = True \ No newline at end of file diff --git a/backend/special_effects/delayed_effect.py b/backend/special_effects/delayed_effect.py index 327fdc19a..034be334e 100644 --- a/backend/special_effects/delayed_effect.py +++ b/backend/special_effects/delayed_effect.py @@ -8,21 +8,22 @@ class DelayedEffect(SpecialEffect): of time has passed. """ - def __init__(self, param, effects, completed, goal_time=4, arg=None): + def __init__(self, param, effects, special_effects, goal_time=4, arg=None): """ Initializes a delayed effect. Args: param (Object): The parameter of the special effect. effects (Dictionary[Predicate, bool]): The effects of the action, - represented by a dictionary of predicates and bools. - completed (bool): Whether or not the effect has been completed. + represented by a dictionary of predicates and bools. + special_effects (List[SpecialEffect]): The nested special effects of + the action. goal_time (int): The number of time steps that must pass before the - effect is applied. + effect is applied. arg (Object): The object that the effect is applied to. If the special effect is not applied to an object, arg is None. """ - super().__init__(param, effects, completed, arg) + super().__init__(param, effects, special_effects, False, arg) self.goal_time = goal_time self.current_time = 0 @@ -37,8 +38,8 @@ def __eq__(self, other): bool: True if the effects are equal, False otherwise. """ return self.param == other.param and self.effects == other.effects \ - and self.goal_time == other.goal_time\ - and self.arg == other.arg + and self.special_effects == other.special_effects \ + and self.goal_time == other.goal_time and self.arg == other.arg def __hash__(self): """ @@ -47,8 +48,8 @@ def __hash__(self): Returns: hash (int): The hash of the delayed effect. """ - return hash((self.param, tuple(self.effects), self.completed, - self.goal_time, self.arg)) + return hash((self.param, tuple(self.effects), tuple(self.special_effects), + self.completed, self.current_time, self.arg)) def __repr__(self): """ @@ -76,7 +77,10 @@ def apply_sfx_on_arg(self, arg, param_arg_dict): new_effects = {} for effect, value in self.effects.items(): new_effects[effect.replace_pred_params_with_args(param_arg_dict)] = value - return DelayedEffect(self.param, new_effects, self.completed, self.goal_time, arg) + new_special_effects = [] + for special_effect in self.special_effects: + new_special_effects.append(special_effect.apply_sfx_on_arg(arg, param_arg_dict)) + return DelayedEffect(self.param, new_effects, new_special_effects, self.goal_time, arg) def increment_time(self): """ @@ -98,4 +102,6 @@ def update(self, state, active=False): if self.current_time == self.goal_time: for effect, value in self.effects.items(): state.update_predicate(effect, value) + for special_effect in self.special_effects: + special_effect.update(state) self.completed = True \ No newline at end of file diff --git a/backend/special_effects/deletion_effect.py b/backend/special_effects/deletion_effect.py index 376c6df1f..c7d54e5c4 100644 --- a/backend/special_effects/deletion_effect.py +++ b/backend/special_effects/deletion_effect.py @@ -5,69 +5,48 @@ class DeletionEffect(SpecialEffect): This class represents deletion effects in Robotouille. A creation effect is an effect that delets an object in the state. - It can be immediate, or require a delay or repeated actions. """ - def __init__(self, param, completed, goal_time=4, goal_repetitions=0, arg=None): + def __init__(self, param, arg=None): """ Initializes a deletion effect. Args: - param (Object): The parameter of the object to be deleted. - completed (bool): Whether or not the effect has been completed. - goal_time (int): The number of time steps that must pass before the - effect is applied. - goal_repetitions (int): The number of times the effect must be - repeated before it is applied. - arg (Object): The object that the effect is applied to. If the + param (Object): The parameter of the deletion effect. + arg (Object): The object that deleted. If the special effect is not applied to an object, arg is None. - - Requires: - goal_time == 0 if goal_repetitions > 0, - and goal_repetitions == 0 if goal_time > 0. """ - super().__init__(param, {}, completed, arg) - self.goal_time = goal_time - self.current_time = 0 - self.goal_repetitions = goal_repetitions - self.current_repetitions = 0 - + super().__init__(param, {}, [], False, arg) + def __eq__(self, other): """ - Checks if two creation effects are equal. + Checks if two deletion effects are equal. Args: - other (CreationEffect): The creation effect to compare to. + other (DeletionEffect): The deletion effect to compare to. Returns: bool: True if the effects are equal, False otherwise. """ - return self.param == other.param and self.effects == other.effects \ - and self.goal_time == other.goal_time\ - and self.goal_repetitions == other.goal_repetitions and \ - self.arg == other.arg - + return self.param == other.param and self.arg == other.arg + def __hash__(self): """ - Returns the hash of the creation effect. + Returns the hash of the deletion effect. Returns: - hash (int): The hash of the creation effect. + hash (int): The hash of the deletion effect. """ - return hash((self.param, tuple(self.effects), self.completed, - self.goal_time, self.goal_repetitions, - self.arg)) + return hash((self.param, self.completed, self.arg)) def __repr__(self): """ - Returns the string representation of the creation effect. + Returns the string representation of the deletion effect. Returns: - string (str): The string representation of the creation effect. + string (str): The string representation of the deletion effect. """ - return f"CreationEffect({self.param}, {self.completed}, \ - {self.current_repetitions}, {self.current_time}, \ - {self.arg})" + return f"DeletionEffect({self.param}, {self.completed}, {self.arg})" def apply_sfx_on_arg(self, arg, param_arg_dict): """ @@ -75,29 +54,16 @@ def apply_sfx_on_arg(self, arg, param_arg_dict): argument. Args: - arg (Object): The argument that the special effect is applied to. + arg (Object): The object to apply the special effect to. param_arg_dict (Dictionary[Object, Object]): A dictionary mapping - parameters to arguments. + parameters to arguments. Since the creation effect does not have + any immediate effects, this dictionary is not used. Returns: - CreationEffect: A copy of the special effect definition, but applied + DeletionEffect: A copy of the special effect definition, but applied to an argument. """ - return DeletionEffect(param_arg_dict[self.param], self.effects, - self.completed, self.goal_time, - self.goal_repetitions, arg) - - def increment_time(self): - """ - Increments the time of the effect. - """ - self.current_time += 1 - - def increment_repetitions(self): - """ - Increments the number of repetitions of the effect. - """ - self.current_repetitions += 1 + return DeletionEffect(self.param, arg) def update(self, state, active=False): """ @@ -106,19 +72,9 @@ def update(self, state, active=False): Args: state (State): The state to update. active (bool): Whether or not the update is due to an action being - performed. + performed. """ if self.completed: return - if self.goal_time > 0: - if active: return - self.increment_time() - if self.current_time == self.goal_time: - state.delete_obj(self.arg) - self.completed = True - elif self.goal_repetitions > 0: - if not active: return - self.increment_repetitions() - if self.current_repetitions == self.goal_repetitions: - state.delet_obj(self.arg) - self.completed = True \ No newline at end of file + state.remove_object(self.arg, active) + self.completed = True \ No newline at end of file diff --git a/backend/special_effects/repetitive_effect.py b/backend/special_effects/repetitive_effect.py index 00beb9379..63659d25b 100644 --- a/backend/special_effects/repetitive_effect.py +++ b/backend/special_effects/repetitive_effect.py @@ -8,21 +8,22 @@ class RepetitiveEffect(SpecialEffect): been performed a certain number of times. """ - def __init__(self, param, effects, completed, goal_repetitions=3, arg=None): + def __init__(self, param, effects, special_effects, goal_repetitions=3, arg=None): """ Initializes a repetitive effect. Args: param (Object): The parameter of the special effect. effects (Dictionary[Predicate, bool]): The effects of the action, - represented by a dictionary of predicates and bools. - completed (bool): Whether or not the effect has been completed. + represented by a dictionary of predicates and bools. + special_effects (List[SpecialEffect]): The nested special effects of + the action. goal_repetitions (int): The number of times the action must be - performed before the effect is applied. + performed before the effect is applied. arg (Object): The object that the effect is applied to. If the special effect is not applied to an object, arg is None. """ - super().__init__(param, effects, completed, arg) + super().__init__(param, effects, special_effects, False, arg) self.goal_repetitions = goal_repetitions self.current_repetitions = 0 @@ -37,8 +38,9 @@ def __eq__(self, other): bool: True if the effects are equal, False otherwise. """ return self.param == other.param and self.effects == other.effects \ - and self.goal_repetitions == other.goal_repetitions \ - and self.arg == other.arg + and self.special_effects == other.special_effects \ + and self.goal_repetitions == other.goal_repetitions \ + and self.arg == other.arg def __hash__(self): """ @@ -47,8 +49,8 @@ def __hash__(self): Returns: hash (int): The hash of the repetitive effect. """ - return hash((self.param, tuple(self.effects), self.completed, - self.goal_repetitions, self.arg)) + return hash((self.param, tuple(self.effects), tuple(self.special_effects), + self.completed, self.current_repetitions, self.arg)) def __repr__(self): """ @@ -76,7 +78,10 @@ def apply_sfx_on_arg(self, arg, param_arg_dict): new_effects = {} for effect, value in self.effects.items(): new_effects[effect.replace_pred_params_with_args(param_arg_dict)] = value - return RepetitiveEffect(self.param, new_effects, self.completed, self.goal_repetitions, arg) + new_special_effects = [] + for special_effect in self.special_effects: + new_special_effects.append(special_effect.apply_sfx_on_arg(arg, param_arg_dict)) + return RepetitiveEffect(self.param, new_effects, new_special_effects, self.completed, self.goal_repetitions, arg) def increment_repetitions(self): """ @@ -98,4 +103,6 @@ def update(self, state, active=False): if self.current_repetitions == self.goal_repetitions: for effect, value in self.effects.items(): state.update_predicate(effect, value) + for special_effect in self.special_effects: + special_effect.update(state, active) self.completed = True \ No newline at end of file diff --git a/backend/state.py b/backend/state.py index dc2dc7a49..01c038448 100644 --- a/backend/state.py +++ b/backend/state.py @@ -319,7 +319,12 @@ def step(self, action, param_arg_dict): the given state. """ assert action.is_valid(self, param_arg_dict) + + print(f"before: {self.predicates} \n") + self = action.perform_action(self, param_arg_dict) + + print(f"after: {self.predicates} \n") for special_effect in self.special_effects: special_effect.update(self) diff --git a/domain/domain_builder.py b/domain/domain_builder.py index 46533824b..1fd366f20 100644 --- a/domain/domain_builder.py +++ b/domain/domain_builder.py @@ -64,12 +64,12 @@ def _build_pred_list(key, param_objs, dict, predicate_dict): return precons_or_effects -def _build_special_effects(action, param_objs, predicate_dict): +def _build_special_effects(dict, param_objs, predicate_dict): """ - This function builds the special effects of an action. + This function builds special effects. Args: - action (Dictionary): The action to build the special effects from. + dict (Dictionary): The dictionary to build the special effects from. param_objs (Dictionary[str, Object]): A dictionary whose keys are parameter names and the values are placeholder objects. predicate_dict (Dictionary[str, Predicate]): The predicate dictionary. @@ -82,28 +82,27 @@ def _build_special_effects(action, param_objs, predicate_dict): """ special_effects = [] - for special_effect in action["special_fx"]: + for special_effect in dict: param_name = special_effect["param"] param_obj = param_objs[param_name] - if special_effect["type"] == "creation": + effects = _build_pred_list( + "fx", param_objs, special_effect, predicate_dict) + nested_sfx = _build_special_effects(special_effect["sfx"], param_objs, predicate_dict) + if special_effect["type"] == "delayed": + # TODO: The values for goal repetitions/time should be decided by the problem json + sfx = DelayedEffect(param_obj, effects, nested_sfx) + elif special_effect["type"] == "repetitive": + sfx = RepetitiveEffect(param_obj, effects, nested_sfx) + elif special_effect["type"] == "conditional": + conditions = _build_pred_list( + "conditions", param_objs, special_effect, predicate_dict) + sfx = ConditionalEffect(param_obj, effects, nested_sfx, conditions) + elif special_effect["type"] == "creation": created_obj_name = special_effect["created_obj"]["name"] created_obj_type = special_effect["created_obj"]["type"] + created_obj_param = special_effect["created_obj"]["param"] created_obj = Object(created_obj_name, created_obj_type) - sfx = CreationEffect(param_obj, False, created_obj) - elif special_effect["type"] == "deletion": - sfx = DeletionEffect(param_obj, False) - else: - effects = _build_pred_list( - "fx", param_objs, special_effect, predicate_dict) - if special_effect["type"] == "delayed": - # TODO: The values for goal repetitions/time should be decided by the problem json - sfx = DelayedEffect(param_obj, effects, False) - elif special_effect["type"] == "repetitive": - sfx = RepetitiveEffect(param_obj, effects, False) - elif special_effect["type"] == "conditional": - conditions = _build_pred_list( - "conditions", param_objs, special_effect, predicate_dict) - sfx = ConditionalEffect(param_obj, effects, False, conditions) + sfx = CreationEffect(param_obj, (created_obj_param, created_obj), effects, nested_sfx) special_effects.append(sfx) return special_effects @@ -132,7 +131,7 @@ def _build_action_defs(input_json, predicate_defs): immediate_effects = _build_pred_list( "immediate_fx", param_objs, action, predicate_dict) special_effects = _build_special_effects( - action, param_objs, predicate_dict) + action["sfx"], param_objs, predicate_dict) action_def = Action(name, precons, immediate_effects, special_effects) action_defs.append(action_def) diff --git a/domain/input.json b/domain/input.json index ef3f36a01..948346cb5 100644 --- a/domain/input.json +++ b/domain/input.json @@ -72,6 +72,13 @@ "at": "s1" } }, + { + "name": "fill-pot", + "input_instructions": { + "key": "e", + "at": "s1" + } + }, { "name": "wait", "input_instructions": { diff --git a/domain/robotouille.json b/domain/robotouille.json index 5bacb4e2d..1ed25369c 100644 --- a/domain/robotouille.json +++ b/domain/robotouille.json @@ -210,7 +210,7 @@ "is_true": false } ], - "special_fx": [] + "sfx": [] }, { "name": "pick-up-item", @@ -268,7 +268,7 @@ "is_true": false } ], - "special_fx": [] + "sfx": [] }, { "name": "place-item", @@ -321,7 +321,7 @@ "is_true": false } ], - "special_fx": [] + "sfx": [] }, { "name": "pick-up-container", @@ -357,9 +357,14 @@ "predicate": "nothing", "params": ["p1"], "is_true": false + }, + { + "predicate": "empty", + "params": ["s1"], + "is_true": true } ], - "special_fx": [] + "sfx": [] }, { "name": "place-container", @@ -402,7 +407,7 @@ "is_true": false } ], - "special_fx": [] + "sfx": [] }, { "name": "cook", @@ -434,7 +439,7 @@ } ], "immediate_fx": [], - "special_fx": [ + "sfx": [ { "type": "delayed", "param": "i1", @@ -444,7 +449,8 @@ "params": ["i1"], "is_true": true } - ] + ], + "sfx": [] } ] }, @@ -478,7 +484,7 @@ } ], "immediate_fx": [], - "special_fx": [ + "sfx": [ { "type": "repetitive", "param": "i1", @@ -488,7 +494,8 @@ "params": ["i1"], "is_true": true } - ] + ], + "sfx": [] }, { "type": "conditional", @@ -511,7 +518,8 @@ "params": ["i1"], "is_true": true } - ] + ], + "sfx": [] } ] @@ -546,7 +554,7 @@ } ], "immediate_fx": [], - "special_fx": [ + "sfx": [ { "type": "delayed", "param": "i1", @@ -556,7 +564,8 @@ "params": ["i1"], "is_true": true } - ] + ], + "sfx": [] } ] }, @@ -585,24 +594,33 @@ } ], "immediate_fx":[], - "special_fx": [ - { - "type": "creation", - "param": "c1", - "created_obj": { - "name": "water", - "type": "item", - "param": "m1" - } - }, + "sfx": [ { "type": "delayed", "param": "c1", - "fx": [ + "fx": [], + "sfx": [ { - "predicate": "in", - "params": ["m1", "c1"], - "is_true": true + "type": "creation", + "param": "c1", + "created_obj": { + "name": "water", + "type": "meal", + "param": "m1" + }, + "fx": [ + { + "predicate": "iswater", + "params": ["m1"], + "is_true": true + }, + { + "predicate": "in", + "params": ["m1", "c1"], + "is_true": true + } + ], + "sfx": [] } ] } @@ -643,20 +661,7 @@ } ], "immediate_fx": [], - "special_fx": [ - { - "type": "creation", - "param": "c1", - "created_obj": { - "name": "boiling-water", - "type": "meal" - } - }, - { - "type": "deletion", - "param": "m1" - } - ] + "sfx": [] }, { "name": "stack", @@ -714,7 +719,7 @@ "is_true": false } ], - "special_fx": [] + "sfx": [] }, { "name": "unstack", @@ -782,13 +787,13 @@ "is_true": false } ], - "special_fx": [] + "sfx": [] }, { "name": "wait", "precons": [], "immediate_fx": [], - "special_fx": [] + "sfx": [] } ] } \ No newline at end of file diff --git a/environments/env_generator/examples/base_fill_water.json b/environments/env_generator/examples/base_fill_water.json index 461c5033a..d46a7e289 100644 --- a/environments/env_generator/examples/base_fill_water.json +++ b/environments/env_generator/examples/base_fill_water.json @@ -40,8 +40,9 @@ ], "goal": [ { - "predicate": "in", - "args": ["water", "pot"] + "predicate": "isbowl", + "args": ["pot"], + "ids": ["a"] } ] } \ No newline at end of file diff --git a/renderer/canvas.py b/renderer/canvas.py index e1248f37b..dda36f4fa 100644 --- a/renderer/canvas.py +++ b/renderer/canvas.py @@ -167,7 +167,7 @@ def _choose_container_asset(self, container_image_name, obs): return container_config["assets"]["default"] # If there is a meal in the container, choose the asset based on the meal - return container_config["assets"][meal_name] + return container_config["assets"][meal_name]["asset"] def _draw_container_image(self, surface, container_name, obs, position): """ diff --git a/robotouille/env.py b/robotouille/env.py index 10454e1d6..2c727b30d 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -52,22 +52,24 @@ def build_station_location_predicates(environment_dict): predicates = [] for station in environment_dict["stations"]: station_obj = Object(station["name"], "station") + match = False for field in ["items", "players", "containers"]: - match = False - no_match_predicate = "empty" if field == "items" else "vacant" + no_match_predicate = "empty" if field in ["items", "containers"] else "vacant" predicate = "item_at" if field == "items" else "container_at" if field == "containers" else "loc" for entity in environment_dict[field]: + print(entity["name"]) x = entity["x"] + entity["direction"][0] if field == "players" else entity["x"] y = entity["y"] + entity["direction"][1] if field == "players" else entity["y"] if x == station["x"] and y == station["y"]: + print("match") name = entity["name"] obj = Object(name, field[:-1]) pred = Predicate().initialize(predicate, [field[:-1], "station"], [obj, station_obj]) predicates.append(pred) match = True - if not match: - pred = Predicate().initialize(no_match_predicate, ["station"], [station_obj]) - predicates.append(pred) + if not match: + pred = Predicate().initialize(no_match_predicate, ["station"], [station_obj]) + predicates.append(pred) return predicates def build_player_location_predicates(environment_dict): From 7201b5d859bea8505e365b39eee57738a6281c15 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Wed, 20 Mar 2024 20:53:26 -0400 Subject: [PATCH 05/41] Made boil water: bug when creating new objects, no id assigned --- backend/special_effects/creation_effect.py | 3 +- backend/special_effects/deletion_effect.py | 32 ++++++--- backend/state.py | 4 +- domain/domain_builder.py | 4 +- domain/input.json | 7 ++ domain/robotouille.json | 66 ++++++++++++++++--- .../examples/base_boil_water.json | 54 +++++++++++++++ renderer/canvas.py | 2 + robotouille/env.py | 55 ++++++++++++---- 9 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 environments/env_generator/examples/base_boil_water.json diff --git a/backend/special_effects/creation_effect.py b/backend/special_effects/creation_effect.py index 2cc8550d1..cec8820a6 100644 --- a/backend/special_effects/creation_effect.py +++ b/backend/special_effects/creation_effect.py @@ -71,8 +71,7 @@ def apply_sfx_on_arg(self, arg, param_arg_dict): Args: arg (Object): The argument that the special effect is applied to. param_arg_dict (Dictionary[Object, Object]): A dictionary mapping - parameters to arguments. Since the creation effect does not have - any immediate effects, this dictionary is not used. + parameters to arguments. Returns: CreationEffect: A copy of the special effect definition, but applied diff --git a/backend/special_effects/deletion_effect.py b/backend/special_effects/deletion_effect.py index c7d54e5c4..9289c43de 100644 --- a/backend/special_effects/deletion_effect.py +++ b/backend/special_effects/deletion_effect.py @@ -7,16 +7,20 @@ class DeletionEffect(SpecialEffect): A creation effect is an effect that delets an object in the state. """ - def __init__(self, param, arg=None): + def __init__(self, param, effects, special_effects, arg=None): """ Initializes a deletion effect. Args: param (Object): The parameter of the deletion effect. + effects (Dictionary[Predicate, bool]): The effects of the action, + represented by a dictionary of predicates and bools. + special_effects (List[SpecialEffect]): The nested special effects of + the action. arg (Object): The object that deleted. If the special effect is not applied to an object, arg is None. """ - super().__init__(param, {}, [], False, arg) + super().__init__(param, effects, special_effects, False, arg) def __eq__(self, other): """ @@ -28,7 +32,8 @@ def __eq__(self, other): Returns: bool: True if the effects are equal, False otherwise. """ - return self.param == other.param and self.arg == other.arg + return self.param == other.param and self.effects == other.effects \ + and self.special_effects == other.special_effects and self.arg == other.arg def __hash__(self): """ @@ -37,7 +42,8 @@ def __hash__(self): Returns: hash (int): The hash of the deletion effect. """ - return hash((self.param, self.completed, self.arg)) + return hash((self.param, tuple(self.effects), tuple(self.special_effects), + self.completed, self.arg)) def __repr__(self): """ @@ -56,14 +62,20 @@ def apply_sfx_on_arg(self, arg, param_arg_dict): Args: arg (Object): The object to apply the special effect to. param_arg_dict (Dictionary[Object, Object]): A dictionary mapping - parameters to arguments. Since the creation effect does not have - any immediate effects, this dictionary is not used. + parameters to arguments. Returns: DeletionEffect: A copy of the special effect definition, but applied to an argument. """ - return DeletionEffect(self.param, arg) + new_effects = {} + for effect, value in self.effects.items(): + new_effects[effect.replace_pred_params_with_args(param_arg_dict)] = value + new_special_effects = [] + for special_effect in self.special_effects: + new_special_effects.append(special_effect.apply_sfx_on_arg(arg, param_arg_dict)) + deleted_obj = param_arg_dict[self.param.name] + return DeletionEffect(self.param, new_effects, new_special_effects, deleted_obj) def update(self, state, active=False): """ @@ -76,5 +88,9 @@ def update(self, state, active=False): """ if self.completed: return - state.remove_object(self.arg, active) + state.delete_object(self.arg) + for effect, value in self.effects.items(): + state.update_predicate(effect, value, active) + for special_effect in self.special_effects: + special_effect.update(state, active) self.completed = True \ No newline at end of file diff --git a/backend/state.py b/backend/state.py index 01c038448..8ff57aed9 100644 --- a/backend/state.py +++ b/backend/state.py @@ -250,7 +250,7 @@ def add_object(self, obj): self.predicates = self._build_predicates(self.domain, self.objects, true_predicates) self.actions = self._build_actions(self.domain, self.objects) - def delete_obj(self, obj): + def delete_object(self, obj): """ Deletes an object from the state. @@ -319,7 +319,7 @@ def step(self, action, param_arg_dict): the given state. """ assert action.is_valid(self, param_arg_dict) - + print(f"Performing action {action.name}\n") print(f"before: {self.predicates} \n") self = action.perform_action(self, param_arg_dict) diff --git a/domain/domain_builder.py b/domain/domain_builder.py index 1fd366f20..df80a4e7d 100644 --- a/domain/domain_builder.py +++ b/domain/domain_builder.py @@ -102,7 +102,9 @@ def _build_special_effects(dict, param_objs, predicate_dict): created_obj_type = special_effect["created_obj"]["type"] created_obj_param = special_effect["created_obj"]["param"] created_obj = Object(created_obj_name, created_obj_type) - sfx = CreationEffect(param_obj, (created_obj_param, created_obj), effects, nested_sfx) + sfx = CreationEffect(param_obj, (created_obj_param, created_obj), effects, nested_sfx) + elif special_effect["type"] == "deletion": + sfx = DeletionEffect(param_obj, effects, nested_sfx) special_effects.append(sfx) return special_effects diff --git a/domain/input.json b/domain/input.json index 948346cb5..ae77ad35c 100644 --- a/domain/input.json +++ b/domain/input.json @@ -79,6 +79,13 @@ "at": "s1" } }, + { + "name": "boil-water", + "input_instructions": { + "key": "e", + "at": "s1" + } + }, { "name": "wait", "input_instructions": { diff --git a/domain/robotouille.json b/domain/robotouille.json index 1ed25369c..cc715de2e 100644 --- a/domain/robotouille.json +++ b/domain/robotouille.json @@ -132,9 +132,13 @@ "param_types": ["player"] }, { - "name": "empty", + "name": "station_empty", "param_types": ["station"] }, + { + "name": "container_empty", + "param_types": ["container"] + }, { "name": "item_on", "param_types": ["item", "station"] @@ -243,7 +247,7 @@ "is_true": true }, { - "predicate": "empty", + "predicate": "station_empty", "params": ["s1"], "is_true": true }, @@ -284,7 +288,7 @@ "is_true": true }, { - "predicate": "empty", + "predicate": "station_empty", "params": ["s1"], "is_true": true } @@ -316,7 +320,7 @@ "is_true": false }, { - "predicate": "empty", + "predicate": "station_empty", "params": ["s1"], "is_true": false } @@ -359,7 +363,7 @@ "is_true": false }, { - "predicate": "empty", + "predicate": "station_empty", "params": ["s1"], "is_true": true } @@ -380,7 +384,7 @@ "is_true": true }, { - "predicate": "empty", + "predicate": "station_empty", "params": ["s1"], "is_true": true } @@ -402,7 +406,7 @@ "is_true": false }, { - "predicate": "empty", + "predicate": "station_empty", "params": ["s1"], "is_true": false } @@ -591,6 +595,11 @@ "predicate": "container_at", "params": ["c1", "s1"], "is_true": true + }, + { + "predicate": "container_empty", + "params": ["c1"], + "is_true": true } ], "immediate_fx":[], @@ -618,6 +627,11 @@ "predicate": "in", "params": ["m1", "c1"], "is_true": true + }, + { + "predicate": "container_empty", + "params": ["c1"], + "is_true": false } ], "sfx": [] @@ -661,7 +675,43 @@ } ], "immediate_fx": [], - "sfx": [] + "sfx": [ + { + "type": "delayed", + "param": "c1", + "fx": [], + "sfx": [ + { + "type": "creation", + "param": "c1", + "created_obj": { + "name": "boilingwater", + "type": "meal", + "param": "m2" + }, + "fx": [ + { + "predicate": "isboilingwater", + "params": ["m2"], + "is_true": true + }, + { + "predicate": "in", + "params": ["m2", "c1"], + "is_true": true + } + ], + "sfx": [] + }, + { + "type": "deletion", + "param": "m1", + "fx": [], + "sfx": [] + } + ] + } + ] }, { "name": "stack", diff --git a/environments/env_generator/examples/base_boil_water.json b/environments/env_generator/examples/base_boil_water.json new file mode 100644 index 000000000..5609802fb --- /dev/null +++ b/environments/env_generator/examples/base_boil_water.json @@ -0,0 +1,54 @@ +{ + "width": 3, + "height": 3, + "config": { + "num_cuts": { + "lettuce": 3, + "default": 3 + }, + "cook_time": { + "patty": 3, + "default": 3 + } + }, + "stations": [ + { + "name": "stove", + "x": 0, + "y": 1, + "id": "A" + } + ], + "items": [], + "players": [ + { + "name": "robot", + "x": 0, + "y": 0, + "direction": [0, 1] + } + ], + "meals": [ + { + "name": "water", + "x": 0, + "y": 1, + "id": "b" + } + ], + "containers": [ + { + "name": "pot", + "x": 0, + "y": 1, + "id": "a" + } + ], + "goal": [ + { + "predicate": "isbowl", + "args": ["pot"], + "ids": ["a"] + } + ] +} \ No newline at end of file diff --git a/renderer/canvas.py b/renderer/canvas.py index dda36f4fa..a863411ee 100644 --- a/renderer/canvas.py +++ b/renderer/canvas.py @@ -167,6 +167,8 @@ def _choose_container_asset(self, container_image_name, obs): return container_config["assets"]["default"] # If there is a meal in the container, choose the asset based on the meal + print(meal_name) + meal_name, _ = trim_item_ID(meal_name) return container_config["assets"][meal_name]["asset"] def _draw_container_image(self, surface, container_name, obs, position): diff --git a/robotouille/env.py b/robotouille/env.py index 2c727b30d..2bc290d5f 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -10,7 +10,7 @@ def build_identity_predicates(environment_dict, entity_fields): """ - Builds identity predicates string from an environment dictionary. + Builds identity predicates from an environment dictionary. Note that if the typed enum is a cuttable/cookable item, then that identity predicate will be added in this function. @@ -36,32 +36,58 @@ def build_identity_predicates(environment_dict, entity_fields): identity_predicates.append(pred) return identity_predicates +def build_container_location_predicates(environment_dict): + """ + Builds container location predicates form an environment dictionary. + + These include (in) and (container_empty) predicates. + + Args: + environment_dict (dict): Dictionary containing the initial stations, items, + and player location. + + Returns: + predicates (List): Container location predicates. + """ + predicates = [] + for container in environment_dict["containers"]: + container_obj = Object(container["name"], "container") + match = False + for meal in environment_dict["meals"]: + if meal["x"] == container["x"] and meal["y"] == container["y"]: + meal_obj = Object(meal["name"], "meal") + pred = Predicate().initialize("in", ["meal", "container"], [meal_obj, container_obj]) + predicates.append(pred) + match = True + if not match: + pred = Predicate().initialize("container_empty", ["container"], [container_obj]) + predicates.append(pred) + return predicates + def build_station_location_predicates(environment_dict): """ - Builds station location predicates string from an environment dictionary. + Builds station location predicates from an environment dictionary. - These include the (empty), (vacant), (loc) and (at) predicates. + These include the (station_empty), (vacant), (loc) and (at) predicates. Args: environment_dict (dict): Dictionary containing the initial stations, items, and player location. Returns: - predicates (List): Station location predicates string. + predicates (List): Station location predicates. """ predicates = [] for station in environment_dict["stations"]: station_obj = Object(station["name"], "station") match = False for field in ["items", "players", "containers"]: - no_match_predicate = "empty" if field in ["items", "containers"] else "vacant" + no_match_predicate = "station_empty" if field in ["items", "containers"] else "vacant" predicate = "item_at" if field == "items" else "container_at" if field == "containers" else "loc" for entity in environment_dict[field]: - print(entity["name"]) x = entity["x"] + entity["direction"][0] if field == "players" else entity["x"] y = entity["y"] + entity["direction"][1] if field == "players" else entity["y"] if x == station["x"] and y == station["y"]: - print("match") name = entity["name"] obj = Object(name, field[:-1]) pred = Predicate().initialize(predicate, [field[:-1], "station"], [obj, station_obj]) @@ -74,7 +100,7 @@ def build_station_location_predicates(environment_dict): def build_player_location_predicates(environment_dict): """ - Builds player location predicates string from an environment dictionary. + Builds player location predicates from an environment dictionary. These include the (nothing) and (has) predicates. @@ -83,7 +109,7 @@ def build_player_location_predicates(environment_dict): and player location. Returns: - predicates (List): Player location predicates string. + predicates (List): Player location predicates. """ predicates = [] for player in environment_dict["players"]: @@ -111,11 +137,11 @@ def build_player_location_predicates(environment_dict): def build_location_predicates(environment_dict): """ - Builds location predicates string from an environment dictionary. + Builds location predicates from an environment dictionary. The most explicit location predicates are the (loc) and (at) predicates which specify the location of players and items relative to stations respectively. - There are other implicit location predicates such as (nothing), (has), (empty) and + There are other implicit location predicates such as (nothing), (has), (station_empty) and (vacant) which all imply the location of the player or item. Note that the (clear), (on) and (atop) predicates are added in the build_stacking_predicates function. @@ -124,16 +150,17 @@ def build_location_predicates(environment_dict): environment_dict (dict): Dictionary containing the initial stations, items, and player location. Returns: - location_predicates (List): PDDL location predicates string. + location_predicates (List): PDDL location predicates. """ location_predicates = [] + location_predicates += build_container_location_predicates(environment_dict) location_predicates += build_station_location_predicates(environment_dict) location_predicates += build_player_location_predicates(environment_dict) return location_predicates def build_stacking_predicates(environment_dict): """ - Build stacking predicates string from an environment dictionary. + Build stacking predicates from an environment dictionary. These include the (clear), (on) and (atop) predicates. @@ -141,7 +168,7 @@ def build_stacking_predicates(environment_dict): environment_dict (dict): Dictionary containing the initial stations, items, and player location. Returns: - stacking_predicates (List): Stacking predicates string. + stacking_predicates (List): Stacking predicates. """ stacking_predicates = [] stacks = {} From 4fa20780cc1bed79aa11b085b2525899ceb1aa49 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 25 Mar 2024 09:49:54 -0400 Subject: [PATCH 06/41] boil soup bug fixed --- backend/state.py | 15 +++++++++++++++ renderer/configuration/robotouille_config.json | 4 ++-- robotouille/env.py | 1 + 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/backend/state.py b/backend/state.py index 8ff57aed9..eb71ba2dc 100644 --- a/backend/state.py +++ b/backend/state.py @@ -1,4 +1,5 @@ from backend.predicate import Predicate +from utils.robotouille_utils import trim_item_ID import itertools class State(object): @@ -238,6 +239,18 @@ def update_special_effect(self, special_effect, arg, param_arg_dict): current = self.special_effects[self.special_effects.index(replaced_effect)] current.update(self, active=True) + def _get_num_of_objs_with_same_name(self, obj): + """ + Returns the number of objects with the same name as the given object. + + Args: + obj (Object): The object to check. + + Returns: + num (int): The number of objects with the same name as the given object. + """ + return len([o for o in self.objects if trim_item_ID(o.name) == obj.name]) + def add_object(self, obj): """ Adds an object to the state. @@ -245,6 +258,8 @@ def add_object(self, obj): Args: obj (Object): The object to add to the state. """ + num = self._get_num_of_objs_with_same_name(obj) + obj.name = f"{obj.name}{num+1}" self.objects.append(obj) true_predicates = {predicate for predicate, value in self.predicates.items() if value} self.predicates = self._build_predicates(self.domain, self.objects, true_predicates) diff --git a/renderer/configuration/robotouille_config.json b/renderer/configuration/robotouille_config.json index 91791c5c0..010451e56 100644 --- a/renderer/configuration/robotouille_config.json +++ b/renderer/configuration/robotouille_config.json @@ -166,9 +166,9 @@ "asset": "pot_water.png", "meal": "water" }, - "boiling_water": { + "boilingwater": { "asset": "pot_water_boil.png", - "meal": "boiling_water" + "meal": "boilingwater" }, "soup": { "asset": "pot_soup.png", diff --git a/robotouille/env.py b/robotouille/env.py index 2bc290d5f..d1cdf54e8 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -5,6 +5,7 @@ from environments.env_generator.builder import entity_to_entity_field, create_unique_and_combination_preds, create_combinations import copy from domain.domain_builder import build_domain +from utils.robotouille_utils import trim_item_ID import gym import json From 34058a0b1ecdf832c8278146009d7168fad912a5 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 25 Mar 2024 15:54:44 -0400 Subject: [PATCH 07/41] finish implementing all actions --- backend/state.py | 23 +-- domain/input.json | 14 ++ domain/robotouille.json | 152 ++++++++++++++---- .../examples/base_add_to_soup.json | 63 ++++++++ .../examples/composite_add_fill_bowl.json | 74 +++++++++ renderer/canvas.py | 36 ++++- .../configuration/robotouille_config.json | 35 ++-- robotouille/env.py | 8 +- 8 files changed, 340 insertions(+), 65 deletions(-) create mode 100644 environments/env_generator/examples/base_add_to_soup.json create mode 100644 environments/env_generator/examples/composite_add_fill_bowl.json diff --git a/backend/state.py b/backend/state.py index eb71ba2dc..8e1647798 100644 --- a/backend/state.py +++ b/backend/state.py @@ -239,17 +239,22 @@ def update_special_effect(self, special_effect, arg, param_arg_dict): current = self.special_effects[self.special_effects.index(replaced_effect)] current.update(self, active=True) - def _get_num_of_objs_with_same_name(self, obj): + def _get_next_ID_for_object(self, obj): """ - Returns the number of objects with the same name as the given object. + Gets the next available ID for an object. Args: - obj (Object): The object to check. + obj (Object): The object to get the next available ID for. Returns: - num (int): The number of objects with the same name as the given object. + int: The next available ID for the object. """ - return len([o for o in self.objects if trim_item_ID(o.name) == obj.name]) + num = 0 + for object in self.objects: + name, id = trim_item_ID(object.name) + if name == obj.name: + num = max(num, id) + return num+1 def add_object(self, obj): """ @@ -258,8 +263,8 @@ def add_object(self, obj): Args: obj (Object): The object to add to the state. """ - num = self._get_num_of_objs_with_same_name(obj) - obj.name = f"{obj.name}{num+1}" + num = self._get_next_ID_for_object(obj) + obj.name = f"{obj.name}{num}" self.objects.append(obj) true_predicates = {predicate for predicate, value in self.predicates.items() if value} self.predicates = self._build_predicates(self.domain, self.objects, true_predicates) @@ -334,12 +339,8 @@ def step(self, action, param_arg_dict): the given state. """ assert action.is_valid(self, param_arg_dict) - print(f"Performing action {action.name}\n") - print(f"before: {self.predicates} \n") self = action.perform_action(self, param_arg_dict) - - print(f"after: {self.predicates} \n") for special_effect in self.special_effects: special_effect.update(self) diff --git a/domain/input.json b/domain/input.json index ae77ad35c..f33a7dd38 100644 --- a/domain/input.json +++ b/domain/input.json @@ -86,6 +86,20 @@ "at": "s1" } }, + { + "name": "add-to", + "input_instructions": { + "key": "a", + "at": "s1" + } + }, + { + "name": "fill-bowl", + "input_instructions": { + "key": "f", + "at": "s1" + } + }, { "name": "wait", "input_instructions": { diff --git a/domain/robotouille.json b/domain/robotouille.json index cc715de2e..409c851bc 100644 --- a/domain/robotouille.json +++ b/domain/robotouille.json @@ -107,16 +107,12 @@ "name": "isbowl", "param_types": ["container"] }, - { - "name": "issoup", - "param_types": ["meal"] - }, { "name": "iswater", "param_types": ["meal"] }, { - "name": "isboilingwater", + "name": "isboiling", "param_types": ["meal"] }, { @@ -679,40 +675,128 @@ { "type": "delayed", "param": "c1", - "fx": [], - "sfx": [ - { - "type": "creation", - "param": "c1", - "created_obj": { - "name": "boilingwater", - "type": "meal", - "param": "m2" - }, - "fx": [ - { - "predicate": "isboilingwater", - "params": ["m2"], - "is_true": true - }, - { - "predicate": "in", - "params": ["m2", "c1"], - "is_true": true - } - ], - "sfx": [] - }, + "fx": [ { - "type": "deletion", - "param": "m1", - "fx": [], - "sfx": [] + "predicate": "isboiling", + "params": ["m1"], + "is_true": true } - ] + ], + "sfx": [] } ] }, + { + "name": "add-to", + "precons": [ + { + "predicate": "ispot", + "params": ["c1"], + "is_true": true + }, + { + "predicate": "addedto", + "params": ["i1", "m1"], + "is_true": false + }, + { + "predicate": "in", + "params": ["m1", "c1"], + "is_true": true + }, + { + "predicate": "iscut", + "params": ["i1"], + "is_true": true + }, + { + "predicate": "loc", + "params": ["p1", "s1"], + "is_true": true + }, + { + "predicate": "has_item", + "params": ["p1", "i1"], + "is_true": true + } + ], + "immediate_fx": [ + { + "predicate": "addedto", + "params": ["i1", "m1"], + "is_true": true + }, + { + "predicate": "has_item", + "params": ["p1", "i1"], + "is_true": false + }, + { + "predicate": "nothing", + "params": ["p1"], + "is_true": true + } + ], + "sfx": [] + }, + { + "name": "fill-bowl", + "precons": [ + { + "predicate": "isbowl", + "params": ["c1"], + "is_true": true + }, + { + "predicate": "ispot", + "params": ["c2"], + "is_true": true + }, + { + "predicate": "container_at", + "params": ["c2", "s1"], + "is_true": true + }, + { + "predicate": "in", + "params": ["m1", "c2"], + "is_true": true + }, + { + "predicate": "loc", + "params": ["p1", "s1"], + "is_true": true + }, + { + "predicate": "container_empty", + "params": ["c1"], + "is_true": true + } + ], + "immediate_fx": [ + { + "predicate": "in", + "params": ["m1", "c1"], + "is_true": true + }, + { + "predicate": "in", + "params": ["m1", "c2"], + "is_true": false + }, + { + "predicate": "container_empty", + "params": ["c1"], + "is_true": false + }, + { + "predicate": "container_empty", + "params": ["c2"], + "is_true": true + } + ], + "sfx": [] + }, { "name": "stack", "precons": [ diff --git a/environments/env_generator/examples/base_add_to_soup.json b/environments/env_generator/examples/base_add_to_soup.json new file mode 100644 index 000000000..3cbc3b9f8 --- /dev/null +++ b/environments/env_generator/examples/base_add_to_soup.json @@ -0,0 +1,63 @@ +{ + "width": 3, + "height": 3, + "config": { + "num_cuts": { + "lettuce": 3, + "default": 3 + }, + "cook_time": { + "patty": 3, + "default": 3 + } + }, + "stations": [ + { + "name": "stove", + "x": 0, + "y": 1, + "id": "A" + } + ], + "items": [ + { + "name": "tomato", + "x": 0, + "y": 0, + "id": "c", + "predicates": ["iscut"] + } + ], + "players": [ + { + "name": "robot", + "x": 0, + "y": 0, + "direction": [0, 1] + } + ], + "meals": [ + { + "name": "water", + "x": 0, + "y": 1, + "id": "b", + "predicates": ["isboiling"] + } + ], + "containers": [ + { + "name": "pot", + "x": 0, + "y": 1, + "id": "a" + } + ], + "goal": [ + { + "predicate": "isbowl", + "args": ["pot"], + "ids": ["a"] + } + ] +} \ No newline at end of file diff --git a/environments/env_generator/examples/composite_add_fill_bowl.json b/environments/env_generator/examples/composite_add_fill_bowl.json new file mode 100644 index 000000000..1d626b266 --- /dev/null +++ b/environments/env_generator/examples/composite_add_fill_bowl.json @@ -0,0 +1,74 @@ +{ + "width": 3, + "height": 3, + "config": { + "num_cuts": { + "lettuce": 3, + "default": 3 + }, + "cook_time": { + "patty": 3, + "default": 3 + } + }, + "stations": [ + { + "name": "table", + "x": 0, + "y": 1 + }, + { + "name": "stove", + "x": 2, + "y": 1, + "id": "A" + } + ], + "items": [ + { + "name": "tomato", + "x": 0, + "y": 0, + "predicates": ["iscut"], + "id": "a" + } + ], + "players": [ + { + "name": "robot", + "x": 0, + "y": 0, + "direction": [0, 1] + } + ], + "meals": [ + { + "name": "water", + "x": 2, + "y": 1, + "id": "b", + "predicates": ["isboiling"] + } + ], + "containers": [ + { + "name": "pot", + "x": 2, + "y": 1, + "id": "c" + }, + { + "name": "bowl", + "x": 0, + "y": 1, + "id": "d" + } + ], + "goal": [ + { + "predicate": "iscooked", + "args": ["patty"], + "ids": ["a"] + } + ] +} \ No newline at end of file diff --git a/renderer/canvas.py b/renderer/canvas.py index a863411ee..27e681e0a 100644 --- a/renderer/canvas.py +++ b/renderer/canvas.py @@ -167,9 +167,41 @@ def _choose_container_asset(self, container_image_name, obs): return container_config["assets"]["default"] # If there is a meal in the container, choose the asset based on the meal - print(meal_name) + item_predicates = {} + for literal, is_true in obs.predicates.items(): + if is_true: + literal_args = [param.name for param in literal.params] + if meal_name in literal_args: + item_predicates[literal.name] = [param.name for param in literal.params] + if len(item_predicates) == 0: + return container_config["assets"]["default"] + meal_name, _ = trim_item_ID(meal_name) - return container_config["assets"][meal_name]["asset"] + max_matches = 0 + asset_config = container_config["assets"][meal_name] + chosen_asset = asset_config["default"] + for asset in asset_config: + if asset == "default": + continue + matches = 0 + for predicate in asset_config[asset]["predicates"]: + if predicate["name"] in item_predicates: + params = [] + pred_params = [trim_item_ID(param)[0] for param in item_predicates[predicate["name"]]] + for param in predicate["params"]: + if param == "": + param = pred_params[predicate["params"].index("")] + params.append(param) + if params == pred_params: + matches += 1 + if matches == len(asset_config[asset]["predicates"]): + if matches > max_matches: + max_matches = matches + chosen_asset = asset_config[asset]["asset"] + elif matches == max_matches: + chosen_asset = asset_config["default"] + + return chosen_asset def _draw_container_image(self, surface, container_name, obs, position): """ diff --git a/renderer/configuration/robotouille_config.json b/renderer/configuration/robotouille_config.json index 010451e56..248933726 100644 --- a/renderer/configuration/robotouille_config.json +++ b/renderer/configuration/robotouille_config.json @@ -163,16 +163,20 @@ "assets": { "default": "pot.png", "water": { - "asset": "pot_water.png", - "meal": "water" - }, - "boilingwater": { - "asset": "pot_water_boil.png", - "meal": "boilingwater" - }, - "soup": { - "asset": "pot_soup.png", - "meal": "soup" + "default": "pot_water.png", + "boiling": { + "asset": "pot_water_boil.png", + "predicates": [ + {"name": "isboiling", "params": ["water"]} + ] + }, + "soup": { + "asset": "pot_soup.png", + "predicates": [ + {"name": "isboiling", "params": ["water"]}, + {"name": "addedto", "params": ["", "water"]} + ] + } } }, "constants": {} @@ -180,9 +184,14 @@ "bowl": { "assets": { "default": "bowl.png", - "soup": { - "asset": "bowl_soup.png", - "meal": "soup" + "water": { + "default": "bowl.png", + "soup": { + "asset": "bowl_soup.png", + "predicates": [ + {"name": "addedto", "params": ["", "water"]} + ] + } } }, "constants": {} diff --git a/robotouille/env.py b/robotouille/env.py index d1cdf54e8..dc2d90b63 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -94,9 +94,9 @@ def build_station_location_predicates(environment_dict): pred = Predicate().initialize(predicate, [field[:-1], "station"], [obj, station_obj]) predicates.append(pred) match = True - if not match: - pred = Predicate().initialize(no_match_predicate, ["station"], [station_obj]) - predicates.append(pred) + if not match: + pred = Predicate().initialize(no_match_predicate, ["station"], [station_obj]) + predicates.append(pred) return predicates def build_player_location_predicates(environment_dict): @@ -312,8 +312,6 @@ def __init__(self, domain_json, environment_json, render_fn, render_mode=None, s self.initial_state = initial_state - print(initial_state.predicates) - self.observation_space = initial_state self.action_space = initial_state.domain.actions From c79971f125fbf6f5352dd78526cfebc4c4fa5f3d Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 25 Mar 2024 16:23:09 -0400 Subject: [PATCH 08/41] implemented cook soup test --- backend/special_effects/conditional_effect.py | 2 +- backend/special_effects/repetitive_effect.py | 2 +- domain/robotouille.json | 7 +- .../env_generator/examples/cook_soup.json | 96 +++++++++++++++++++ renderer/canvas.py | 3 + .../configuration/robotouille_config.json | 2 +- 6 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 environments/env_generator/examples/cook_soup.json diff --git a/backend/special_effects/conditional_effect.py b/backend/special_effects/conditional_effect.py index 57d02810e..50179e6e9 100644 --- a/backend/special_effects/conditional_effect.py +++ b/backend/special_effects/conditional_effect.py @@ -86,7 +86,7 @@ def apply_sfx_on_arg(self, arg, param_arg_dict): new_conditions = {} for condition, value in self.condition.items(): new_conditions[condition.replace_pred_params_with_args(param_arg_dict)] = value - return ConditionalEffect(self.param, new_effects, new_special_effects, self.completed, new_conditions, arg) + return ConditionalEffect(self.param, new_effects, new_special_effects, new_conditions, arg) def update(self, state, active=False): """ diff --git a/backend/special_effects/repetitive_effect.py b/backend/special_effects/repetitive_effect.py index 63659d25b..e3416f885 100644 --- a/backend/special_effects/repetitive_effect.py +++ b/backend/special_effects/repetitive_effect.py @@ -81,7 +81,7 @@ def apply_sfx_on_arg(self, arg, param_arg_dict): new_special_effects = [] for special_effect in self.special_effects: new_special_effects.append(special_effect.apply_sfx_on_arg(arg, param_arg_dict)) - return RepetitiveEffect(self.param, new_effects, new_special_effects, self.completed, self.goal_repetitions, arg) + return RepetitiveEffect(self.param, new_effects, new_special_effects, self.goal_repetitions, arg) def increment_repetitions(self): """ diff --git a/domain/robotouille.json b/domain/robotouille.json index 409c851bc..59c0a9bbd 100644 --- a/domain/robotouille.json +++ b/domain/robotouille.json @@ -754,7 +754,7 @@ }, { "predicate": "container_at", - "params": ["c2", "s1"], + "params": ["c1", "s1"], "is_true": true }, { @@ -771,6 +771,11 @@ "predicate": "container_empty", "params": ["c1"], "is_true": true + }, + { + "predicate": "has_container", + "params": ["p1", "c2"], + "is_true": true } ], "immediate_fx": [ diff --git a/environments/env_generator/examples/cook_soup.json b/environments/env_generator/examples/cook_soup.json new file mode 100644 index 000000000..a0b1453f3 --- /dev/null +++ b/environments/env_generator/examples/cook_soup.json @@ -0,0 +1,96 @@ +{ + "width": 6, + "height": 6, + "config": {}, + "stations": [ + { + "name": "table", + "x": 0, + "y": 1 + }, + { + "name": "stove", + "x": 1, + "y": 1 + }, + { + "name": "table", + "x": 2, + "y": 1 + }, + { + "name": "board", + "x": 3, + "y": 1 + }, + { + "name": "sink", + "x": 4, + "y": 1 + }, + { + "name": "table", + "x": 0, + "y": 3 + }, + { + "name": "table", + "x": 1, + "y": 3 + }, + { + "name": "table", + "x": 2, + "y": 3 + }, + { + "name": "table", + "x": 3, + "y": 3 + }, + { + "name": "table", + "x": 4, + "y": 3 + } + ], + "items": [ + { + "name": "tomato", + "x": 0, + "y": 1, + "stack-level": 0, + "predicates": ["iscuttable"] + } + ], + "meals": [], + "containers": [ + { + "name": "pot", + "x": 2, + "y": 1, + "id": "a" + }, + { + "name": "bowl", + "x": 0, + "y": 3 + } + ], + "players": [ + { + "name": "robot", + "x": 0, + "y": 0, + "direction": [0, 1] + } + ], + "goal_description": "", + "goal": [ + { + "predicate": "isbowl", + "args": ["pot"], + "ids": ["a"] + } + ] +} \ No newline at end of file diff --git a/renderer/canvas.py b/renderer/canvas.py index 27e681e0a..6198a7028 100644 --- a/renderer/canvas.py +++ b/renderer/canvas.py @@ -423,11 +423,14 @@ def _draw_container(self, surface, obs): surface (pygame.Surface): Surface to draw on obs (State): Game state predicates """ + station_container_offset = self.config["container"]["constants"]["STATION_CONTAINER_OFFSET"] + for literal, is_true in obs.predicates.items(): if is_true and literal.name == "container_at": container = literal.params[0].name station = literal.params[1].name container_pos = self._get_station_position(station) + container_pos[1] -= station_container_offset self._draw_container_image(surface, container, obs, container_pos * self.pix_square_size) if is_true and literal.name == "has_container": container = literal.params[1].name diff --git a/renderer/configuration/robotouille_config.json b/renderer/configuration/robotouille_config.json index 248933726..1372121ae 100644 --- a/renderer/configuration/robotouille_config.json +++ b/renderer/configuration/robotouille_config.json @@ -154,7 +154,7 @@ "container": { "constants": { - "STATION_ITEM_OFFSET" : 0.25, + "STATION_CONTAINER_OFFSET" : 0.25, "X_SCALE_FACTOR": 0.125, "Y_SCALE_FACTOR": 0.75 }, From 82a9e361cdb1391cb4b41062dc73c2dbcc35d0b9 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Wed, 27 Mar 2024 15:08:13 -0400 Subject: [PATCH 09/41] added conditional delayed effect for cook, boil, and fry; added versions to env files; empty fields in problem files not not required --- backend/special_effects/conditional_effect.py | 4 +- domain/input.json | 4 +- domain/robotouille.json | 99 ++++++++++++++++--- environments/env_generator/builder.py | 27 ++--- .../examples/base_add_to_soup.json | 1 + .../examples/base_boil_water.json | 8 +- .../env_generator/examples/base_cook.json | 1 + .../env_generator/examples/base_cut.json | 1 + .../examples/base_fill_water.json | 1 + .../env_generator/examples/base_move.json | 1 + .../env_generator/examples/base_pickup.json | 1 + .../examples/base_pickup_container.json | 1 + .../env_generator/examples/base_place.json | 1 + .../examples/base_place_container.json | 1 + .../env_generator/examples/base_stack.json | 1 + .../env_generator/examples/base_unstack.json | 3 +- .../examples/composite_add_fill_bowl.json | 1 + .../examples/composite_cook_pickup.json | 3 +- .../examples/composite_cut_pickup.json | 3 +- .../examples/composite_move_cook.json | 1 + .../examples/composite_move_cut.json | 1 + .../examples/composite_move_pickup.json | 3 +- .../examples/composite_move_place.json | 3 +- .../examples/composite_move_stack.json | 1 + .../examples/composite_move_unstack.json | 3 +- .../examples/composite_place_cook.json | 1 + .../examples/composite_place_cut.json | 1 + .../env_generator/examples/cook_patties.json | 1 + .../env_generator/examples/cook_soup.json | 1 + .../env_generator/examples/cut_lettuces.json | 1 + .../env_generator/examples/fry_chicken.json | 1 + .../env_generator/examples/fry_potato.json | 1 + .../examples/high_level_assemble_burgers.json | 5 +- .../high_level_big_american_meal.json | 1 + .../examples/high_level_cheese_burger.json | 1 + .../examples/high_level_chicken_burger.json | 1 + .../examples/high_level_cook_and_cut.json | 1 + .../examples/high_level_lettuce_burger.json | 1 + .../high_level_lettuce_tomato_burger.json | 1 + .../high_level_two_cheese_burger.json | 1 + .../high_level_two_chicken_burger.json | 1 + .../high_level_two_lettuce_burger.json | 1 + .../high_level_two_lettuce_tomato_burger.json | 1 + .../env_generator/examples/original.json | 2 +- .../env_generator/examples/test_arena.json | 9 +- environments/env_generator/object_enums.py | 2 + robotouille/env.py | 24 +++-- 47 files changed, 170 insertions(+), 63 deletions(-) diff --git a/backend/special_effects/conditional_effect.py b/backend/special_effects/conditional_effect.py index 50179e6e9..a8439623e 100644 --- a/backend/special_effects/conditional_effect.py +++ b/backend/special_effects/conditional_effect.py @@ -110,4 +110,6 @@ def update(self, state, active=False): state.update_predicate(effect, value) for special_effect in self.special_effects: special_effect.update(state, active) - self.completed = True \ No newline at end of file + if all([special_effect.completed for special_effect in self.special_effects]): + self.completed = True + \ No newline at end of file diff --git a/domain/input.json b/domain/input.json index f33a7dd38..e6c384e6b 100644 --- a/domain/input.json +++ b/domain/input.json @@ -89,14 +89,14 @@ { "name": "add-to", "input_instructions": { - "key": "a", + "key": "e", "at": "s1" } }, { "name": "fill-bowl", "input_instructions": { - "key": "f", + "key": "e", "at": "s1" } }, diff --git a/domain/robotouille.json b/domain/robotouille.json index 59c0a9bbd..f7a3b250e 100644 --- a/domain/robotouille.json +++ b/domain/robotouille.json @@ -436,21 +436,40 @@ "predicate": "clear", "params": ["i1"], "is_true": true + }, + { + "predicate": "nothing", + "params": ["p1"], + "is_true": true } ], "immediate_fx": [], "sfx": [ { - "type": "delayed", + "type": "conditional", "param": "i1", - "fx": [ + "conditions": [ { - "predicate": "iscooked", - "params": ["i1"], + "predicate": "item_on", + "params": ["i1", "s1"], "is_true": true } ], - "sfx": [] + "fx": [], + "sfx": [ + { + "type": "delayed", + "param": "i1", + "fx": [ + { + "predicate": "iscooked", + "params": ["i1"], + "is_true": true + } + ], + "sfx": [] + } + ] } ] }, @@ -481,6 +500,11 @@ "predicate": "clear", "params": ["i1"], "is_true": true + }, + { + "predicate": "nothing", + "params": ["p1"], + "is_true": true } ], "immediate_fx": [], @@ -551,21 +575,40 @@ "predicate": "clear", "params": ["i1"], "is_true": true + }, + { + "predicate": "nothing", + "params": ["p1"], + "is_true": true } ], "immediate_fx": [], "sfx": [ { - "type": "delayed", + "type": "conditional", "param": "i1", - "fx": [ + "conditions": [ { - "predicate": "isfried", - "params": ["i1"], + "predicate": "item_on", + "params": ["i1", "s1"], "is_true": true } ], - "sfx": [] + "fx": [], + "sfx": [ + { + "type": "delayed", + "param": "i1", + "fx": [ + { + "predicate": "isfried", + "params": ["i1"], + "is_true": true + } + ], + "sfx": [] + } + ] } ] }, @@ -596,6 +639,11 @@ "predicate": "container_empty", "params": ["c1"], "is_true": true + }, + { + "predicate": "nothing", + "params": ["p1"], + "is_true": true } ], "immediate_fx":[], @@ -668,21 +716,40 @@ "predicate": "loc", "params": ["p1", "s1"], "is_true": true + }, + { + "predicate": "nothing", + "params": ["p1"], + "is_true": true } ], "immediate_fx": [], "sfx": [ { - "type": "delayed", - "param": "c1", - "fx": [ + "type": "conditional", + "param": "m1", + "conditions": [ { - "predicate": "isboiling", - "params": ["m1"], + "predicate": "container_at", + "params": ["c1", "s1"], "is_true": true } ], - "sfx": [] + "fx": [], + "sfx": [ + { + "type": "delayed", + "param": "m1", + "fx": [ + { + "predicate": "isboiling", + "params": ["m1"], + "is_true": true + } + ], + "sfx": [] + } + ] } ] }, diff --git a/environments/env_generator/builder.py b/environments/env_generator/builder.py index f19c80ea9..ce0c7fd2f 100644 --- a/environments/env_generator/builder.py +++ b/environments/env_generator/builder.py @@ -3,7 +3,7 @@ import os import copy import itertools -from .object_enums import Item, Player, Station, Container, Meal, str_to_typed_enum +from .object_enums import Item, Player, Station, Container, Meal, str_to_typed_enum, TYPES from .procedural_generator import randomize_environment import random @@ -75,22 +75,12 @@ def load_environment(json_filename, seed=None): sorting_key = lambda entity: (entity["x"], entity["y"]) environment_json["stations"].sort(key=sorting_key) # TODO: Breaks seed that gives consistent layout - for station in environment_json["stations"]: - if station["name"] == "station": - station["name"] = random.choice(list(Station)).value - environment_json["items"].sort(key=sorting_key) - for item in environment_json["items"]: - if item["name"] == "item": - item["name"] = random.choice(list(Item)).value - environment_json["players"].sort(key=sorting_key) - for container in environment_json["containers"]: - if container["name"] == "container": - container["name"] = random.choice(list(Container)).value - environment_json["containers"].sort(key=sorting_key) - for meal in environment_json["meals"]: - if meal["name"] == "meal": - meal["name"] = random.choice(list(Meal)).value - environment_json["meals"].sort(key=sorting_key) + for field in ENTITY_FIELDS: + if not environment_json.get(field): continue + for entity in environment_json[field]: + if entity["name"] == field[:-1]: + entity["name"] = random.choice(list(TYPES[field[:-1]])).value + environment_json[field].sort(key=sorting_key) return environment_json def build_objects(environment_dict): @@ -109,6 +99,7 @@ def build_objects(environment_dict): objects_str = "" updated_environment_dict = copy.deepcopy(environment_dict) for field in ENTITY_FIELDS: + if not environment_dict.get(field): continue object_type = field[:-1] seen = {} updated_environment_dict[field].sort(key=lambda entity: (entity["x"], entity["y"])) @@ -162,7 +153,7 @@ def build_station_location_predicates(environment_dict): for field in ["items", "players"]: match = False no_match_predicate = "empty" if field == "items" else "vacant" - predicate = "at" if field == "items" else "loc" + predicate = "item_at" if field == "items" else "loc" for entity in environment_dict[field]: x = entity["x"] + entity["direction"][0] if field == "players" else entity["x"] y = entity["y"] + entity["direction"][1] if field == "players" else entity["y"] diff --git a/environments/env_generator/examples/base_add_to_soup.json b/environments/env_generator/examples/base_add_to_soup.json index 3cbc3b9f8..d20c49714 100644 --- a/environments/env_generator/examples/base_add_to_soup.json +++ b/environments/env_generator/examples/base_add_to_soup.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_boil_water.json b/environments/env_generator/examples/base_boil_water.json index 5609802fb..ed2cb612a 100644 --- a/environments/env_generator/examples/base_boil_water.json +++ b/environments/env_generator/examples/base_boil_water.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { @@ -19,7 +20,6 @@ "id": "A" } ], - "items": [], "players": [ { "name": "robot", @@ -46,9 +46,9 @@ ], "goal": [ { - "predicate": "isbowl", - "args": ["pot"], - "ids": ["a"] + "predicate": "isboiling", + "args": ["water"], + "ids": ["b"] } ] } \ No newline at end of file diff --git a/environments/env_generator/examples/base_cook.json b/environments/env_generator/examples/base_cook.json index 8ff9e2ef9..3ddb2eece 100644 --- a/environments/env_generator/examples/base_cook.json +++ b/environments/env_generator/examples/base_cook.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_cut.json b/environments/env_generator/examples/base_cut.json index dedc3af10..66906596c 100644 --- a/environments/env_generator/examples/base_cut.json +++ b/environments/env_generator/examples/base_cut.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_fill_water.json b/environments/env_generator/examples/base_fill_water.json index d46a7e289..2b9d3e41e 100644 --- a/environments/env_generator/examples/base_fill_water.json +++ b/environments/env_generator/examples/base_fill_water.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_move.json b/environments/env_generator/examples/base_move.json index a6f18c082..d704c7237 100644 --- a/environments/env_generator/examples/base_move.json +++ b/environments/env_generator/examples/base_move.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_pickup.json b/environments/env_generator/examples/base_pickup.json index b9cb46c17..1c6a0ef56 100644 --- a/environments/env_generator/examples/base_pickup.json +++ b/environments/env_generator/examples/base_pickup.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_pickup_container.json b/environments/env_generator/examples/base_pickup_container.json index 6c6ed5b30..53b154cea 100644 --- a/environments/env_generator/examples/base_pickup_container.json +++ b/environments/env_generator/examples/base_pickup_container.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_place.json b/environments/env_generator/examples/base_place.json index 0f991ead8..1cdaf43d3 100644 --- a/environments/env_generator/examples/base_place.json +++ b/environments/env_generator/examples/base_place.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_place_container.json b/environments/env_generator/examples/base_place_container.json index b178f8260..4e09091a3 100644 --- a/environments/env_generator/examples/base_place_container.json +++ b/environments/env_generator/examples/base_place_container.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_stack.json b/environments/env_generator/examples/base_stack.json index 5c627f1cb..af09b5faa 100644 --- a/environments/env_generator/examples/base_stack.json +++ b/environments/env_generator/examples/base_stack.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/base_unstack.json b/environments/env_generator/examples/base_unstack.json index 012202190..0ce5cdab1 100644 --- a/environments/env_generator/examples/base_unstack.json +++ b/environments/env_generator/examples/base_unstack.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { @@ -45,7 +46,7 @@ ], "goal": [ { - "predicate": "has", + "predicate": "has_item", "args": ["robot", "item"], "ids": [1, "a"] } diff --git a/environments/env_generator/examples/composite_add_fill_bowl.json b/environments/env_generator/examples/composite_add_fill_bowl.json index 1d626b266..729a02b35 100644 --- a/environments/env_generator/examples/composite_add_fill_bowl.json +++ b/environments/env_generator/examples/composite_add_fill_bowl.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/composite_cook_pickup.json b/environments/env_generator/examples/composite_cook_pickup.json index f3b59679b..32de17618 100644 --- a/environments/env_generator/examples/composite_cook_pickup.json +++ b/environments/env_generator/examples/composite_cook_pickup.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { @@ -44,7 +45,7 @@ "ids": ["a"] }, { - "predicate": "has", + "predicate": "has_item", "args": ["robot", "patty"], "ids": [1, "a"] } diff --git a/environments/env_generator/examples/composite_cut_pickup.json b/environments/env_generator/examples/composite_cut_pickup.json index 28773ee6f..d5630d7ce 100644 --- a/environments/env_generator/examples/composite_cut_pickup.json +++ b/environments/env_generator/examples/composite_cut_pickup.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { @@ -44,7 +45,7 @@ "ids": ["a"] }, { - "predicate": "has", + "predicate": "has_item", "args": ["robot", "lettuce"], "ids": [1, "a"] } diff --git a/environments/env_generator/examples/composite_move_cook.json b/environments/env_generator/examples/composite_move_cook.json index 0bd587b56..ad115e724 100644 --- a/environments/env_generator/examples/composite_move_cook.json +++ b/environments/env_generator/examples/composite_move_cook.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/composite_move_cut.json b/environments/env_generator/examples/composite_move_cut.json index 7985d279f..42688ac6c 100644 --- a/environments/env_generator/examples/composite_move_cut.json +++ b/environments/env_generator/examples/composite_move_cut.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/composite_move_pickup.json b/environments/env_generator/examples/composite_move_pickup.json index 2e414513e..29ca2dfeb 100644 --- a/environments/env_generator/examples/composite_move_pickup.json +++ b/environments/env_generator/examples/composite_move_pickup.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { @@ -43,7 +44,7 @@ ], "goal": [ { - "predicate": "has", + "predicate": "has_item", "args": ["robot", "item"], "ids": [1, "a"] } diff --git a/environments/env_generator/examples/composite_move_place.json b/environments/env_generator/examples/composite_move_place.json index e6d19b910..6c43ca7d6 100644 --- a/environments/env_generator/examples/composite_move_place.json +++ b/environments/env_generator/examples/composite_move_place.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { @@ -43,7 +44,7 @@ ], "goal": [ { - "predicate": "on", + "predicate": "item_on", "args": ["item", "station"], "ids": ["a", "A"] } diff --git a/environments/env_generator/examples/composite_move_stack.json b/environments/env_generator/examples/composite_move_stack.json index 1be7b2fa7..cae6a0d27 100644 --- a/environments/env_generator/examples/composite_move_stack.json +++ b/environments/env_generator/examples/composite_move_stack.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/composite_move_unstack.json b/environments/env_generator/examples/composite_move_unstack.json index 5f35a4baf..1787921bb 100644 --- a/environments/env_generator/examples/composite_move_unstack.json +++ b/environments/env_generator/examples/composite_move_unstack.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { @@ -50,7 +51,7 @@ ], "goal": [ { - "predicate": "has", + "predicate": "has_item", "args": ["robot", "patty"], "ids": [1, "a"] } diff --git a/environments/env_generator/examples/composite_place_cook.json b/environments/env_generator/examples/composite_place_cook.json index 05963ef60..773ba307c 100644 --- a/environments/env_generator/examples/composite_place_cook.json +++ b/environments/env_generator/examples/composite_place_cook.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/composite_place_cut.json b/environments/env_generator/examples/composite_place_cut.json index 5b6ed3379..2772138d8 100644 --- a/environments/env_generator/examples/composite_place_cut.json +++ b/environments/env_generator/examples/composite_place_cut.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/cook_patties.json b/environments/env_generator/examples/cook_patties.json index 3e58b9ece..1924b1ea2 100644 --- a/environments/env_generator/examples/cook_patties.json +++ b/environments/env_generator/examples/cook_patties.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/cook_soup.json b/environments/env_generator/examples/cook_soup.json index a0b1453f3..4e4cd75d7 100644 --- a/environments/env_generator/examples/cook_soup.json +++ b/environments/env_generator/examples/cook_soup.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 6, "height": 6, "config": {}, diff --git a/environments/env_generator/examples/cut_lettuces.json b/environments/env_generator/examples/cut_lettuces.json index 356339ea2..59994e968 100644 --- a/environments/env_generator/examples/cut_lettuces.json +++ b/environments/env_generator/examples/cut_lettuces.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/fry_chicken.json b/environments/env_generator/examples/fry_chicken.json index 872df2d6d..96a8bbb1c 100644 --- a/environments/env_generator/examples/fry_chicken.json +++ b/environments/env_generator/examples/fry_chicken.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/fry_potato.json b/environments/env_generator/examples/fry_potato.json index 9176bf972..21382baa3 100644 --- a/environments/env_generator/examples/fry_potato.json +++ b/environments/env_generator/examples/fry_potato.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 3, "height": 3, "config": { diff --git a/environments/env_generator/examples/high_level_assemble_burgers.json b/environments/env_generator/examples/high_level_assemble_burgers.json index 61ab1dccf..edf4eee53 100644 --- a/environments/env_generator/examples/high_level_assemble_burgers.json +++ b/environments/env_generator/examples/high_level_assemble_burgers.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 5, "height": 5, "config": { @@ -94,7 +95,7 @@ "ids": [2, 3] }, { - "predicate": "on", + "predicate": "item_on", "args": ["bottombun", "table"], "ids": [3, 4] }, @@ -109,7 +110,7 @@ "ids": [6, 7] }, { - "predicate": "on", + "predicate": "item_on", "args": ["bottombun", "table"], "ids": [7, 8] } diff --git a/environments/env_generator/examples/high_level_big_american_meal.json b/environments/env_generator/examples/high_level_big_american_meal.json index b7dad7f39..43800834f 100644 --- a/environments/env_generator/examples/high_level_big_american_meal.json +++ b/environments/env_generator/examples/high_level_big_american_meal.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 6, "height": 6, "config": { diff --git a/environments/env_generator/examples/high_level_cheese_burger.json b/environments/env_generator/examples/high_level_cheese_burger.json index b24b03725..ff569954e 100644 --- a/environments/env_generator/examples/high_level_cheese_burger.json +++ b/environments/env_generator/examples/high_level_cheese_burger.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 6, "height": 6, "config": { diff --git a/environments/env_generator/examples/high_level_chicken_burger.json b/environments/env_generator/examples/high_level_chicken_burger.json index 11b87cfc4..dd47f4802 100644 --- a/environments/env_generator/examples/high_level_chicken_burger.json +++ b/environments/env_generator/examples/high_level_chicken_burger.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 6, "height": 6, "config": { diff --git a/environments/env_generator/examples/high_level_cook_and_cut.json b/environments/env_generator/examples/high_level_cook_and_cut.json index 87dd3675d..bd5bcb9a9 100644 --- a/environments/env_generator/examples/high_level_cook_and_cut.json +++ b/environments/env_generator/examples/high_level_cook_and_cut.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 4, "height": 4, "config": { diff --git a/environments/env_generator/examples/high_level_lettuce_burger.json b/environments/env_generator/examples/high_level_lettuce_burger.json index 96daef071..3ee6d5833 100644 --- a/environments/env_generator/examples/high_level_lettuce_burger.json +++ b/environments/env_generator/examples/high_level_lettuce_burger.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 6, "height": 6, "config": { diff --git a/environments/env_generator/examples/high_level_lettuce_tomato_burger.json b/environments/env_generator/examples/high_level_lettuce_tomato_burger.json index 39f47c6c4..8cf0197de 100644 --- a/environments/env_generator/examples/high_level_lettuce_tomato_burger.json +++ b/environments/env_generator/examples/high_level_lettuce_tomato_burger.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 6, "height": 6, "config": { diff --git a/environments/env_generator/examples/high_level_two_cheese_burger.json b/environments/env_generator/examples/high_level_two_cheese_burger.json index 488481e3b..1c582d810 100644 --- a/environments/env_generator/examples/high_level_two_cheese_burger.json +++ b/environments/env_generator/examples/high_level_two_cheese_burger.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 8, "height": 8, "config": { diff --git a/environments/env_generator/examples/high_level_two_chicken_burger.json b/environments/env_generator/examples/high_level_two_chicken_burger.json index ebacf97b7..62751bbde 100644 --- a/environments/env_generator/examples/high_level_two_chicken_burger.json +++ b/environments/env_generator/examples/high_level_two_chicken_burger.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 8, "height": 8, "config": { diff --git a/environments/env_generator/examples/high_level_two_lettuce_burger.json b/environments/env_generator/examples/high_level_two_lettuce_burger.json index e83677405..f7e3ff201 100644 --- a/environments/env_generator/examples/high_level_two_lettuce_burger.json +++ b/environments/env_generator/examples/high_level_two_lettuce_burger.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 8, "height": 8, "config": { diff --git a/environments/env_generator/examples/high_level_two_lettuce_tomato_burger.json b/environments/env_generator/examples/high_level_two_lettuce_tomato_burger.json index 61a2b601b..e43c34296 100644 --- a/environments/env_generator/examples/high_level_two_lettuce_tomato_burger.json +++ b/environments/env_generator/examples/high_level_two_lettuce_tomato_burger.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 8, "height": 8, "config": { diff --git a/environments/env_generator/examples/original.json b/environments/env_generator/examples/original.json index caa6cf90c..8baa83b53 100644 --- a/environments/env_generator/examples/original.json +++ b/environments/env_generator/examples/original.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 6, "height": 6, "config": { @@ -79,7 +80,6 @@ "direction": [0, 1] } ], - "containers": [], "goal_description": "Make a lettuce burger with lettuce on top of the patty.", "goal": [ { diff --git a/environments/env_generator/examples/test_arena.json b/environments/env_generator/examples/test_arena.json index b1206dd6e..d806bb001 100644 --- a/environments/env_generator/examples/test_arena.json +++ b/environments/env_generator/examples/test_arena.json @@ -1,4 +1,5 @@ { + "version": "1.0.0", "width": 8, "height": 8, "config": { @@ -236,22 +237,22 @@ "ids": [4, 7] }, { - "predicate": "on", + "predicate": "item_on", "args": ["bread", "table"], "ids": [8, 9] }, { - "predicate": "at", + "predicate": "item_at", "args": ["onion", "table"], "ids": [3, 9] }, { - "predicate": "at", + "predicate": "item_at", "args": ["chicken", "table"], "ids": [5, 9] }, { - "predicate": "at", + "predicate": "item_at", "args": ["bread", "table"], "ids": [11, 9] }, diff --git a/environments/env_generator/object_enums.py b/environments/env_generator/object_enums.py index 8d183b7ad..ead755fb1 100644 --- a/environments/env_generator/object_enums.py +++ b/environments/env_generator/object_enums.py @@ -31,6 +31,8 @@ class Meal(Enum): BOILING_WATER = "boiling_water" SOUP = "soup" +TYPES = {"item": Item, "player": Player, "station": Station, "container": Container, "meal": Meal} + def str_to_typed_enum(s): """ Attempts to convert a string into any of the typed enums. diff --git a/robotouille/env.py b/robotouille/env.py index dc2d90b63..0c578f829 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -26,6 +26,7 @@ def build_identity_predicates(environment_dict, entity_fields): """ identity_predicates = [] for field in entity_fields: + if not environment_dict.get(field): continue for entity in environment_dict[field]: name = entity['name'] while name[-1].isdigit(): @@ -51,6 +52,7 @@ def build_container_location_predicates(environment_dict): predicates (List): Container location predicates. """ predicates = [] + if not environment_dict.get("containers"): return predicates for container in environment_dict["containers"]: container_obj = Object(container["name"], "container") match = False @@ -81,10 +83,11 @@ def build_station_location_predicates(environment_dict): predicates = [] for station in environment_dict["stations"]: station_obj = Object(station["name"], "station") - match = False for field in ["items", "players", "containers"]: + if not environment_dict.get(field): continue no_match_predicate = "station_empty" if field in ["items", "containers"] else "vacant" predicate = "item_at" if field == "items" else "container_at" if field == "containers" else "loc" + match = False for entity in environment_dict[field]: x = entity["x"] + entity["direction"][0] if field == "players" else entity["x"] y = entity["y"] + entity["direction"][1] if field == "players" else entity["y"] @@ -116,14 +119,15 @@ def build_player_location_predicates(environment_dict): for player in environment_dict["players"]: player_obj = Object(player["name"], "player") match = False - for item in environment_dict["items"]: - if player["x"] == item["x"] and player["y"] == item["y"]: - obj = Object(item["name"], "item") - pred = Predicate().initialize("has_item", ["player", "item"], [player_obj, obj]) - predicates.append(pred) - match = True - break - if not match: + if environment_dict.get("items"): + for item in environment_dict["items"]: + if player["x"] == item["x"] and player["y"] == item["y"]: + obj = Object(item["name"], "item") + pred = Predicate().initialize("has_item", ["player", "item"], [player_obj, obj]) + predicates.append(pred) + match = True + break + if not match and environment_dict.get("containers"): for container in environment_dict["containers"]: if player["x"] == container["x"] and player["y"] == container["y"]: obj = Object(container["name"], "container") @@ -175,6 +179,7 @@ def build_stacking_predicates(environment_dict): stacks = {} # Sort items into stacks ordered by stacking order sorting_key = lambda item: item["stack-level"] + if not environment_dict.get("items"): return stacking_predicates for item in environment_dict["items"]: for station in environment_dict["stations"]: if item["x"] == station["x"] and item["y"] == station["y"]: @@ -271,6 +276,7 @@ def build_state(domain_json, environment_json): objects = [] for field in entity_fields: + if environment_json.get(field) is None: continue for entity in environment_json[field]: objects.append(Object(entity["name"], field[:-1])) From 5ff34f3dfbc48bbbcfe12ddf9cf17b30e6b7e876 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Thu, 28 Mar 2024 15:32:45 -0400 Subject: [PATCH 10/41] Made server also render game (helps debug) --- robotouille/robotouille_simulator.py | 36 ++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index a86fed3d1..b0ced605a 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -21,24 +21,30 @@ async def simulator(websocket): print("Hello client", websocket) env, json_data, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) obs, info = env.reset() + renderer.render(obs, mode='human') done = False interactive = False # Adjust based on client commands later if needed + try: + while not done: + action_message = await websocket.recv() + encoded_action, encoded_args = json.loads(action_message) + action = pickle.loads(base64.b64decode(encoded_action)) + args = pickle.loads(base64.b64decode(encoded_args)) + #print((action, args)) + #time.sleep(0.25) - while not done: - action_message = await websocket.recv() - encoded_action, encoded_args = json.loads(action_message) - action = pickle.loads(base64.b64decode(encoded_action)) - args = pickle.loads(base64.b64decode(encoded_args)) - #print((action, args)) - #time.sleep(0.5) - - obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) - # Convert obs to a suitable format to send over the network - obs_data = pickle.dumps(obs) - encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') - await websocket.send(json.dumps({"obs": encoded_obs_data, "reward": reward, "done": done, "info": info})) - - print("GG") + obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) + # Convert obs to a suitable format to send over the network + obs_data = pickle.dumps(obs) + encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') + #time.sleep(0.25) + await websocket.send(json.dumps({"obs": encoded_obs_data, "reward": reward, "done": done, "info": info})) + renderer.render(obs, mode='human') + except e: + pass + finally: + renderer.render(obs, close=True) + print("GG") start_server = websockets.serve(simulator, "localhost", 8765) From 34c3714ffdb461c270f3d99d70248b5e341c558a Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Thu, 28 Mar 2024 16:37:03 -0400 Subject: [PATCH 11/41] Client runs responsive local environment with rollback support --- robotouille/robotouille_simulator.py | 101 ++++++++++++++++----------- 1 file changed, 59 insertions(+), 42 deletions(-) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index b0ced605a..fa4129fb4 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -31,17 +31,25 @@ async def simulator(websocket): action = pickle.loads(base64.b64decode(encoded_action)) args = pickle.loads(base64.b64decode(encoded_args)) #print((action, args)) - #time.sleep(0.25) - - obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) - # Convert obs to a suitable format to send over the network - obs_data = pickle.dumps(obs) - encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') - #time.sleep(0.25) - await websocket.send(json.dumps({"obs": encoded_obs_data, "reward": reward, "done": done, "info": info})) - renderer.render(obs, mode='human') - except e: - pass + time.sleep(0.25) + + reply = None + + try: + obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) + reply = json.dumps({"valid": True, "done": done}) + renderer.render(obs, mode='human') + except AssertionError: + env_data = pickle.dumps(env.get_state()) + encoded_env_data = base64.b64encode(env_data).decode('utf-8') + obs_data = pickle.dumps(obs) + encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') + reply = json.dumps({"valid": False, "env": encoded_env_data, "obs": encoded_obs_data, "done": False}) + + time.sleep(0.25) + await websocket.send(reply) + except BaseException as e: + print(e) finally: renderer.render(obs, close=True) print("GG") @@ -51,41 +59,50 @@ async def simulator(websocket): asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever() -def client_loop(environment_name: str, seed: int=42, host="ws://localhost:8765", noisy_randomization: bool=False): +def client_loop(environment_name: str, seed: int = 42, host="ws://localhost:8765", noisy_randomization: bool = False): uri = host + async def send_actions(websocket, shared_state): + env = shared_state["env"] + renderer = shared_state["renderer"] + renderer.render(shared_state["obs"], mode='human') + online = True + while not shared_state["done"]: + pygame_events = pygame.event.get() + mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) + keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) + action, args = create_action_from_control(env, shared_state["obs"], mousedown_events + keydown_events, renderer) + + online = not (pygame.key.get_mods() & pygame.KMOD_CAPS) + + if action is not None: + if online: + encoded_action = base64.b64encode(pickle.dumps(action)).decode('utf-8') + encoded_args = base64.b64encode(pickle.dumps(args)).decode('utf-8') + await websocket.send(json.dumps((encoded_action, encoded_args))) + shared_state["obs"], reward, done, info = env.step(action=action, args=args, interactive=True) + renderer.render(env.get_state(), mode='human') + + await asyncio.sleep(0) # Yield control to allow other tasks to run + + async def receive_responses(websocket, shared_state): + while not shared_state["done"]: + response = await websocket.recv() + data = json.loads(response) + shared_state["done"] = data["done"] + + if(not data["valid"]): + shared_state["env"].set_state(pickle.loads(base64.b64decode(data["env"]))) + shared_state["obs"] = pickle.loads(base64.b64decode(data["obs"])) + async def interact_with_server(): async with websockets.connect(uri) as websocket: env, _, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) obs, info = env.reset() - renderer.render(obs, mode='human') - done = False - interactive = False # Set to True to interact with the environment through terminal REPL (ignores input) - - while True: - pygame_events = pygame.event.get() - # Mouse clicks for movement and pick/place stack/unstack - mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) - # Keyboard events ('e' button) for cut/cook ('space' button) for noop - keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) - # Assume the action can be created despite not updating environment - action, args = create_action_from_control(env, obs, mousedown_events + keydown_events, renderer) - - if action is None: - continue - - #print((action, args)) - encoded_action = base64.b64encode(pickle.dumps(action)).decode('utf-8') - encoded_args = base64.b64encode(pickle.dumps(args)).decode('utf-8') - await websocket.send(json.dumps((encoded_action, encoded_args))) - - response = await websocket.recv() - data = json.loads(response) - encoded_obs_data, reward, done, info = data["obs"], data["reward"], data["done"], data["info"] - obs = pickle.loads(base64.b64decode(encoded_obs_data)) - renderer.render(obs, mode='human') - if done: - break - renderer.render(obs, close=True) + shared_state = {"done": False, "env": env, "renderer": renderer, "obs": obs} + sender = asyncio.create_task(send_actions(websocket, shared_state)) + receiver = asyncio.create_task(receive_responses(websocket, shared_state)) + await asyncio.gather(sender, receiver) + # Additional cleanup if necessary - asyncio.get_event_loop().run_until_complete(interact_with_server()) + asyncio.get_event_loop().run_until_complete(interact_with_server()) \ No newline at end of file From 0eba4519c68c38655b0276976a7066e22c96b9e4 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 8 Apr 2024 16:34:46 -0400 Subject: [PATCH 12/41] updated based on comments --- backend/special_effects/deletion_effect.py | 2 +- backend/state.py | 28 +++++++++++++--- domain/domain_builder.py | 33 +++++++++---------- environments/env_generator/builder.py | 38 +++++++++++----------- renderer/canvas.py | 3 ++ robotouille/env.py | 3 +- 6 files changed, 64 insertions(+), 43 deletions(-) diff --git a/backend/special_effects/deletion_effect.py b/backend/special_effects/deletion_effect.py index 9289c43de..561819a47 100644 --- a/backend/special_effects/deletion_effect.py +++ b/backend/special_effects/deletion_effect.py @@ -4,7 +4,7 @@ class DeletionEffect(SpecialEffect): """ This class represents deletion effects in Robotouille. - A creation effect is an effect that delets an object in the state. + A creation effect is an effect that deletes an object in the state. """ def __init__(self, param, effects, special_effects, arg=None): diff --git a/backend/state.py b/backend/state.py index 8e1647798..cbfef698a 100644 --- a/backend/state.py +++ b/backend/state.py @@ -241,7 +241,11 @@ def update_special_effect(self, special_effect, arg, param_arg_dict): def _get_next_ID_for_object(self, obj): """ - Gets the next available ID for an object. + This helper function finds all objects in the current state with the + same name as the added object and returns the next available ID for the + object. IDs are still not reused even if objects have been deleted, + because the function always finds the greatest current ID and increments + it by 1. Args: obj (Object): The object to get the next available ID for. @@ -249,23 +253,31 @@ def _get_next_ID_for_object(self, obj): Returns: int: The next available ID for the object. """ - num = 0 + max_id = 0 for object in self.objects: name, id = trim_item_ID(object.name) if name == obj.name: - num = max(num, id) - return num+1 + max_id = max(max_id, id) + return max_id + 1 def add_object(self, obj): """ Adds an object to the state. Args: - obj (Object): The object to add to the state. + obj (Object): The object to add to the state. + + Side effects: + - The argument obj is given an id + - The objects, predicates, and actions in the state are modified and + updated to account for the new object. """ num = self._get_next_ID_for_object(obj) obj.name = f"{obj.name}{num}" + # TODO(lsuyean): create field to store ID instead of modifying name self.objects.append(obj) + # TODO(lsuyean): optimize creating predicates and actions; only add + # necessary predicates and actions instead of building from scratch true_predicates = {predicate for predicate, value in self.predicates.items() if value} self.predicates = self._build_predicates(self.domain, self.objects, true_predicates) self.actions = self._build_actions(self.domain, self.objects) @@ -276,8 +288,14 @@ def delete_object(self, obj): Args: obj (Object): The object to delete from the state. + + Side effects: + - The objects, predicates, and actions in the state are modified and + updated to account for the deleted object. """ self.objects.remove(obj) + # TODO(lsuyean): optimize creating predicates and actions; only delete + # necessary predicates and actions instead of building from scratch true_predicates = {predicate for predicate, value in self.predicates.items() if value} self.predicates = self._build_predicates(self.domain, self.objects, true_predicates) self.actions = self._build_actions(self.domain, self.objects) diff --git a/domain/domain_builder.py b/domain/domain_builder.py index df80a4e7d..6860afa7c 100644 --- a/domain/domain_builder.py +++ b/domain/domain_builder.py @@ -28,30 +28,28 @@ def _build_predicate_defs(input_json): return predicate_defs -def _build_pred_list(key, param_objs, dict, predicate_dict): +def _build_pred_list(defn, param_objs, predicate_dict): """ This function builds a list of predicates from a JSON input. This is used in building actions and special effects, where their preconditions, immediate effects, or conditions are defined as a list of predicates. Args: - key (str): The key for the list or predicates being built - (e.g. "precons"). + defn (List[Dictionary[str, any]]): A list of predicate definitions. param_objs (Dictionary[str, Object]): A dictionary whose keys are parameter names and the values are placeholder objects. - dict (Dictionary): The dictionary containing the predicates to be built. predicate_dict (Dictionary[str, Predicate]): The predicate dictionary. Returns: - precons_or_effects (Dictionary[Predicate, bool]): The preconditions or - immediate effects of the action. + predicates (Dictionary[Predicate, bool]): A predicate dictionary built + based on the json input. Side Effects: - Updates 'param_objs' in-place with parameter objects. """ precons_or_effects = {} - for precon_or_effect in dict[key]: + for precon_or_effect in defn: pred = predicate_dict[precon_or_effect["predicate"]] params = [] for i, param in enumerate(precon_or_effect["params"]): @@ -64,12 +62,12 @@ def _build_pred_list(key, param_objs, dict, predicate_dict): return precons_or_effects -def _build_special_effects(dict, param_objs, predicate_dict): +def _build_special_effects(defn, param_objs, predicate_dict): """ This function builds special effects. Args: - dict (Dictionary): The dictionary to build the special effects from. + defn (List[Dictionary[str, any]]): A list of special effect definitions. param_objs (Dictionary[str, Object]): A dictionary whose keys are parameter names and the values are placeholder objects. predicate_dict (Dictionary[str, Predicate]): The predicate dictionary. @@ -82,11 +80,11 @@ def _build_special_effects(dict, param_objs, predicate_dict): """ special_effects = [] - for special_effect in dict: + for special_effect in defn: param_name = special_effect["param"] param_obj = param_objs[param_name] effects = _build_pred_list( - "fx", param_objs, special_effect, predicate_dict) + special_effect["fx"], param_objs, predicate_dict) nested_sfx = _build_special_effects(special_effect["sfx"], param_objs, predicate_dict) if special_effect["type"] == "delayed": # TODO: The values for goal repetitions/time should be decided by the problem json @@ -95,12 +93,13 @@ def _build_special_effects(dict, param_objs, predicate_dict): sfx = RepetitiveEffect(param_obj, effects, nested_sfx) elif special_effect["type"] == "conditional": conditions = _build_pred_list( - "conditions", param_objs, special_effect, predicate_dict) + special_effect["conditions"], param_objs, predicate_dict) sfx = ConditionalEffect(param_obj, effects, nested_sfx, conditions) elif special_effect["type"] == "creation": - created_obj_name = special_effect["created_obj"]["name"] - created_obj_type = special_effect["created_obj"]["type"] - created_obj_param = special_effect["created_obj"]["param"] + created_obj_attrs = special_effect["created_obj"] + created_obj_name = created_obj_attrs["name"] + created_obj_type = created_obj_attrs["type"] + created_obj_param = created_obj_attrs["param"] created_obj = Object(created_obj_name, created_obj_type) sfx = CreationEffect(param_obj, (created_obj_param, created_obj), effects, nested_sfx) elif special_effect["type"] == "deletion": @@ -129,9 +128,9 @@ def _build_action_defs(input_json, predicate_defs): for action in input_json["action_defs"]: name = action["name"] precons = _build_pred_list( - "precons", param_objs, action, predicate_dict) + action["precons"], param_objs, predicate_dict) immediate_effects = _build_pred_list( - "immediate_fx", param_objs, action, predicate_dict) + action["immediate_fx"], param_objs, predicate_dict) special_effects = _build_special_effects( action["sfx"], param_objs, predicate_dict) action_def = Action(name, precons, immediate_effects, special_effects) diff --git a/environments/env_generator/builder.py b/environments/env_generator/builder.py index ce0c7fd2f..46a7808fa 100644 --- a/environments/env_generator/builder.py +++ b/environments/env_generator/builder.py @@ -73,14 +73,13 @@ def load_environment(json_filename, seed=None): with open(os.path.join(EXAMPLES_DIR, json_filename), "r") as f: environment_json = json.load(f) sorting_key = lambda entity: (entity["x"], entity["y"]) - environment_json["stations"].sort(key=sorting_key) # TODO: Breaks seed that gives consistent layout - for field in ENTITY_FIELDS: - if not environment_json.get(field): continue + valid_entity_fields = [field for field in ENTITY_FIELDS if field in environment_json] + for field in valid_entity_fields: + environment_json[field].sort(key=sorting_key) for entity in environment_json[field]: if entity["name"] == field[:-1]: entity["name"] = random.choice(list(TYPES[field[:-1]])).value - environment_json[field].sort(key=sorting_key) return environment_json def build_objects(environment_dict): @@ -98,8 +97,8 @@ def build_objects(environment_dict): """ objects_str = "" updated_environment_dict = copy.deepcopy(environment_dict) - for field in ENTITY_FIELDS: - if not environment_dict.get(field): continue + valid_entity_fields = [field for field in ENTITY_FIELDS if field in environment_dict] + for field in valid_entity_fields: object_type = field[:-1] seen = {} updated_environment_dict[field].sort(key=lambda entity: (entity["x"], entity["y"])) @@ -126,7 +125,8 @@ def build_identity_predicates(environment_dict): identity_predicates_str (str): PDDL identity predicates string. """ identity_predicates_str = "" - for field in ENTITY_FIELDS: + valid_entity_fields = [field for field in ENTITY_FIELDS if field in environment_dict] + for field in valid_entity_fields: for entity in environment_dict[field]: typed_enum = entity['typed_enum'] name = entity['name'] @@ -401,19 +401,19 @@ def build_problem(environment_dict): new_environment_dict (dict): Dictionary containing IDed stations, items, and player location. """ problem = "(define (problem robotouille)\n" - # problem += "(:domain robotouille)\n" - # problem += "(:objects\n" + problem += "(:domain robotouille)\n" + problem += "(:objects\n" objects_str, new_environment_dict = build_objects(environment_dict) - # problem += objects_str - # problem += ")\n" - # problem += "(:init\n" - # problem += build_identity_predicates(new_environment_dict) - # problem += build_location_predicates(new_environment_dict) - # problem += build_stacking_predicates(new_environment_dict) - # problem += ")\n" - # problem += "(:goal\n" - # problem += build_goal(new_environment_dict) - # problem += ")\n" + problem += objects_str + problem += ")\n" + problem += "(:init\n" + problem += build_identity_predicates(new_environment_dict) + problem += build_location_predicates(new_environment_dict) + problem += build_stacking_predicates(new_environment_dict) + problem += ")\n" + problem += "(:goal\n" + problem += build_goal(new_environment_dict) + problem += ")\n" return problem, new_environment_dict def write_problem_file(problem, filename): diff --git a/renderer/canvas.py b/renderer/canvas.py index 6198a7028..a78514f56 100644 --- a/renderer/canvas.py +++ b/renderer/canvas.py @@ -422,6 +422,9 @@ def _draw_container(self, surface, obs): Args: surface (pygame.Surface): Surface to draw on obs (State): Game state predicates + + Side effects: + Draws the containers to surface """ station_container_offset = self.config["container"]["constants"]["STATION_CONTAINER_OFFSET"] diff --git a/robotouille/env.py b/robotouille/env.py index 0c578f829..fc2fe7197 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -25,7 +25,8 @@ def build_identity_predicates(environment_dict, entity_fields): identity_predicates (List): Identity predicates list. """ identity_predicates = [] - for field in entity_fields: + valid_entity_fields = [field for field in entity_fields if field in environment_dict.keys()] + for field in valid_entity_fields: if not environment_dict.get(field): continue for entity in environment_dict[field]: name = entity['name'] From 5d3cea255c2f83531151a6c1379aa26ae8292937 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:19:18 -0400 Subject: [PATCH 13/41] Server now records gameplay --- robotouille/robotouille_simulator.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index fa4129fb4..dbd2eebc7 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -7,6 +7,8 @@ import base64 import websockets import time +from pathlib import Path +import datetime def simulator(environment_name: str, seed: int=42, role="client", host="ws://localhost:8765", noisy_randomization: bool=False): @@ -19,6 +21,12 @@ def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=F print("I am server") async def simulator(websocket): print("Hello client", websocket) + recording = {} + recording["start_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + recording["actions"] = [] + recording["violations"] = [] + start_time = time.monotonic() + env, json_data, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) obs, info = env.reset() renderer.render(obs, mode='human') @@ -37,9 +45,11 @@ async def simulator(websocket): try: obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) + recording["actions"].append((action, time.monotonic() - start_time)) reply = json.dumps({"valid": True, "done": done}) renderer.render(obs, mode='human') except AssertionError: + recording["violations"].append((action, time.monotonic() - start_time)) env_data = pickle.dumps(env.get_state()) encoded_env_data = base64.b64encode(env_data).decode('utf-8') obs_data = pickle.dumps(obs) @@ -48,11 +58,19 @@ async def simulator(websocket): time.sleep(0.25) await websocket.send(reply) + recording["result"] = "done" except BaseException as e: print(e) + recording["result"] = e finally: renderer.render(obs, close=True) print("GG") + recording["end_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + + p = Path('recordings') + p.mkdir(exist_ok=True) + with open(p / (recording["start_time"] + '.pkl'), 'wb') as f: + pickle.dump(recording, f) start_server = websockets.serve(simulator, "localhost", 8765) From 704df1f729c4e909e6eb565c52e4b7ee6953cbc3 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:48:24 -0400 Subject: [PATCH 14/41] Recordings can now be replayed --- .gitignore | 3 ++- main.py | 5 ++-- robotouille/robotouille_simulator.py | 34 ++++++++++++++++++++++++---- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 2c9084b0b..0dbeef6f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /venv/ **/__pycache__/ *-env -robotouille.egg-info \ No newline at end of file +robotouille.egg-info +recordings \ No newline at end of file diff --git a/main.py b/main.py index 90afd5ae9..575a184dc 100644 --- a/main.py +++ b/main.py @@ -4,9 +4,10 @@ parser = argparse.ArgumentParser() parser.add_argument("--environment_name", help="The name of the environment to create.", default="original") parser.add_argument("--seed", help="The seed to use for the environment.", default=None) -parser.add_argument("--role", help="\"client\" if client, \"server\" if server.", default="client") +parser.add_argument("--role", help="\"client\" if client, \"server\" if server, \"replay\" if replaying.", default="client") parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765") +parser.add_argument("--replay", help="Recording to replay", default="") parser.add_argument("--noisy_randomization", action="store_true", help="Whether to use 'noisy randomization' for procedural generation") args = parser.parse_args() -simulator(args.environment_name, args.seed, args.role, args.host, args.noisy_randomization) +simulator(args.environment_name, args.seed, args.role, args.host, args.replay, args.noisy_randomization) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index dbd2eebc7..134aa0442 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -8,14 +8,18 @@ import websockets import time from pathlib import Path -import datetime +from datetime import datetime -def simulator(environment_name: str, seed: int=42, role="client", host="ws://localhost:8765", noisy_randomization: bool=False): +def simulator(environment_name: str, seed: int=42, role="client", host="ws://localhost:8765", recording="", noisy_randomization: bool=False): + if recording != "": + role = "replay" if role == "server": server_loop(environment_name, seed, noisy_randomization) - else: + elif role == "client": client_loop(environment_name, seed, host, noisy_randomization) + else: + replay(recording) def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False): print("I am server") @@ -23,6 +27,9 @@ async def simulator(websocket): print("Hello client", websocket) recording = {} recording["start_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + recording["environment_name"] = environment_name + recording["seed"] = seed + recording["noisy_randomization"] = noisy_randomization recording["actions"] = [] recording["violations"] = [] start_time = time.monotonic() @@ -45,7 +52,7 @@ async def simulator(websocket): try: obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) - recording["actions"].append((action, time.monotonic() - start_time)) + recording["actions"].append((action, args, env.get_state(), time.monotonic() - start_time)) reply = json.dumps({"valid": True, "done": done}) renderer.render(obs, mode='human') except AssertionError: @@ -123,4 +130,21 @@ async def interact_with_server(): await asyncio.gather(sender, receiver) # Additional cleanup if necessary - asyncio.get_event_loop().run_until_complete(interact_with_server()) \ No newline at end of file + asyncio.get_event_loop().run_until_complete(interact_with_server()) + +def replay(recording_name): + p = Path('recordings') + with open(p / (recording_name + '.pkl'), 'rb') as f: + recording = pickle.load(f) + + env, _, renderer = create_robotouille_env(recording["environment_name"], recording["seed"], recording["noisy_randomization"]) + obs, _ = env.reset() + renderer.render(obs, mode='human') + + previous_time = 0 + for action, args, state, t in recording["actions"]: + time.sleep(t - previous_time) + previous_time = t + obs, reward, done, info = env.step(action=action, args=args, interactive=False) + renderer.render(obs, mode='human') + renderer.render(obs, close=True) From bc539fe8511a9b8fd08f6e6ebf80b78e76a702af Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:51:35 -0400 Subject: [PATCH 15/41] Added recording video rendering --- main.py | 2 +- requirements.txt | 1 + robotouille/robotouille_simulator.py | 37 ++++++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 575a184dc..d870d0689 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ parser = argparse.ArgumentParser() parser.add_argument("--environment_name", help="The name of the environment to create.", default="original") parser.add_argument("--seed", help="The seed to use for the environment.", default=None) -parser.add_argument("--role", help="\"client\" if client, \"server\" if server, \"replay\" if replaying.", default="client") +parser.add_argument("--role", help="\"client\" if client, \"server\" if server, \"replay\" if replaying, \"render\" if rendering video", default="client") parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765") parser.add_argument("--replay", help="Recording to replay", default="") parser.add_argument("--noisy_randomization", action="store_true", help="Whether to use 'noisy randomization' for procedural generation") diff --git a/requirements.txt b/requirements.txt index c60824bbe..26d896538 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,4 @@ scipy==1.10.0 six==1.16.0 tifffile==2023.2.3 zipp==3.14.0 +imageio-ffmpeg==0.4.9 diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 134aa0442..035710a0b 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -9,17 +9,20 @@ import time from pathlib import Path from datetime import datetime +import imageio def simulator(environment_name: str, seed: int=42, role="client", host="ws://localhost:8765", recording="", noisy_randomization: bool=False): - if recording != "": + if recording != "" and role != "replay" and role != "render": role = "replay" if role == "server": server_loop(environment_name, seed, noisy_randomization) elif role == "client": client_loop(environment_name, seed, host, noisy_randomization) - else: + elif role == "replay": replay(recording) + elif role == "render": + render(recording) def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False): print("I am server") @@ -148,3 +151,33 @@ def replay(recording_name): obs, reward, done, info = env.step(action=action, args=args, interactive=False) renderer.render(obs, mode='human') renderer.render(obs, close=True) + +def render(recording_name): + p = Path('recordings') + with open(p / (recording_name + '.pkl'), 'rb') as f: + recording = pickle.load(f) + + env, _, renderer = create_robotouille_env(recording["environment_name"], recording["seed"], recording["noisy_randomization"]) + obs, _ = env.reset() + frame = renderer.render(obs, mode='rgb_array') + + vp = Path('recordings') + vp.mkdir(exist_ok=True) + fps = 20 + video_writer = imageio.get_writer(vp / (recording_name + '.mp4'), fps=fps) + + i = 0 + t = 0 + while i < len(recording["actions"]): + action, args, state, time_stamp = recording["actions"][i] + while t > time_stamp: + obs, reward, done, info = env.step(action=action, args=args, interactive=False) + frame = renderer.render(obs, mode='rgb_array') + i += 1 + if i >= len(recording["actions"]): + break + action, args, state, time_stamp = recording["actions"][i] + t += 1 / fps + video_writer.append_data(frame) + renderer.render(obs, close=True) + video_writer.close() \ No newline at end of file From 304560833988bd39777529694ad0c26b373673ce Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Wed, 10 Apr 2024 14:59:14 -0400 Subject: [PATCH 16/41] edited goal such that it can include objects not in initial state --- backend/state.py | 12 +++----- backend/tests/test_envs.py | 7 +++++ domain/robotouille.json | 5 ++++ environments/env_generator/builder.py | 29 ++++++++++--------- .../examples/base_add_to_soup.json | 6 ++-- .../examples/base_fill_water.json | 6 ++-- .../examples/composite_add_fill_bowl.json | 11 +++++-- .../env_generator/examples/cook_soup.json | 14 ++++++--- 8 files changed, 55 insertions(+), 35 deletions(-) diff --git a/backend/state.py b/backend/state.py index cbfef698a..490fad21e 100644 --- a/backend/state.py +++ b/backend/state.py @@ -161,8 +161,8 @@ def initialize(self, domain, objects, true_predicates, all_goals, special_effect # check if goal predicates are defined in domain for goal_set in all_goals: for goal in goal_set: - if goal not in predicates: - raise ValueError(f"Predicate {goal} is not defined in the domain.") + if goal.name not in list(map(lambda x: x.name, domain.predicates)): + raise ValueError(f"Predicate {goal.name} is not defined in the domain.") if not domain.are_valid_object_types(goal.types): raise ValueError(f"Types {goal.types} are not defined in the domain.") @@ -198,13 +198,9 @@ def get_predicate_value(self, predicate): value (bool): The value of the predicate to check for. Returns: - bool: True if the predicate is True in the state, False otherwise. - - Raises: - AssertionError: If the predicate is not in the state. + bool: True if the predicate is True in the state, False otherwise. """ - assert predicate in self.predicates - return self.predicates[predicate] + return self.predicates[predicate] if predicate in self.predicates else False def update_predicate(self, predicate, value): """ diff --git a/backend/tests/test_envs.py b/backend/tests/test_envs.py index 4113f7dcb..21fbeec14 100644 --- a/backend/tests/test_envs.py +++ b/backend/tests/test_envs.py @@ -4,15 +4,21 @@ ALL_TESTS = { 'base tests':[ + 'base_add_to_soup', + 'base_boil_water', 'base_cook', 'base_cut', + 'base_fill_water', 'base_move', + 'base_pickup_container', 'base_pickup', + 'base_place_container', 'base_place', 'base_stack', 'base_unstack' ], 'composite tests':[ + 'composite_add_fill_bowl', 'composite_cook_pickup', 'composite_cut_pickup', 'composite_move_cook', @@ -26,6 +32,7 @@ ], 'high level tests':[ 'cook_patties', + 'cook_soup', 'cut_lettuces', 'fry_chicken', 'fry_potato', diff --git a/domain/robotouille.json b/domain/robotouille.json index f7a3b250e..a158de87a 100644 --- a/domain/robotouille.json +++ b/domain/robotouille.json @@ -781,6 +781,11 @@ "params": ["p1", "s1"], "is_true": true }, + { + "predicate": "container_at", + "params": ["c1", "s1"], + "is_true": true + }, { "predicate": "has_item", "params": ["p1", "i1"], diff --git a/environments/env_generator/builder.py b/environments/env_generator/builder.py index 46a7808fa..b38388e84 100644 --- a/environments/env_generator/builder.py +++ b/environments/env_generator/builder.py @@ -300,13 +300,14 @@ def create_unique_and_combination_preds(environment_dict): # Get all entities to prepare the combination arg_entities = list(filter(lambda entity: arg in entity["name"], environment_dict[entity_field])) arg_entity_names = list(map(lambda entity: entity["name"], arg_entities)) - combination_dict[arg]['entities'] = arg_entity_names + combination_dict[arg]['entities'] = arg_entity_names if arg_entity_names else [arg + "1"] combination_dict[arg]['ids'] = set() combination_dict[arg]['ids'].add(arg_id) else: # Unique predicate same_id_entity = list(filter(lambda entity: entity.get("id") == arg_id, environment_dict[entity_field])) - entity_name = same_id_entity[0]["name"] + # If the entity is not found, then it is a wild card entity + entity_name = arg + "1" if not same_id_entity else same_id_entity[0]["name"] pred.append(entity_name) if unique_pred: unique_preds.append(pred) @@ -401,19 +402,19 @@ def build_problem(environment_dict): new_environment_dict (dict): Dictionary containing IDed stations, items, and player location. """ problem = "(define (problem robotouille)\n" - problem += "(:domain robotouille)\n" - problem += "(:objects\n" + # problem += "(:domain robotouille)\n" + # problem += "(:objects\n" objects_str, new_environment_dict = build_objects(environment_dict) - problem += objects_str - problem += ")\n" - problem += "(:init\n" - problem += build_identity_predicates(new_environment_dict) - problem += build_location_predicates(new_environment_dict) - problem += build_stacking_predicates(new_environment_dict) - problem += ")\n" - problem += "(:goal\n" - problem += build_goal(new_environment_dict) - problem += ")\n" + # problem += objects_str + # problem += ")\n" + # problem += "(:init\n" + # problem += build_identity_predicates(new_environment_dict) + # problem += build_location_predicates(new_environment_dict) + # problem += build_stacking_predicates(new_environment_dict) + # problem += ")\n" + # problem += "(:goal\n" + # problem += build_goal(new_environment_dict) + # problem += ")\n" return problem, new_environment_dict def write_problem_file(problem, filename): diff --git a/environments/env_generator/examples/base_add_to_soup.json b/environments/env_generator/examples/base_add_to_soup.json index d20c49714..7396b7b97 100644 --- a/environments/env_generator/examples/base_add_to_soup.json +++ b/environments/env_generator/examples/base_add_to_soup.json @@ -56,9 +56,9 @@ ], "goal": [ { - "predicate": "isbowl", - "args": ["pot"], - "ids": ["a"] + "predicate": "addedto", + "args": ["tomato", "water"], + "ids": ["c", "b"] } ] } \ No newline at end of file diff --git a/environments/env_generator/examples/base_fill_water.json b/environments/env_generator/examples/base_fill_water.json index 2b9d3e41e..cb74a98ae 100644 --- a/environments/env_generator/examples/base_fill_water.json +++ b/environments/env_generator/examples/base_fill_water.json @@ -41,9 +41,9 @@ ], "goal": [ { - "predicate": "isbowl", - "args": ["pot"], - "ids": ["a"] + "predicate": "in", + "args": ["water", "pot"], + "ids": [1, 2] } ] } \ No newline at end of file diff --git a/environments/env_generator/examples/composite_add_fill_bowl.json b/environments/env_generator/examples/composite_add_fill_bowl.json index 729a02b35..2c1400fcb 100644 --- a/environments/env_generator/examples/composite_add_fill_bowl.json +++ b/environments/env_generator/examples/composite_add_fill_bowl.json @@ -67,9 +67,14 @@ ], "goal": [ { - "predicate": "iscooked", - "args": ["patty"], - "ids": ["a"] + "predicate": "addedto", + "args": ["tomato", "water"], + "ids": ["a", "c"] + }, + { + "predicate": "in", + "args": ["water", "bowl"], + "ids": ["b", "d"] } ] } \ No newline at end of file diff --git a/environments/env_generator/examples/cook_soup.json b/environments/env_generator/examples/cook_soup.json index 4e4cd75d7..677721cf2 100644 --- a/environments/env_generator/examples/cook_soup.json +++ b/environments/env_generator/examples/cook_soup.json @@ -75,7 +75,8 @@ { "name": "bowl", "x": 0, - "y": 3 + "y": 3, + "id": "b" } ], "players": [ @@ -89,9 +90,14 @@ "goal_description": "", "goal": [ { - "predicate": "isbowl", - "args": ["pot"], - "ids": ["a"] + "predicate": "in", + "args": ["water", "bowl"], + "ids": ["c", "b"] + }, + { + "predicate": "addedto", + "args": ["tomato", "water"], + "ids": [1, 2] } ] } \ No newline at end of file From 9c5710a254de5ab807d4596546df62798fc97cbe Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Thu, 11 Apr 2024 20:35:13 -0400 Subject: [PATCH 17/41] Added docker support --- Dockerfile | 7 ++++++ main.py | 3 ++- requirements.txt | 3 ++- robotouille/robotouille_simulator.py | 34 ++++++++++++++++++---------- 4 files changed, 33 insertions(+), 14 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..dc4b7c986 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.9.6 + +COPY . . + +RUN pip install -r requirements.txt + +CMD python main.py --role server \ No newline at end of file diff --git a/main.py b/main.py index d870d0689..2ff82477e 100644 --- a/main.py +++ b/main.py @@ -5,9 +5,10 @@ parser.add_argument("--environment_name", help="The name of the environment to create.", default="original") parser.add_argument("--seed", help="The seed to use for the environment.", default=None) parser.add_argument("--role", help="\"client\" if client, \"server\" if server, \"replay\" if replaying, \"render\" if rendering video", default="client") +parser.add_argument("--server_display", action="store_true", help="Whether to show the game window as server (ignored for other roles)") parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765") parser.add_argument("--replay", help="Recording to replay", default="") parser.add_argument("--noisy_randomization", action="store_true", help="Whether to use 'noisy randomization' for procedural generation") args = parser.parse_args() -simulator(args.environment_name, args.seed, args.role, args.host, args.replay, args.noisy_randomization) +simulator(args.environment_name, args.seed, args.role, args.server_display, args.host, args.replay, args.noisy_randomization) diff --git a/requirements.txt b/requirements.txt index 26d896538..6bbd8d722 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ fonttools==4.38.0 gym==0.26.2 gym-notices==0.0.8 imageio==2.25.1 +imageio-ffmpeg==0.4.9 importlib-metadata==6.0.0 importlib-resources==5.12.0 kiwisolver==1.4.4 @@ -21,5 +22,5 @@ scikit-image==0.19.3 scipy==1.10.0 six==1.16.0 tifffile==2023.2.3 +websockets==12.0 zipp==3.14.0 -imageio-ffmpeg==0.4.9 diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 035710a0b..19d3cf713 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -11,12 +11,14 @@ from datetime import datetime import imageio +SIMULATE_LATENCY = False +SIMULATED_LATENCY_DURATION = 0.25 -def simulator(environment_name: str, seed: int=42, role="client", host="ws://localhost:8765", recording="", noisy_randomization: bool=False): +def simulator(environment_name: str, seed: int=42, role: str="client", display_server: bool=False, host: str="ws://localhost:8765", recording: str="", noisy_randomization: bool=False): if recording != "" and role != "replay" and role != "render": role = "replay" if role == "server": - server_loop(environment_name, seed, noisy_randomization) + server_loop(environment_name, seed, noisy_randomization, display_server) elif role == "client": client_loop(environment_name, seed, host, noisy_randomization) elif role == "replay": @@ -24,7 +26,7 @@ def simulator(environment_name: str, seed: int=42, role="client", host="ws://loc elif role == "render": render(recording) -def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False): +def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False, display_server: bool=False): print("I am server") async def simulator(websocket): print("Hello client", websocket) @@ -39,7 +41,8 @@ async def simulator(websocket): env, json_data, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) obs, info = env.reset() - renderer.render(obs, mode='human') + if display_server: + renderer.render(obs, mode='human') done = False interactive = False # Adjust based on client commands later if needed try: @@ -49,7 +52,8 @@ async def simulator(websocket): action = pickle.loads(base64.b64decode(encoded_action)) args = pickle.loads(base64.b64decode(encoded_args)) #print((action, args)) - time.sleep(0.25) + if SIMULATE_LATENCY: + time.sleep(SIMULATED_LATENCY_DURATION) reply = None @@ -57,7 +61,8 @@ async def simulator(websocket): obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) recording["actions"].append((action, args, env.get_state(), time.monotonic() - start_time)) reply = json.dumps({"valid": True, "done": done}) - renderer.render(obs, mode='human') + if display_server: + renderer.render(obs, mode='human') except AssertionError: recording["violations"].append((action, time.monotonic() - start_time)) env_data = pickle.dumps(env.get_state()) @@ -66,14 +71,16 @@ async def simulator(websocket): encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') reply = json.dumps({"valid": False, "env": encoded_env_data, "obs": encoded_obs_data, "done": False}) - time.sleep(0.25) + if SIMULATE_LATENCY: + time.sleep(SIMULATED_LATENCY_DURATION) await websocket.send(reply) recording["result"] = "done" except BaseException as e: print(e) recording["result"] = e finally: - renderer.render(obs, close=True) + if display_server: + renderer.render(obs, close=True) print("GG") recording["end_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") @@ -82,12 +89,12 @@ async def simulator(websocket): with open(p / (recording["start_time"] + '.pkl'), 'wb') as f: pickle.dump(recording, f) - start_server = websockets.serve(simulator, "localhost", 8765) + start_server = websockets.serve(simulator, "0.0.0.0", 8765) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever() -def client_loop(environment_name: str, seed: int = 42, host="ws://localhost:8765", noisy_randomization: bool = False): +def client_loop(environment_name: str, seed: int = 42, host: str="ws://localhost:8765", noisy_randomization: bool = False): uri = host async def send_actions(websocket, shared_state): @@ -135,7 +142,10 @@ async def interact_with_server(): asyncio.get_event_loop().run_until_complete(interact_with_server()) -def replay(recording_name): +def replay(recording_name: str): + if not recording_name: + raise ValueError("Empty recording_name supplied") + p = Path('recordings') with open(p / (recording_name + '.pkl'), 'rb') as f: recording = pickle.load(f) @@ -152,7 +162,7 @@ def replay(recording_name): renderer.render(obs, mode='human') renderer.render(obs, close=True) -def render(recording_name): +def render(recording_name: str): p = Path('recordings') with open(p / (recording_name + '.pkl'), 'rb') as f: recording = pickle.load(f) From 29863d2b0f02d407dfb507375115ab75b366d307 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 15 Apr 2024 00:45:53 -0400 Subject: [PATCH 18/41] pddl functions uncommented --- environments/env_generator/builder.py | 55 ++++++++++++++------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/environments/env_generator/builder.py b/environments/env_generator/builder.py index dac1ef9ab..c2c7f1dd0 100644 --- a/environments/env_generator/builder.py +++ b/environments/env_generator/builder.py @@ -150,7 +150,8 @@ def build_station_location_predicates(environment_dict): """ predicates_str = "" for station in environment_dict["stations"]: - for field in ["items", "players"]: + valid_fields = [field for field in ["items", "players"] if field in environment_dict] + for field in valid_fields: match = False no_match_predicate = "empty" if field == "items" else "vacant" predicate = "item_at" if field == "items" else "loc" @@ -181,13 +182,14 @@ def build_player_location_predicates(environment_dict): predicates_str = "" for player in environment_dict["players"]: match = False - for item in environment_dict["items"]: - if player["x"] == item["x"] and player["y"] == item["y"]: - predicates_str += f" (has {player['name']} {item['name']})\n" - match = True - break - if not match: - predicates_str += f" (nothing {player['name']})\n" + if environment_dict.get("items"): + for item in environment_dict["items"]: + if player["x"] == item["x"] and player["y"] == item["y"]: + predicates_str += f" (has {player['name']} {item['name']})\n" + match = True + break + if not match: + predicates_str += f" (nothing {player['name']})\n" return predicates_str def build_location_predicates(environment_dict): @@ -228,12 +230,13 @@ def build_stacking_predicates(environment_dict): stacks = {} # Sort items into stacks ordered by stacking order sorting_key = lambda item: item["stack-level"] - for item in environment_dict["items"]: - for station in environment_dict["stations"]: - if item["x"] == station["x"] and item["y"] == station["y"]: - stacks[station["name"]] = stacks.get(station["name"], []) + [item] - stacks[station["name"]].sort(key=sorting_key) - break + if environment_dict.get("items"): + for item in environment_dict["items"]: + for station in environment_dict["stations"]: + if item["x"] == station["x"] and item["y"] == station["y"]: + stacks[station["name"]] = stacks.get(station["name"], []) + [item] + stacks[station["name"]].sort(key=sorting_key) + break # Add stacking predicates for station_name, items in stacks.items(): stacking_predicates_str += f" (on {items[0]['name']} {station_name})\n" @@ -402,19 +405,19 @@ def build_problem(environment_dict): new_environment_dict (dict): Dictionary containing IDed stations, items, and player location. """ problem = "(define (problem robotouille)\n" - # problem += "(:domain robotouille)\n" - # problem += "(:objects\n" + problem += "(:domain robotouille)\n" + problem += "(:objects\n" objects_str, new_environment_dict = build_objects(environment_dict) - # problem += objects_str - # problem += ")\n" - # problem += "(:init\n" - # problem += build_identity_predicates(new_environment_dict) - # problem += build_location_predicates(new_environment_dict) - # problem += build_stacking_predicates(new_environment_dict) - # problem += ")\n" - # problem += "(:goal\n" - # problem += build_goal(new_environment_dict) - # problem += ")\n" + problem += objects_str + problem += ")\n" + problem += "(:init\n" + problem += build_identity_predicates(new_environment_dict) + problem += build_location_predicates(new_environment_dict) + problem += build_stacking_predicates(new_environment_dict) + problem += ")\n" + problem += "(:goal\n" + problem += build_goal(new_environment_dict) + problem += ")\n" return problem, new_environment_dict def write_problem_file(problem, filename): From d7f92e378eb8186e9f234d432d3436c29b5b8269 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 15 Apr 2024 13:04:28 -0400 Subject: [PATCH 19/41] fixed bug for station location predicates and no conditional effect for filling water --- domain/robotouille.json | 60 +++++++++++++++++++++++++---------------- robotouille/env.py | 10 ++++--- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/domain/robotouille.json b/domain/robotouille.json index a158de87a..1fb6f067d 100644 --- a/domain/robotouille.json +++ b/domain/robotouille.json @@ -649,36 +649,50 @@ "immediate_fx":[], "sfx": [ { - "type": "delayed", + "type": "conditional", "param": "c1", + "conditions": [ + { + "predicate": "container_at", + "params": ["c1", "s1"], + "is_true": true + } + ], "fx": [], "sfx": [ { - "type": "creation", + "type": "delayed", "param": "c1", - "created_obj": { - "name": "water", - "type": "meal", - "param": "m1" - }, - "fx": [ - { - "predicate": "iswater", - "params": ["m1"], - "is_true": true - }, + "fx": [], + "sfx": [ { - "predicate": "in", - "params": ["m1", "c1"], - "is_true": true - }, - { - "predicate": "container_empty", - "params": ["c1"], - "is_true": false + "type": "creation", + "param": "c1", + "created_obj": { + "name": "water", + "type": "meal", + "param": "m1" + }, + "fx": [ + { + "predicate": "iswater", + "params": ["m1"], + "is_true": true + }, + { + "predicate": "in", + "params": ["m1", "c1"], + "is_true": true + }, + { + "predicate": "container_empty", + "params": ["c1"], + "is_true": false + } + ], + "sfx": [] } - ], - "sfx": [] + ] } ] } diff --git a/robotouille/env.py b/robotouille/env.py index fc2fe7197..6df2e8664 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -84,11 +84,11 @@ def build_station_location_predicates(environment_dict): predicates = [] for station in environment_dict["stations"]: station_obj = Object(station["name"], "station") - for field in ["items", "players", "containers"]: + match = False + for field in ["players", "items", "containers"]: if not environment_dict.get(field): continue no_match_predicate = "station_empty" if field in ["items", "containers"] else "vacant" predicate = "item_at" if field == "items" else "container_at" if field == "containers" else "loc" - match = False for entity in environment_dict[field]: x = entity["x"] + entity["direction"][0] if field == "players" else entity["x"] y = entity["y"] + entity["direction"][1] if field == "players" else entity["y"] @@ -98,9 +98,13 @@ def build_station_location_predicates(environment_dict): pred = Predicate().initialize(predicate, [field[:-1], "station"], [obj, station_obj]) predicates.append(pred) match = True - if not match: + if not match and field == "players": pred = Predicate().initialize(no_match_predicate, ["station"], [station_obj]) predicates.append(pred) + match = False if field == "players" else match + if not match: + pred = Predicate().initialize("station_empty", ["station"], [station_obj]) + predicates.append(pred) return predicates def build_player_location_predicates(environment_dict): From d81deea9bd51b66d9a2497a66ad1a57f50ec1f00 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 15 Apr 2024 16:54:13 -0400 Subject: [PATCH 20/41] Implemented multi-agent --- backend/state.py | 62 ++++++++++++++++--- .../env_generator/examples/cook_soup.json | 6 ++ renderer/canvas.py | 49 ++++++++------- renderer/renderer.py | 2 +- robotouille/env.py | 19 +++--- robotouille/robotouille_env.py | 2 +- robotouille/robotouille_simulator.py | 6 +- utils/robotouille_input.py | 7 ++- 8 files changed, 109 insertions(+), 44 deletions(-) diff --git a/backend/state.py b/backend/state.py index 7b02db2b9..10673e103 100644 --- a/backend/state.py +++ b/backend/state.py @@ -124,6 +124,15 @@ def _build_actions(self, domain, objects): args = {param.name:combination[params.index(param)] for param in params} actions[action].append(args) return actions + + def get_players(self): + """ + Returns the player objects in the state. + + Returns: + players (List[Object]): The player objects in the state. + """ + return [obj for obj in self.objects if obj.object_type == "player"] def initialize(self, domain, objects, true_predicates, all_goals, special_effects=[]): """ @@ -168,6 +177,7 @@ def initialize(self, domain, objects, true_predicates, all_goals, special_effect self.domain = domain self.objects = objects + self.current_player = self.get_players()[0] self.predicates = predicates self.actions = self._build_actions(domain, objects) self.goal = all_goals @@ -337,15 +347,51 @@ def get_valid_actions(self): valid_actions[action].append(arg) return valid_actions + + def get_valid_actions_for_player(self, player): + """ + Gets all valid actions for a player in the state. - def step(self, action, param_arg_dict): + Args: + player (Object): The player to get the valid actions for. + + Returns: + valid_actions (Dictionary[Action, Dictionary[Str, Object]]): A + dictionary of valid actions for the player. The keys are the + actions, and the values are the parameter-argument dictionaries + for the actions. + """ + valid_actions = self.get_valid_actions() + + player_actions = {action:[] for action in self.actions} + + for action, args in valid_actions.items(): + for arg in args: + if player in arg.values() or arg == {}: + player_actions[action].append(arg) + + return player_actions + + def next_player(self): + """ + Returns the next player in the state. + + Returns: + player (Object): The next player in the state. + """ + players = self.get_players() + current_index = players.index(self.current_player) + next_index = (current_index + 1) % len(players) + return players[next_index] + + def step(self, actions): """ Steps the state forward by applying the effects of the action. Args: - action (Action): The action to apply the effects of. - param_arg_dict (Dictionary[Str, Object]): The dictionary that map - parameters to arguments. + actions (Dictionary[Action, Dictionary[Str, Object]]): A dictionary + of actions to perform. The keys are the actions, and the values + are the parameter-argument dictionaries for the actions. Returns: new_state (State): The successor state. @@ -355,15 +401,17 @@ def step(self, action, param_arg_dict): AssertionError: If the action is invalid with the given arguments in the given state. """ - assert action.is_valid(self, param_arg_dict) - - self = action.perform_action(self, param_arg_dict) + for action, param_arg_dict in actions.items(): + assert action.is_valid(self, param_arg_dict) + self = action.perform_action(self, param_arg_dict) for special_effect in self.special_effects: special_effect.update(self) if self.is_goal_reached(): return self, True + + self.current_player = self.next_player() return self, False \ No newline at end of file diff --git a/environments/env_generator/examples/cook_soup.json b/environments/env_generator/examples/cook_soup.json index 4e4cd75d7..bdb400409 100644 --- a/environments/env_generator/examples/cook_soup.json +++ b/environments/env_generator/examples/cook_soup.json @@ -84,6 +84,12 @@ "x": 0, "y": 0, "direction": [0, 1] + }, + { + "name": "robot", + "x": 1, + "y": 0, + "direction": [0, 1] } ], "goal_description": "", diff --git a/renderer/canvas.py b/renderer/canvas.py index 686a78e55..3c96ba727 100644 --- a/renderer/canvas.py +++ b/renderer/canvas.py @@ -13,7 +13,7 @@ class RobotouilleCanvas: # The directory containing the assets ASSETS_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets") - def __init__(self, config, layout, player, window_size=np.array([512,512])): + def __init__(self, config, layout, players, window_size=np.array([512,512])): """ Initializes the canvas. @@ -24,8 +24,10 @@ def __init__(self, config, layout, player, window_size=np.array([512,512])): # The layout of the game self.layout = layout # The player's position and direction (assuming one player) - player_pos = (player["x"], len(layout) - player["y"] - 1) - self.player_pose = {"position": player_pos, "direction": tuple(player["direction"])} + self.player_pose = {} + for player in players: + player_pos = (player["x"], len(layout) - player["y"] - 1) + self.player_pose[player["name"]] = {"position": player_pos, "direction": tuple(player["direction"])} grid_dimensions = np.array([len(layout[0]), len(layout)]) # The scaling factor for a grid square self.pix_square_size = window_size / grid_dimensions @@ -349,24 +351,26 @@ def _draw_player(self, surface, obs): surface (pygame.Surface): Surface to draw on obs (State): Game state predicates """ - player_pos = None - held_item_name = None - for literal, is_true in obs.predicates.items(): - if is_true and literal.name == "loc": - player_station = literal.params[1].name - station_pos = self._get_station_position(player_station) - player_pos = self.player_pose["position"] - player_pos, player_direction = self._move_player_to_station(player_pos, tuple(station_pos), self.layout) - self.player_pose = {"position": player_pos, "direction": player_direction} - #pos[1] += 1 # place the player below the station - #player_pos = pos - robot_image_name = self._get_player_image_name(player_direction) - self._draw_image(surface, robot_image_name, player_pos * self.pix_square_size, self.pix_square_size) - if is_true and literal.name == "has_item": - player_pos = self.player_pose["position"] - held_item_name = literal.params[1].name - if held_item_name: - self._draw_item_image(surface, held_item_name, obs, player_pos * self.pix_square_size) + players = obs.get_players() + for player in players: + player_pos = None + held_item_name = None + for literal, is_true in obs.predicates.items(): + if is_true and literal.name == "loc" and literal.params[0].name == player.name: + player_station = literal.params[1].name + station_pos = self._get_station_position(player_station) + player_pos = self.player_pose[player.name]["position"] + player_pos, player_direction = self._move_player_to_station(player_pos, tuple(station_pos), self.layout) + self.player_pose[player.name] = {"position": player_pos, "direction": player_direction} + #pos[1] += 1 # place the player below the station + #player_pos = pos + robot_image_name = self._get_player_image_name(player_direction) + self._draw_image(surface, robot_image_name, player_pos * self.pix_square_size, self.pix_square_size) + if is_true and literal.name == "has_item" and literal.params[0].name == player.name: + player_pos = self.player_pose[player.name]["position"] + held_item_name = literal.params[1].name + if held_item_name: + self._draw_item_image(surface, held_item_name, obs, player_pos * self.pix_square_size) def _draw_item(self, surface, obs): """ @@ -439,7 +443,8 @@ def _draw_container(self, surface, obs): self._draw_container_image(surface, container, obs, container_pos * self.pix_square_size) if is_true and literal.name == "has_container": container = literal.params[1].name - container_pos = self.player_pose["position"] + player = literal.params[0].name + container_pos = self.player_pose[player]["position"] self._draw_container_image(surface, container, obs, container_pos * self.pix_square_size) def draw_to_surface(self, surface, obs): diff --git a/renderer/renderer.py b/renderer/renderer.py index 4e6e083b0..c0e7ee855 100644 --- a/renderer/renderer.py +++ b/renderer/renderer.py @@ -27,7 +27,7 @@ def __init__(self, config_filename, layout=[], players=[], window_size=np.array with open(os.path.join(CONFIG_DIR, config_filename), "r") as f: config = json.load(f) # The canvas is responsible for drawing the game state on a pygame surface. - self.canvas = RobotouilleCanvas(config, layout, players[0], window_size) + self.canvas = RobotouilleCanvas(config, layout, players, window_size) # The pygame window size. self.window_size = window_size # The framerate of the renderer. This isn't too important since the renderer diff --git a/robotouille/env.py b/robotouille/env.py index fc2fe7197..8c90a1776 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -84,11 +84,11 @@ def build_station_location_predicates(environment_dict): predicates = [] for station in environment_dict["stations"]: station_obj = Object(station["name"], "station") - for field in ["items", "players", "containers"]: + match = False + for field in ["players", "items", "containers"]: if not environment_dict.get(field): continue no_match_predicate = "station_empty" if field in ["items", "containers"] else "vacant" predicate = "item_at" if field == "items" else "container_at" if field == "containers" else "loc" - match = False for entity in environment_dict[field]: x = entity["x"] + entity["direction"][0] if field == "players" else entity["x"] y = entity["y"] + entity["direction"][1] if field == "players" else entity["y"] @@ -98,9 +98,14 @@ def build_station_location_predicates(environment_dict): pred = Predicate().initialize(predicate, [field[:-1], "station"], [obj, station_obj]) predicates.append(pred) match = True - if not match: - pred = Predicate().initialize(no_match_predicate, ["station"], [station_obj]) - predicates.append(pred) + if field == "players": + if not match: + pred = Predicate().initialize(no_match_predicate, ["station"], [station_obj]) + predicates.append(pred) + else: match = False + if not match: + pred = Predicate().initialize("station_empty", ["station"], [station_obj]) + predicates.append(pred) return predicates def build_player_location_predicates(environment_dict): @@ -339,8 +344,8 @@ def get_state(self): def set_state(self, state): self.observation_space = state - def step(self, action, args, interactive): - obs, done = self.observation_space.step(action, args) + def step(self, actions, interactive): + obs, done = self.observation_space.step(actions) return obs, 0, done, {} def reset(self, seed=None, options=None): diff --git a/robotouille/robotouille_env.py b/robotouille/robotouille_env.py index 91680923d..efab0315f 100644 --- a/robotouille/robotouille_env.py +++ b/robotouille/robotouille_env.py @@ -74,9 +74,9 @@ def create_robotouille_env(problem_filename, seed=None, noisy_randomization=Fals environment_json = _procedurally_generate(environment_json, int(seed), noisy_randomization) layout = _parse_renderer_layout(environment_json) config_filename = "robotouille_config.json" + problem_string, environment_json = builder.build_problem(environment_json) # IDs objects in environment renderer = RobotouilleRenderer(config_filename=config_filename, layout=layout, players=environment_json["players"]) render_fn = renderer.render - problem_string, environment_json = builder.build_problem(environment_json) # IDs objects in environment domain_filename = "domain/robotouille.json" with open(domain_filename, "r") as domain_file: domain_json = json.load(domain_file) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 522686c6e..13bb80464 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -10,7 +10,7 @@ def simulator(environment_name: str, seed: int=42, noisy_randomization: bool=Fal renderer.render(obs, mode='human') done = False interactive = False # Set to True to interact with the environment through terminal REPL (ignores input) - + while not done: # Construct action from input pygame_events = pygame.event.get() @@ -18,10 +18,10 @@ def simulator(environment_name: str, seed: int=42, noisy_randomization: bool=Fal mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) # Keyboard events ('e' button) for cut/cook ('space' button) for noop keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) - action, args = create_action_from_control(env, obs, mousedown_events+keydown_events, renderer) + action, args = create_action_from_control(env, obs, obs.current_player, mousedown_events+keydown_events, renderer) if not interactive and action is None: # Retry for keyboard input continue - obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) + obs, reward, done, info = env.step(actions={action: args}, interactive=interactive) renderer.render(obs, mode='human') renderer.render(obs, close=True) diff --git a/utils/robotouille_input.py b/utils/robotouille_input.py index 2b8356636..986a5b850 100644 --- a/utils/robotouille_input.py +++ b/utils/robotouille_input.py @@ -1,6 +1,6 @@ import pygame -def create_action_from_control(env, obs, action, renderer): +def create_action_from_control(env, obs, player, action, renderer): """ This function attempts to create a valid action from the provided action. @@ -14,6 +14,7 @@ def create_action_from_control(env, obs, action, renderer): Args: env: The environment. obs: The current observation. + player: The player to control. action: The action to transform. renderer: The renderer. @@ -23,12 +24,12 @@ def create_action_from_control(env, obs, action, renderer): arguments for the action. """ if len(action) == 0: return None, None - valid_actions = obs.get_valid_actions() + valid_actions = obs.get_valid_actions_for_player(player) action_dict = {str(action): action for action in env.action_space} input_json = env.input_json action = action[0] for literal, is_true in obs.predicates.items(): - if literal.name == "loc" and is_true: + if literal.name == "loc" and is_true and literal.params[0].name == player.name: player_loc = str(literal.params[1]) if action.type == pygame.MOUSEBUTTONDOWN: pos_x, pos_y = action.pos From 2611715f662f9e8b9e11497c38b6722d5a232ecf Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 22 Apr 2024 15:22:59 -0400 Subject: [PATCH 21/41] addressed comments, fixed bug in creation effect --- backend/action.py | 1 - backend/predicate.py | 7 +- backend/special_effects/creation_effect.py | 9 ++- backend/state.py | 25 +++++-- environments/env_generator/builder.py | 37 +++++----- ...ase_fill_water.json => base_fill_pot.json} | 0 .../examples/base_fill_two_pots.json | 67 +++++++++++++++++++ robotouille/env.py | 35 ++++++---- 8 files changed, 141 insertions(+), 40 deletions(-) rename environments/env_generator/examples/{base_fill_water.json => base_fill_pot.json} (100%) create mode 100644 environments/env_generator/examples/base_fill_two_pots.json diff --git a/backend/action.py b/backend/action.py index fe9a67e9b..003e5b6a8 100644 --- a/backend/action.py +++ b/backend/action.py @@ -1,5 +1,4 @@ from backend.predicate import Predicate -import pygame class Action(object): """ diff --git a/backend/predicate.py b/backend/predicate.py index ce839a8ed..d22e2b65d 100644 --- a/backend/predicate.py +++ b/backend/predicate.py @@ -1,3 +1,5 @@ +from backend.object import Object + class Predicate(object): ''' This class represents a predicate in Robotouille. @@ -89,4 +91,7 @@ def replace_pred_params_with_args(self, param_arg_dict): pred (Predicate): The copy of the predicate. """ pred_args = [param_arg_dict[param.name] for param in self.params] - return Predicate().initialize(self.name, self.types, pred_args) \ No newline at end of file + new_pred_args = [] + for arg in pred_args: + new_pred_args.append(Object(arg.name, arg.object_type)) + return Predicate().initialize(self.name, self.types, new_pred_args) \ No newline at end of file diff --git a/backend/special_effects/creation_effect.py b/backend/special_effects/creation_effect.py index cec8820a6..e1a494fa9 100644 --- a/backend/special_effects/creation_effect.py +++ b/backend/special_effects/creation_effect.py @@ -1,4 +1,5 @@ from backend.special_effect import SpecialEffect +from backend.object import Object class CreationEffect(SpecialEffect): """ @@ -97,9 +98,13 @@ def update(self, state, active=False): """ if self.completed: return - state.add_object(self.created_obj[1]) + new_obj = state.add_object(self.created_obj[1]) for effect, value in self.effects.items(): + for param in effect.params: + if param == self.created_obj[1]: + param.name = new_obj.name state.update_predicate(effect, value) for special_effect in self.special_effects: special_effect.update(state, active) - self.completed = True \ No newline at end of file + if all([special_effect.completed for special_effect in self.special_effects]): + self.completed = True \ No newline at end of file diff --git a/backend/state.py b/backend/state.py index 717d19661..dd8d46f02 100644 --- a/backend/state.py +++ b/backend/state.py @@ -1,4 +1,5 @@ from backend.predicate import Predicate +from backend.object import Object from utils.robotouille_utils import trim_item_ID import itertools @@ -158,10 +159,11 @@ def initialize(self, domain, objects, true_predicates, all_goals, special_effect for object in objects: if object.object_type not in domain.object_types: raise ValueError(f"Type {object.object_type} is not defined in the domain.") + predicate_names = list(map(lambda x: x.name, domain.predicates)) # check if goal predicates are defined in domain for goal_set in all_goals: for goal in goal_set: - if goal.name not in list(map(lambda x: x.name, domain.predicates)): + if goal.name not in predicate_names: raise ValueError(f"Predicate {goal.name} is not defined in the domain.") if not domain.are_valid_object_types(goal.types): raise ValueError(f"Types {goal.types} are not defined in the domain.") @@ -193,12 +195,19 @@ def get_predicate_value(self, predicate): """ Returns the value of a predicate in the state. + If a predicate is not yet in the state, then the function returns False. + For example, for goal predicates involving objects not yet created, the + predicate would not be defined in the state. Then the function returns + False. + Args: predicate (Predicate): The predicate to check. value (bool): The value of the predicate to check for. Returns: - bool: True if the predicate is True in the state, False otherwise. + bool: True if the predicate is True in the state, False if the + predicate is False in the state or if the predicate is not in + the state. """ return self.predicates[predicate] if predicate in self.predicates else False @@ -252,7 +261,7 @@ def _get_next_ID_for_object(self, obj): for object in self.objects: name, id = trim_item_ID(object.name) if name == obj.name: - max_id = max(max_id, id) + max_id = max(max_id, int(id)) return max_id + 1 def add_object(self, obj): @@ -263,19 +272,23 @@ def add_object(self, obj): obj (Object): The object to add to the state. Side effects: - - The argument obj is given an id - The objects, predicates, and actions in the state are modified and updated to account for the new object. + + Returns: + created_obj (Object): The object that was created. """ num = self._get_next_ID_for_object(obj) - obj.name = f"{obj.name}{num}" + name = f"{obj.name}{num}" + new_obj = Object(name, obj.object_type) # TODO(lsuyean): create field to store ID instead of modifying name - self.objects.append(obj) + self.objects.append(new_obj) # TODO(lsuyean): optimize creating predicates and actions; only add # necessary predicates and actions instead of building from scratch true_predicates = {predicate for predicate, value in self.predicates.items() if value} self.predicates = self._build_predicates(self.domain, self.objects, true_predicates) self.actions = self._build_actions(self.domain, self.objects) + return new_obj def delete_object(self, obj): """ diff --git a/environments/env_generator/builder.py b/environments/env_generator/builder.py index c2c7f1dd0..387d15644 100644 --- a/environments/env_generator/builder.py +++ b/environments/env_generator/builder.py @@ -182,14 +182,13 @@ def build_player_location_predicates(environment_dict): predicates_str = "" for player in environment_dict["players"]: match = False - if environment_dict.get("items"): - for item in environment_dict["items"]: - if player["x"] == item["x"] and player["y"] == item["y"]: - predicates_str += f" (has {player['name']} {item['name']})\n" - match = True - break - if not match: - predicates_str += f" (nothing {player['name']})\n" + for item in environment_dict.get("items", []): + if player["x"] == item["x"] and player["y"] == item["y"]: + predicates_str += f" (has {player['name']} {item['name']})\n" + match = True + break + if not match: + predicates_str += f" (nothing {player['name']})\n" return predicates_str def build_location_predicates(environment_dict): @@ -230,13 +229,12 @@ def build_stacking_predicates(environment_dict): stacks = {} # Sort items into stacks ordered by stacking order sorting_key = lambda item: item["stack-level"] - if environment_dict.get("items"): - for item in environment_dict["items"]: - for station in environment_dict["stations"]: - if item["x"] == station["x"] and item["y"] == station["y"]: - stacks[station["name"]] = stacks.get(station["name"], []) + [item] - stacks[station["name"]].sort(key=sorting_key) - break + for item in environment_dict.get("items", []): + for station in environment_dict["stations"]: + if item["x"] == station["x"] and item["y"] == station["y"]: + stacks[station["name"]] = stacks.get(station["name"], []) + [item] + stacks[station["name"]].sort(key=sorting_key) + break # Add stacking predicates for station_name, items in stacks.items(): stacking_predicates_str += f" (on {items[0]['name']} {station_name})\n" @@ -303,14 +301,14 @@ def create_unique_and_combination_preds(environment_dict): # Get all entities to prepare the combination arg_entities = list(filter(lambda entity: arg in entity["name"], environment_dict[entity_field])) arg_entity_names = list(map(lambda entity: entity["name"], arg_entities)) - combination_dict[arg]['entities'] = arg_entity_names if arg_entity_names else [arg + "1"] + combination_dict[arg]['entities'] = arg_entity_names if arg_entity_names else [] combination_dict[arg]['ids'] = set() combination_dict[arg]['ids'].add(arg_id) else: # Unique predicate same_id_entity = list(filter(lambda entity: entity.get("id") == arg_id, environment_dict[entity_field])) # If the entity is not found, then it is a wild card entity - entity_name = arg + "1" if not same_id_entity else same_id_entity[0]["name"] + entity_name = same_id_entity[0]["name"] pred.append(entity_name) if unique_pred: unique_preds.append(pred) @@ -339,6 +337,9 @@ def create_combinations(combination_dict): ids = list(combination_dict[arg]['ids']) id_order += ids entities = combination_dict[arg]['entities'] + if entities == []: + for id in ids: + entities.append(arg + id) permutations = list(itertools.permutations(entities, len(ids))) combination_list.append(permutations) product = itertools.product(*combination_list) @@ -380,7 +381,7 @@ def build_goal(environment_dict): goal = " (or\n" unique_preds, combination_preds, combination_dict = create_unique_and_combination_preds(environment_dict) combinations, id_order = create_combinations(combination_dict) - assert len(combinations) > 0, "Object in goal missing from environment" + # assert len(combinations) > 0, "Object in goal missing from environment" for combination in combinations: # Combination predicates with the combination ID arguments filled in filled_combination_preds = copy.deepcopy(combination_preds) diff --git a/environments/env_generator/examples/base_fill_water.json b/environments/env_generator/examples/base_fill_pot.json similarity index 100% rename from environments/env_generator/examples/base_fill_water.json rename to environments/env_generator/examples/base_fill_pot.json diff --git a/environments/env_generator/examples/base_fill_two_pots.json b/environments/env_generator/examples/base_fill_two_pots.json new file mode 100644 index 000000000..35a0c1a65 --- /dev/null +++ b/environments/env_generator/examples/base_fill_two_pots.json @@ -0,0 +1,67 @@ +{ + "version": "1.0.0", + "width": 3, + "height": 3, + "config": { + "num_cuts": { + "lettuce": 3, + "default": 3 + }, + "cook_time": { + "patty": 3, + "default": 3 + } + }, + "stations": [ + { + "name": "sink", + "x": 0, + "y": 1, + "id": "A" + }, + { + "name": "sink", + "x": 2, + "y": 1, + "id": "B" + } + ], + "items": [], + "players": [ + { + "name": "robot", + "x": 0, + "y": 0, + "direction": [0, 1] + } + ], + "meals": [], + "containers": [ + { + "name": "pot", + "x": 0, + "y": 1, + "id": "a", + "meal_id": [] + }, + { + "name": "pot", + "x": 2, + "y": 1, + "id": "b", + "meal_id": [] + } + ], + "goal": [ + { + "predicate": "in", + "args": ["water", "pot"], + "ids": [1, "a"] + }, + { + "predicate": "in", + "args": ["water", "pot"], + "ids": [2, 3] + } + ] +} \ No newline at end of file diff --git a/robotouille/env.py b/robotouille/env.py index 6df2e8664..c18a34c54 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -85,23 +85,34 @@ def build_station_location_predicates(environment_dict): for station in environment_dict["stations"]: station_obj = Object(station["name"], "station") match = False - for field in ["players", "items", "containers"]: - if not environment_dict.get(field): continue - no_match_predicate = "station_empty" if field in ["items", "containers"] else "vacant" - predicate = "item_at" if field == "items" else "container_at" if field == "containers" else "loc" - for entity in environment_dict[field]: - x = entity["x"] + entity["direction"][0] if field == "players" else entity["x"] - y = entity["y"] + entity["direction"][1] if field == "players" else entity["y"] + # Check if there are any players at the station + for player in environment_dict.get("players", []): + x = player["x"] + player["direction"][0] + y = player["y"] + player["direction"][1] + if x == station["x"] and y == station["y"]: + name = player["name"] + obj = Object(name, "player") + pred = Predicate().initialize("loc", ["player", "station"], [obj, station_obj]) + predicates.append(pred) + match = True + # If no players are at the station, add a vacant predicate + if not match: + pred = Predicate().initialize("vacant", ["station"], [station_obj]) + predicates.append(pred) + match = False + # Check if there are any items or containers at the station + for field in ["items", "containers"]: + predicate = "item_at" if field == "items" else "container_at" + for entity in environment_dict.get(field, []): + x = entity["x"] + y = entity["y"] if x == station["x"] and y == station["y"]: name = entity["name"] obj = Object(name, field[:-1]) pred = Predicate().initialize(predicate, [field[:-1], "station"], [obj, station_obj]) predicates.append(pred) match = True - if not match and field == "players": - pred = Predicate().initialize(no_match_predicate, ["station"], [station_obj]) - predicates.append(pred) - match = False if field == "players" else match + # If no items or containers are at the station, add a station_empty predicate if not match: pred = Predicate().initialize("station_empty", ["station"], [station_obj]) predicates.append(pred) @@ -251,7 +262,7 @@ def build_goal(environment_dict): goal = [] unique_preds, combination_preds, combination_dict = create_unique_and_combination_preds(environment_dict) combinations, id_order = create_combinations(combination_dict) - assert len(combinations) > 0, "Object in goal missing from environment" + # assert len(combinations) > 0, "Object in goal missing from environment" for combination in combinations: # Combination predicates with the combination ID arguments filled in filled_combination_preds = copy.deepcopy(combination_preds) From 9f3eb7e01ddd6646d5575ec13d608d06284aa699 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Mon, 22 Apr 2024 15:45:46 -0400 Subject: [PATCH 22/41] fixed tests --- backend/tests/test_envs.py | 3 ++- .../env_generator/examples/composite_add_fill_bowl.json | 2 +- environments/env_generator/examples/cook_soup.json | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_envs.py b/backend/tests/test_envs.py index 21fbeec14..782661eb4 100644 --- a/backend/tests/test_envs.py +++ b/backend/tests/test_envs.py @@ -8,7 +8,8 @@ 'base_boil_water', 'base_cook', 'base_cut', - 'base_fill_water', + 'base_fill_pot', + 'base_fill_two_pots', 'base_move', 'base_pickup_container', 'base_pickup', diff --git a/environments/env_generator/examples/composite_add_fill_bowl.json b/environments/env_generator/examples/composite_add_fill_bowl.json index 2c1400fcb..458958454 100644 --- a/environments/env_generator/examples/composite_add_fill_bowl.json +++ b/environments/env_generator/examples/composite_add_fill_bowl.json @@ -69,7 +69,7 @@ { "predicate": "addedto", "args": ["tomato", "water"], - "ids": ["a", "c"] + "ids": ["a", "b"] }, { "predicate": "in", diff --git a/environments/env_generator/examples/cook_soup.json b/environments/env_generator/examples/cook_soup.json index 677721cf2..40af598c8 100644 --- a/environments/env_generator/examples/cook_soup.json +++ b/environments/env_generator/examples/cook_soup.json @@ -92,12 +92,12 @@ { "predicate": "in", "args": ["water", "bowl"], - "ids": ["c", "b"] + "ids": [1, "b"] }, { "predicate": "addedto", "args": ["tomato", "water"], - "ids": [1, 2] + "ids": [2, 1] } ] } \ No newline at end of file From e3882b64981cc3529d43b8d446ff236e206de772 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Wed, 24 Apr 2024 12:54:14 -0400 Subject: [PATCH 23/41] made changes based on comments --- backend/state.py | 13 +++++++++---- robotouille/robotouille_simulator.py | 8 +++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/backend/state.py b/backend/state.py index 19304da78..53ad28371 100644 --- a/backend/state.py +++ b/backend/state.py @@ -398,9 +398,12 @@ def step(self, actions): Steps the state forward by applying the effects of the action. Args: - actions (Dictionary[Action, Dictionary[Str, Object]]): A dictionary - of actions to perform. The keys are the actions, and the values - are the parameter-argument dictionaries for the actions. + actions (List[Tuple[Action, Dictionary[str, Object]]): A list of + tuples where the first element is the action to perform, and the + second element is a dictionary of arguments for the action. The + length of the list is the number of players, where actions[i] is + the action for player i. If player i is not performing an action, + actions[i] is None. Returns: new_state (State): The successor state. @@ -410,7 +413,9 @@ def step(self, actions): AssertionError: If the action is invalid with the given arguments in the given state. """ - for action, param_arg_dict in actions.items(): + for action, param_arg_dict in actions: + if not action: + continue assert action.is_valid(self, param_arg_dict) self = action.perform_action(self, param_arg_dict) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 13bb80464..b1841b017 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -18,10 +18,16 @@ def simulator(environment_name: str, seed: int=42, noisy_randomization: bool=Fal mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) # Keyboard events ('e' button) for cut/cook ('space' button) for noop keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) + actions = [] action, args = create_action_from_control(env, obs, obs.current_player, mousedown_events+keydown_events, renderer) + for player in obs.get_players(): + if player == obs.current_player: + actions.append((action, args)) + else: + actions.append((None, None)) if not interactive and action is None: # Retry for keyboard input continue - obs, reward, done, info = env.step(actions={action: args}, interactive=interactive) + obs, reward, done, info = env.step(actions, interactive=interactive) renderer.render(obs, mode='human') renderer.render(obs, close=True) From 05c6ea1ee3c0ae9368936acad85f75b8f4eac144 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Mon, 29 Apr 2024 11:59:26 -0400 Subject: [PATCH 24/41] Removed action args --- robotouille/robotouille_simulator.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 19d3cf713..b59815bb3 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -48,9 +48,8 @@ async def simulator(websocket): try: while not done: action_message = await websocket.recv() - encoded_action, encoded_args = json.loads(action_message) + encoded_action = json.loads(action_message) action = pickle.loads(base64.b64decode(encoded_action)) - args = pickle.loads(base64.b64decode(encoded_args)) #print((action, args)) if SIMULATE_LATENCY: time.sleep(SIMULATED_LATENCY_DURATION) @@ -58,8 +57,8 @@ async def simulator(websocket): reply = None try: - obs, reward, done, info = env.step(action=action, args=args, interactive=interactive) - recording["actions"].append((action, args, env.get_state(), time.monotonic() - start_time)) + obs, reward, done, info = env.step(action=action, interactive=interactive) + recording["actions"].append((action, env.get_state(), time.monotonic() - start_time)) reply = json.dumps({"valid": True, "done": done}) if display_server: renderer.render(obs, mode='human') @@ -113,9 +112,8 @@ async def send_actions(websocket, shared_state): if action is not None: if online: encoded_action = base64.b64encode(pickle.dumps(action)).decode('utf-8') - encoded_args = base64.b64encode(pickle.dumps(args)).decode('utf-8') - await websocket.send(json.dumps((encoded_action, encoded_args))) - shared_state["obs"], reward, done, info = env.step(action=action, args=args, interactive=True) + await websocket.send(json.dumps(encoded_action)) + shared_state["obs"], reward, done, info = env.step(action=action, interactive=True) renderer.render(env.get_state(), mode='human') await asyncio.sleep(0) # Yield control to allow other tasks to run @@ -155,10 +153,10 @@ def replay(recording_name: str): renderer.render(obs, mode='human') previous_time = 0 - for action, args, state, t in recording["actions"]: + for action, state, t in recording["actions"]: time.sleep(t - previous_time) previous_time = t - obs, reward, done, info = env.step(action=action, args=args, interactive=False) + obs, reward, done, info = env.step(action=action, interactive=False) renderer.render(obs, mode='human') renderer.render(obs, close=True) @@ -179,14 +177,14 @@ def render(recording_name: str): i = 0 t = 0 while i < len(recording["actions"]): - action, args, state, time_stamp = recording["actions"][i] + action, state, time_stamp = recording["actions"][i] while t > time_stamp: - obs, reward, done, info = env.step(action=action, args=args, interactive=False) + obs, reward, done, info = env.step(action=action, interactive=False) frame = renderer.render(obs, mode='rgb_array') i += 1 if i >= len(recording["actions"]): break - action, args, state, time_stamp = recording["actions"][i] + action, state, time_stamp = recording["actions"][i] t += 1 / fps video_writer.append_data(frame) renderer.render(obs, close=True) From 0b3eb1cd266d0398e1257c9b33610ac8d7c148ae Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:55:03 -0400 Subject: [PATCH 25/41] server and client loop support multiagent --- robotouille/robotouille_simulator.py | 135 +++++++++++++++++++-------- 1 file changed, 96 insertions(+), 39 deletions(-) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index b59815bb3..d7c4fafbc 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -10,6 +10,7 @@ from pathlib import Path from datetime import datetime import imageio +import traceback SIMULATE_LATENCY = False SIMULATED_LATENCY_DURATION = 0.25 @@ -28,28 +29,61 @@ def simulator(environment_name: str, seed: int=42, role: str="client", display_s def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False, display_server: bool=False): print("I am server") - async def simulator(websocket): - print("Hello client", websocket) - recording = {} - recording["start_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - recording["environment_name"] = environment_name - recording["seed"] = seed - recording["noisy_randomization"] = noisy_randomization - recording["actions"] = [] - recording["violations"] = [] - start_time = time.monotonic() - - env, json_data, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) - obs, info = env.reset() - if display_server: - renderer.render(obs, mode='human') - done = False - interactive = False # Adjust based on client commands later if needed + + waiting_queue = {} + gameQueue = asyncio.Queue() + reference_env = create_robotouille_env(environment_name, seed, noisy_randomization)[0] + num_players = len(reference_env.get_state().get_players()) + + async def simulator(connections): try: + print("Start game", connections.keys()) + recording = {} + recording["start_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + recording["environment_name"] = environment_name + recording["seed"] = seed + recording["noisy_randomization"] = noisy_randomization + recording["actions"] = [] + recording["violations"] = [] + start_time = time.monotonic() + + env, json_data, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) + obs, info = env.reset() + if display_server: + renderer.render(obs, mode='human') + done = False + interactive = False # Adjust based on client commands later if needed + + assert len(connections) == num_players + sockets_to_playerID = {} + for i, socket in enumerate(connections.keys()): + sockets_to_playerID[socket] = i + player_data = base64.b64encode(pickle.dumps(i)).decode('utf-8') + opening_message = json.dumps({"player": player_data}) + await socket.send(opening_message) + while not done: - action_message = await websocket.recv() - encoded_action = json.loads(action_message) - action = pickle.loads(base64.b64decode(encoded_action)) + # Wait for messages from any client + # lol this is causing a memory leak + receive_tasks = {asyncio.create_task(q.get()): client for client, q in connections.items()} + finished_tasks, pending_tasks = await asyncio.wait(receive_tasks.keys(), return_when=asyncio.FIRST_COMPLETED) + + # Cancel pending tasks, otherwise we leak + for task in pending_tasks: + task.cancel() + + # Retrieve the message from the completed task + actions = [(None, None)] * num_players + for task in finished_tasks: + message = task.result() + client = receive_tasks[task] + #print(f"Received: {message} from {client.remote_address}") + encoded_action = json.loads(message) + action = pickle.loads(base64.b64decode(encoded_action)) + + actions[sockets_to_playerID[client]] = action + + #print((action, args)) if SIMULATE_LATENCY: time.sleep(SIMULATED_LATENCY_DURATION) @@ -57,27 +91,31 @@ async def simulator(websocket): reply = None try: - obs, reward, done, info = env.step(action=action, interactive=interactive) + obs, reward, done, info = env.step(actions, interactive=interactive) recording["actions"].append((action, env.get_state(), time.monotonic() - start_time)) - reply = json.dumps({"valid": True, "done": done}) if display_server: renderer.render(obs, mode='human') except AssertionError: + print("violation") recording["violations"].append((action, time.monotonic() - start_time)) - env_data = pickle.dumps(env.get_state()) - encoded_env_data = base64.b64encode(env_data).decode('utf-8') - obs_data = pickle.dumps(obs) - encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') - reply = json.dumps({"valid": False, "env": encoded_env_data, "obs": encoded_obs_data, "done": False}) + + env_data = pickle.dumps(env.get_state()) + encoded_env_data = base64.b64encode(env_data).decode('utf-8') + obs_data = pickle.dumps(obs) + encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') + reply = json.dumps({"env": encoded_env_data, "obs": encoded_obs_data, "done": done}) if SIMULATE_LATENCY: time.sleep(SIMULATED_LATENCY_DURATION) - await websocket.send(reply) + websockets.broadcast(connections.keys(), reply) recording["result"] = "done" except BaseException as e: - print(e) - recording["result"] = e + traceback.print_exc(e) + recording["result"] = traceback.format_exc(e) finally: + for websocket in connections.keys(): + await websocket.close() + if display_server: renderer.render(obs, close=True) print("GG") @@ -87,8 +125,20 @@ async def simulator(websocket): p.mkdir(exist_ok=True) with open(p / (recording["start_time"] + '.pkl'), 'wb') as f: pickle.dump(recording, f) + + async def handle_connection(websocket): + # simple lobby code; will break if anyone disconnects, probably has race conditions lol, etc. + print("Hello client", websocket) + q = asyncio.Queue() + waiting_queue[websocket] = q + if len(waiting_queue) == num_players: + connections = waiting_queue.copy() + waiting_queue.clear() + asyncio.create_task(simulator(connections)) + async for message in websocket: + await q.put(message) - start_server = websockets.serve(simulator, "0.0.0.0", 8765) + start_server = websockets.serve(handle_connection, "0.0.0.0", 8765) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever() @@ -100,20 +150,21 @@ async def send_actions(websocket, shared_state): env = shared_state["env"] renderer = shared_state["renderer"] renderer.render(shared_state["obs"], mode='human') + player = shared_state["player"] online = True while not shared_state["done"]: pygame_events = pygame.event.get() mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) - action, args = create_action_from_control(env, shared_state["obs"], mousedown_events + keydown_events, renderer) + action, args = create_action_from_control(env, shared_state["obs"], player, mousedown_events + keydown_events, renderer) online = not (pygame.key.get_mods() & pygame.KMOD_CAPS) if action is not None: if online: - encoded_action = base64.b64encode(pickle.dumps(action)).decode('utf-8') + encoded_action = base64.b64encode(pickle.dumps((action, args))).decode('utf-8') await websocket.send(json.dumps(encoded_action)) - shared_state["obs"], reward, done, info = env.step(action=action, interactive=True) + #shared_state["obs"], reward, done, info = env.step(action=action, interactive=True) renderer.render(env.get_state(), mode='human') await asyncio.sleep(0) # Yield control to allow other tasks to run @@ -123,16 +174,22 @@ async def receive_responses(websocket, shared_state): response = await websocket.recv() data = json.loads(response) shared_state["done"] = data["done"] - - if(not data["valid"]): - shared_state["env"].set_state(pickle.loads(base64.b64decode(data["env"]))) - shared_state["obs"] = pickle.loads(base64.b64decode(data["obs"])) + + shared_state["env"].set_state(pickle.loads(base64.b64decode(data["env"]))) + shared_state["obs"] = pickle.loads(base64.b64decode(data["obs"])) async def interact_with_server(): async with websockets.connect(uri) as websocket: env, _, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) obs, info = env.reset() - shared_state = {"done": False, "env": env, "renderer": renderer, "obs": obs} + shared_state = {"done": False, "env": env, "renderer": renderer, "obs": obs, "player": None} + + opening_message = await websocket.recv() + opening_data = json.loads(opening_message) + player_index = pickle.loads(base64.b64decode(opening_data["player"])) + player = env.get_state().get_players()[player_index] + shared_state["player"] = player + sender = asyncio.create_task(send_actions(websocket, shared_state)) receiver = asyncio.create_task(receive_responses(websocket, shared_state)) await asyncio.gather(sender, receiver) From 18d988aecb23828a6014c530baaf92a77fa582f7 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:21:24 -0400 Subject: [PATCH 26/41] replay and render support multiagent --- robotouille/robotouille_simulator.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index d7c4fafbc..42bf2f639 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -92,12 +92,12 @@ async def simulator(connections): try: obs, reward, done, info = env.step(actions, interactive=interactive) - recording["actions"].append((action, env.get_state(), time.monotonic() - start_time)) + recording["actions"].append((actions, env.get_state(), time.monotonic() - start_time)) if display_server: renderer.render(obs, mode='human') except AssertionError: print("violation") - recording["violations"].append((action, time.monotonic() - start_time)) + recording["violations"].append((actions, time.monotonic() - start_time)) env_data = pickle.dumps(env.get_state()) encoded_env_data = base64.b64encode(env_data).decode('utf-8') @@ -210,10 +210,10 @@ def replay(recording_name: str): renderer.render(obs, mode='human') previous_time = 0 - for action, state, t in recording["actions"]: + for actions, state, t in recording["actions"]: time.sleep(t - previous_time) previous_time = t - obs, reward, done, info = env.step(action=action, interactive=False) + obs, reward, done, info = env.step(actions=actions, interactive=False) renderer.render(obs, mode='human') renderer.render(obs, close=True) @@ -234,9 +234,9 @@ def render(recording_name: str): i = 0 t = 0 while i < len(recording["actions"]): - action, state, time_stamp = recording["actions"][i] + actions, state, time_stamp = recording["actions"][i] while t > time_stamp: - obs, reward, done, info = env.step(action=action, interactive=False) + obs, reward, done, info = env.step(actions=actions, interactive=False) frame = renderer.render(obs, mode='rgb_array') i += 1 if i >= len(recording["actions"]): From 9bd7bfe4b1b10203a27e9973f47b22f3cd39f16d Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Tue, 7 May 2024 14:47:51 -0400 Subject: [PATCH 27/41] Added single player mode (only works for 1 player environments) --- main.py | 2 +- robotouille/robotouille_simulator.py | 72 +++++++++++++++++----------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/main.py b/main.py index 2ff82477e..43fe98299 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ parser = argparse.ArgumentParser() parser.add_argument("--environment_name", help="The name of the environment to create.", default="original") parser.add_argument("--seed", help="The seed to use for the environment.", default=None) -parser.add_argument("--role", help="\"client\" if client, \"server\" if server, \"replay\" if replaying, \"render\" if rendering video", default="client") +parser.add_argument("--role", help="\"client\" if client, \"server\" if server, \"single\" if single-player, \"replay\" if replaying, \"render\" if rendering video", default="client") parser.add_argument("--server_display", action="store_true", help="Whether to show the game window as server (ignored for other roles)") parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765") parser.add_argument("--replay", help="Recording to replay", default="") diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 42bf2f639..eda3f9674 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -19,19 +19,30 @@ def simulator(environment_name: str, seed: int=42, role: str="client", display_s if recording != "" and role != "replay" and role != "render": role = "replay" if role == "server": - server_loop(environment_name, seed, noisy_randomization, display_server) + asyncio.run(server_loop(environment_name, seed, noisy_randomization, display_server)) elif role == "client": - client_loop(environment_name, seed, host, noisy_randomization) + asyncio.run(client_loop(environment_name, seed, host, noisy_randomization)) + elif role == "single": + asyncio.run(single_player(environment_name, seed, noisy_randomization)) elif role == "replay": replay(recording) elif role == "render": render(recording) + else: + print("Invalid role:", role) -def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False, display_server: bool=False): - print("I am server") +async def single_player(environment_name: str, seed: int=42, noisy_randomization: bool=False): + event = asyncio.Event() + server = asyncio.create_task(server_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization, event=event)) + await asyncio.sleep(0.5) # wait for server to initialize + client = asyncio.create_task(client_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization)) + await client + event.set() + await server + +async def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False, display_server: bool=False, event: asyncio.Event=None): waiting_queue = {} - gameQueue = asyncio.Queue() reference_env = create_robotouille_env(environment_name, seed, noisy_randomization)[0] num_players = len(reference_env.get_state().get_players()) @@ -138,12 +149,19 @@ async def handle_connection(websocket): async for message in websocket: await q.put(message) - start_server = websockets.serve(handle_connection, "0.0.0.0", 8765) + #start_server = websockets.serve(handle_connection, "0.0.0.0", 8765) + + #asyncio.get_event_loop().run_until_complete(start_server) + #asyncio.get_event_loop().run_forever() - asyncio.get_event_loop().run_until_complete(start_server) - asyncio.get_event_loop().run_forever() + if event == None: + event = asyncio.Event() -def client_loop(environment_name: str, seed: int = 42, host: str="ws://localhost:8765", noisy_randomization: bool = False): + async with websockets.serve(handle_connection, "localhost", 8765): + print("I am server") + await event.wait() + +async def client_loop(environment_name: str, seed: int = 42, host: str="ws://localhost:8765", noisy_randomization: bool = False): uri = host async def send_actions(websocket, shared_state): @@ -178,24 +196,24 @@ async def receive_responses(websocket, shared_state): shared_state["env"].set_state(pickle.loads(base64.b64decode(data["env"]))) shared_state["obs"] = pickle.loads(base64.b64decode(data["obs"])) - async def interact_with_server(): - async with websockets.connect(uri) as websocket: - env, _, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) - obs, info = env.reset() - shared_state = {"done": False, "env": env, "renderer": renderer, "obs": obs, "player": None} - - opening_message = await websocket.recv() - opening_data = json.loads(opening_message) - player_index = pickle.loads(base64.b64decode(opening_data["player"])) - player = env.get_state().get_players()[player_index] - shared_state["player"] = player - - sender = asyncio.create_task(send_actions(websocket, shared_state)) - receiver = asyncio.create_task(receive_responses(websocket, shared_state)) - await asyncio.gather(sender, receiver) - # Additional cleanup if necessary - - asyncio.get_event_loop().run_until_complete(interact_with_server()) + async with websockets.connect(uri) as websocket: + env, _, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) + obs, info = env.reset() + shared_state = {"done": False, "env": env, "renderer": renderer, "obs": obs, "player": None} + print("In lobby") + + opening_message = await websocket.recv() + print("In game") + opening_data = json.loads(opening_message) + player_index = pickle.loads(base64.b64decode(opening_data["player"])) + player = env.get_state().get_players()[player_index] + shared_state["player"] = player + + + sender = asyncio.create_task(send_actions(websocket, shared_state)) + receiver = asyncio.create_task(receive_responses(websocket, shared_state)) + await asyncio.gather(sender, receiver) + # Additional cleanup if necessary def replay(recording_name: str): if not recording_name: From b95ede909834dd4b820b224984114f0a0c1244e4 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Tue, 7 May 2024 15:39:27 -0400 Subject: [PATCH 28/41] docker build --- Dockerfile | 2 +- robotouille/robotouille_simulator.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index dc4b7c986..e3159e722 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,4 +4,4 @@ COPY . . RUN pip install -r requirements.txt -CMD python main.py --role server \ No newline at end of file +CMD python main.py --environment_name "cook_soup" --role server \ No newline at end of file diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index eda3f9674..57da05bda 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -157,7 +157,7 @@ async def handle_connection(websocket): if event == None: event = asyncio.Event() - async with websockets.serve(handle_connection, "localhost", 8765): + async with websockets.serve(handle_connection, "0.0.0.0", 8765): print("I am server") await event.wait() From da460e304954de787b49cb76c517388fc789ad67 Mon Sep 17 00:00:00 2001 From: Chun Siu Leong Date: Tue, 25 Jun 2024 15:59:36 -0700 Subject: [PATCH 29/41] fixed state bug --- robotouille/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robotouille/env.py b/robotouille/env.py index 7dd650b04..0abdd4ab8 100644 --- a/robotouille/env.py +++ b/robotouille/env.py @@ -302,7 +302,7 @@ def build_state(domain_json, environment_json): true_predicates += build_stacking_predicates(environment_json) goal = build_goal(environment_json) - state = State().initialize(domain, objects, true_predicates, goal) + state = State().initialize(domain, objects, true_predicates, goal, []) return state From 58b9189cdc8246745c2e6ad6e1d4170c8b6f08d0 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Fri, 20 Sep 2024 16:00:25 -0400 Subject: [PATCH 30/41] Adjust comments --- robotouille/robotouille_simulator.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 57da05bda..a9f707678 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -75,7 +75,7 @@ async def simulator(connections): while not done: # Wait for messages from any client - # lol this is causing a memory leak + # TODO(aac77): make more robust receive_tasks = {asyncio.create_task(q.get()): client for client, q in connections.items()} finished_tasks, pending_tasks = await asyncio.wait(receive_tasks.keys(), return_when=asyncio.FIRST_COMPLETED) @@ -88,14 +88,12 @@ async def simulator(connections): for task in finished_tasks: message = task.result() client = receive_tasks[task] - #print(f"Received: {message} from {client.remote_address}") encoded_action = json.loads(message) action = pickle.loads(base64.b64decode(encoded_action)) actions[sockets_to_playerID[client]] = action - #print((action, args)) if SIMULATE_LATENCY: time.sleep(SIMULATED_LATENCY_DURATION) @@ -138,7 +136,7 @@ async def simulator(connections): pickle.dump(recording, f) async def handle_connection(websocket): - # simple lobby code; will break if anyone disconnects, probably has race conditions lol, etc. + # TODO(aac77): make more robust print("Hello client", websocket) q = asyncio.Queue() waiting_queue[websocket] = q @@ -149,11 +147,6 @@ async def handle_connection(websocket): async for message in websocket: await q.put(message) - #start_server = websockets.serve(handle_connection, "0.0.0.0", 8765) - - #asyncio.get_event_loop().run_until_complete(start_server) - #asyncio.get_event_loop().run_forever() - if event == None: event = asyncio.Event() @@ -176,13 +169,13 @@ async def send_actions(websocket, shared_state): keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) action, args = create_action_from_control(env, shared_state["obs"], player, mousedown_events + keydown_events, renderer) + # Use this to simulate disconnect online = not (pygame.key.get_mods() & pygame.KMOD_CAPS) if action is not None: if online: encoded_action = base64.b64encode(pickle.dumps((action, args))).decode('utf-8') await websocket.send(json.dumps(encoded_action)) - #shared_state["obs"], reward, done, info = env.step(action=action, interactive=True) renderer.render(env.get_state(), mode='human') await asyncio.sleep(0) # Yield control to allow other tasks to run From 1110e7b220ccd0dd4182bdd8cd3fca8c1682d429 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Fri, 20 Sep 2024 17:03:50 -0400 Subject: [PATCH 31/41] Set default behavior to run local loop --- main.py | 2 +- robotouille/robotouille_simulator.py | 35 ++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 43fe98299..ae7a95423 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ parser = argparse.ArgumentParser() parser.add_argument("--environment_name", help="The name of the environment to create.", default="original") parser.add_argument("--seed", help="The seed to use for the environment.", default=None) -parser.add_argument("--role", help="\"client\" if client, \"server\" if server, \"single\" if single-player, \"replay\" if replaying, \"render\" if rendering video", default="client") +parser.add_argument("--role", help="\"client\" if client, \"server\" if server, \"single\" if single-player, \"replay\" if replaying, \"render\" if rendering video", default="simulator") parser.add_argument("--server_display", action="store_true", help="Whether to show the game window as server (ignored for other roles)") parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765") parser.add_argument("--replay", help="Recording to replay", default="") diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index a9f707678..d2c0998b9 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -15,7 +15,7 @@ SIMULATE_LATENCY = False SIMULATED_LATENCY_DURATION = 0.25 -def simulator(environment_name: str, seed: int=42, role: str="client", display_server: bool=False, host: str="ws://localhost:8765", recording: str="", noisy_randomization: bool=False): +def simulator(environment_name: str, seed: int=42, role: str="simulator", display_server: bool=False, host: str="ws://localhost:8765", recording: str="", noisy_randomization: bool=False): if recording != "" and role != "replay" and role != "render": role = "replay" if role == "server": @@ -28,6 +28,8 @@ def simulator(environment_name: str, seed: int=42, role: str="client", display_s replay(recording) elif role == "render": render(recording) + elif role == "simulator": + simulate(environment_name, seed, noisy_randomization) else: print("Invalid role:", role) @@ -256,4 +258,33 @@ def render(recording_name: str): t += 1 / fps video_writer.append_data(frame) renderer.render(obs, close=True) - video_writer.close() \ No newline at end of file + video_writer.close() + +def simulate(environment_name, seed, noisy_randomization): + # Your code for robotouille goes here + env, json, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) + obs, info = env.reset() + renderer.render(obs, mode='human') + done = False + interactive = False # Set to True to interact with the environment through terminal REPL (ignores input) + + while not done: + # Construct action from input + pygame_events = pygame.event.get() + # Mouse clicks for movement and pick/place stack/unstack + mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) + # Keyboard events ('e' button) for cut/cook ('space' button) for noop + keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) + actions = [] + action, args = create_action_from_control(env, obs, obs.current_player, mousedown_events+keydown_events, renderer) + for player in obs.get_players(): + if player == obs.current_player: + actions.append((action, args)) + else: + actions.append((None, None)) + if not interactive and action is None: + # Retry for keyboard input + continue + obs, reward, done, info = env.step(actions, interactive=interactive) + renderer.render(obs, mode='human') + renderer.render(obs, close=True) \ No newline at end of file From d717eb811f7e8d20dc460b24fce150971db7f8f4 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Sat, 28 Sep 2024 13:05:32 -0400 Subject: [PATCH 32/41] Factored out server and client code --- main.py | 2 +- networking/client.py | 64 ++++++++++ networking/server.py | 128 +++++++++++++++++++ robotouille/robotouille_simulator.py | 181 +-------------------------- 4 files changed, 199 insertions(+), 176 deletions(-) create mode 100644 networking/client.py create mode 100644 networking/server.py diff --git a/main.py b/main.py index ae7a95423..61dbae655 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ parser = argparse.ArgumentParser() parser.add_argument("--environment_name", help="The name of the environment to create.", default="original") parser.add_argument("--seed", help="The seed to use for the environment.", default=None) -parser.add_argument("--role", help="\"client\" if client, \"server\" if server, \"single\" if single-player, \"replay\" if replaying, \"render\" if rendering video", default="simulator") +parser.add_argument("--role", help="\"simulator\" for vanilla simulator, \"client\" if client, \"server\" if server, \"single\" if single-player, \"replay\" if replaying, \"render\" if rendering video", default="simulator") parser.add_argument("--server_display", action="store_true", help="Whether to show the game window as server (ignored for other roles)") parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765") parser.add_argument("--replay", help="Recording to replay", default="") diff --git a/networking/client.py b/networking/client.py new file mode 100644 index 000000000..623fd3e79 --- /dev/null +++ b/networking/client.py @@ -0,0 +1,64 @@ +import asyncio +import json +import pickle +import base64 +import websockets +import pygame +from robotouille.robotouille_env import create_robotouille_env +from utils.robotouille_input import create_action_from_control + +def run_client(environment_name: str, seed: int = 42, host: str="ws://localhost:8765", noisy_randomization: bool = False): + asyncio.run(client_loop(environment_name, seed, host, noisy_randomization)) + +async def client_loop(environment_name: str, seed: int = 42, host: str="ws://localhost:8765", noisy_randomization: bool = False): + uri = host + + async def send_actions(websocket, shared_state): + env = shared_state["env"] + renderer = shared_state["renderer"] + renderer.render(shared_state["obs"], mode='human') + player = shared_state["player"] + online = True + while not shared_state["done"]: + pygame_events = pygame.event.get() + mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) + keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) + action, args = create_action_from_control(env, shared_state["obs"], player, mousedown_events + keydown_events, renderer) + + # Use this to simulate disconnect + online = not (pygame.key.get_mods() & pygame.KMOD_CAPS) + + if action is not None: + if online: + encoded_action = base64.b64encode(pickle.dumps((action, args))).decode('utf-8') + await websocket.send(json.dumps(encoded_action)) + renderer.render(env.get_state(), mode='human') + + await asyncio.sleep(0) # Yield control to allow other tasks to run + + async def receive_responses(websocket, shared_state): + while not shared_state["done"]: + response = await websocket.recv() + data = json.loads(response) + shared_state["done"] = data["done"] + + shared_state["env"].set_state(pickle.loads(base64.b64decode(data["env"]))) + shared_state["obs"] = pickle.loads(base64.b64decode(data["obs"])) + + async with websockets.connect(uri) as websocket: + env, _, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) + obs, info = env.reset() + shared_state = {"done": False, "env": env, "renderer": renderer, "obs": obs, "player": None} + print("In lobby") + + opening_message = await websocket.recv() + print("In game") + opening_data = json.loads(opening_message) + player_index = pickle.loads(base64.b64decode(opening_data["player"])) + player = env.get_state().get_players()[player_index] + shared_state["player"] = player + + sender = asyncio.create_task(send_actions(websocket, shared_state)) + receiver = asyncio.create_task(receive_responses(websocket, shared_state)) + await asyncio.gather(sender, receiver) + # Additional cleanup if necessary \ No newline at end of file diff --git a/networking/server.py b/networking/server.py new file mode 100644 index 000000000..300c26dc3 --- /dev/null +++ b/networking/server.py @@ -0,0 +1,128 @@ +import asyncio +import json +import pickle +import base64 +import websockets +import time +from pathlib import Path +from datetime import datetime +from robotouille.robotouille_env import create_robotouille_env + +SIMULATE_LATENCY = False +SIMULATED_LATENCY_DURATION = 0.25 + +def run_server(environment_name: str, seed: int=42, noisy_randomization: bool=False, display_server: bool=False, event: asyncio.Event=None): + asyncio.run(server_loop(environment_name, seed, noisy_randomization, display_server)) + +async def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False, display_server: bool=False, event: asyncio.Event=None): + waiting_queue = {} + reference_env = create_robotouille_env(environment_name, seed, noisy_randomization)[0] + num_players = len(reference_env.get_state().get_players()) + + async def simulator(connections): + try: + print("Start game", connections.keys()) + recording = {} + recording["start_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + recording["environment_name"] = environment_name + recording["seed"] = seed + recording["noisy_randomization"] = noisy_randomization + recording["actions"] = [] + recording["violations"] = [] + start_time = time.monotonic() + + env, json_data, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) + obs, info = env.reset() + if display_server: + renderer.render(obs, mode='human') + done = False + interactive = False # Adjust based on client commands later if needed + + assert len(connections) == num_players + sockets_to_playerID = {} + for i, socket in enumerate(connections.keys()): + sockets_to_playerID[socket] = i + player_data = base64.b64encode(pickle.dumps(i)).decode('utf-8') + opening_message = json.dumps({"player": player_data}) + await socket.send(opening_message) + + while not done: + # Wait for messages from any client + # TODO(aac77): make more robust + receive_tasks = {asyncio.create_task(q.get()): client for client, q in connections.items()} + finished_tasks, pending_tasks = await asyncio.wait(receive_tasks.keys(), return_when=asyncio.FIRST_COMPLETED) + + # Cancel pending tasks, otherwise we leak + for task in pending_tasks: + task.cancel() + + # Retrieve the message from the completed task + actions = [(None, None)] * num_players + for task in finished_tasks: + message = task.result() + client = receive_tasks[task] + encoded_action = json.loads(message) + action = pickle.loads(base64.b64decode(encoded_action)) + + actions[sockets_to_playerID[client]] = action + + + if SIMULATE_LATENCY: + time.sleep(SIMULATED_LATENCY_DURATION) + + reply = None + + try: + obs, reward, done, info = env.step(actions, interactive=interactive) + recording["actions"].append((actions, env.get_state(), time.monotonic() - start_time)) + if display_server: + renderer.render(obs, mode='human') + except AssertionError: + print("violation") + recording["violations"].append((actions, time.monotonic() - start_time)) + + env_data = pickle.dumps(env.get_state()) + encoded_env_data = base64.b64encode(env_data).decode('utf-8') + obs_data = pickle.dumps(obs) + encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') + reply = json.dumps({"env": encoded_env_data, "obs": encoded_obs_data, "done": done}) + + if SIMULATE_LATENCY: + time.sleep(SIMULATED_LATENCY_DURATION) + websockets.broadcast(connections.keys(), reply) + recording["result"] = "done" + except BaseException as e: + traceback.print_exc(e) + recording["result"] = traceback.format_exc(e) + finally: + for websocket in connections.keys(): + await websocket.close() + + if display_server: + renderer.render(obs, close=True) + print("GG") + recording["end_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + + p = Path('recordings') + p.mkdir(exist_ok=True) + with open(p / (recording["start_time"] + '.pkl'), 'wb') as f: + pickle.dump(recording, f) + + async def handle_connection(websocket): + # TODO(aac77): make more robust + print("Hello client", websocket) + q = asyncio.Queue() + waiting_queue[websocket] = q + if len(waiting_queue) == num_players: + connections = waiting_queue.copy() + waiting_queue.clear() + asyncio.create_task(simulator(connections)) + async for message in websocket: + await q.put(message) + + if event == None: + event = asyncio.Event() + + async with websockets.serve(handle_connection, "0.0.0.0", 8765): + print("I am server") + await event.wait() \ No newline at end of file diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index d2c0998b9..1be920b46 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -11,17 +11,16 @@ from datetime import datetime import imageio import traceback - -SIMULATE_LATENCY = False -SIMULATED_LATENCY_DURATION = 0.25 +import networking.server as robotouille_server +import networking.client as robotouille_client def simulator(environment_name: str, seed: int=42, role: str="simulator", display_server: bool=False, host: str="ws://localhost:8765", recording: str="", noisy_randomization: bool=False): if recording != "" and role != "replay" and role != "render": role = "replay" if role == "server": - asyncio.run(server_loop(environment_name, seed, noisy_randomization, display_server)) + robotouille_server.run_server(environment_name, seed, noisy_randomization, display_server) elif role == "client": - asyncio.run(client_loop(environment_name, seed, host, noisy_randomization)) + robotouille_client.run_client(environment_name, seed, host, noisy_randomization) elif role == "single": asyncio.run(single_player(environment_name, seed, noisy_randomization)) elif role == "replay": @@ -35,181 +34,13 @@ def simulator(environment_name: str, seed: int=42, role: str="simulator", displa async def single_player(environment_name: str, seed: int=42, noisy_randomization: bool=False): event = asyncio.Event() - server = asyncio.create_task(server_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization, event=event)) + server = asyncio.create_task(robotouille_server.server_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization, event=event)) await asyncio.sleep(0.5) # wait for server to initialize - client = asyncio.create_task(client_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization)) + client = asyncio.create_task(robotouille_client.client_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization)) await client event.set() await server - -async def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False, display_server: bool=False, event: asyncio.Event=None): - waiting_queue = {} - reference_env = create_robotouille_env(environment_name, seed, noisy_randomization)[0] - num_players = len(reference_env.get_state().get_players()) - - async def simulator(connections): - try: - print("Start game", connections.keys()) - recording = {} - recording["start_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - recording["environment_name"] = environment_name - recording["seed"] = seed - recording["noisy_randomization"] = noisy_randomization - recording["actions"] = [] - recording["violations"] = [] - start_time = time.monotonic() - - env, json_data, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) - obs, info = env.reset() - if display_server: - renderer.render(obs, mode='human') - done = False - interactive = False # Adjust based on client commands later if needed - - assert len(connections) == num_players - sockets_to_playerID = {} - for i, socket in enumerate(connections.keys()): - sockets_to_playerID[socket] = i - player_data = base64.b64encode(pickle.dumps(i)).decode('utf-8') - opening_message = json.dumps({"player": player_data}) - await socket.send(opening_message) - - while not done: - # Wait for messages from any client - # TODO(aac77): make more robust - receive_tasks = {asyncio.create_task(q.get()): client for client, q in connections.items()} - finished_tasks, pending_tasks = await asyncio.wait(receive_tasks.keys(), return_when=asyncio.FIRST_COMPLETED) - - # Cancel pending tasks, otherwise we leak - for task in pending_tasks: - task.cancel() - - # Retrieve the message from the completed task - actions = [(None, None)] * num_players - for task in finished_tasks: - message = task.result() - client = receive_tasks[task] - encoded_action = json.loads(message) - action = pickle.loads(base64.b64decode(encoded_action)) - - actions[sockets_to_playerID[client]] = action - - - if SIMULATE_LATENCY: - time.sleep(SIMULATED_LATENCY_DURATION) - - reply = None - - try: - obs, reward, done, info = env.step(actions, interactive=interactive) - recording["actions"].append((actions, env.get_state(), time.monotonic() - start_time)) - if display_server: - renderer.render(obs, mode='human') - except AssertionError: - print("violation") - recording["violations"].append((actions, time.monotonic() - start_time)) - - env_data = pickle.dumps(env.get_state()) - encoded_env_data = base64.b64encode(env_data).decode('utf-8') - obs_data = pickle.dumps(obs) - encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') - reply = json.dumps({"env": encoded_env_data, "obs": encoded_obs_data, "done": done}) - - if SIMULATE_LATENCY: - time.sleep(SIMULATED_LATENCY_DURATION) - websockets.broadcast(connections.keys(), reply) - recording["result"] = "done" - except BaseException as e: - traceback.print_exc(e) - recording["result"] = traceback.format_exc(e) - finally: - for websocket in connections.keys(): - await websocket.close() - - if display_server: - renderer.render(obs, close=True) - print("GG") - recording["end_time"] = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - - p = Path('recordings') - p.mkdir(exist_ok=True) - with open(p / (recording["start_time"] + '.pkl'), 'wb') as f: - pickle.dump(recording, f) - - async def handle_connection(websocket): - # TODO(aac77): make more robust - print("Hello client", websocket) - q = asyncio.Queue() - waiting_queue[websocket] = q - if len(waiting_queue) == num_players: - connections = waiting_queue.copy() - waiting_queue.clear() - asyncio.create_task(simulator(connections)) - async for message in websocket: - await q.put(message) - - if event == None: - event = asyncio.Event() - - async with websockets.serve(handle_connection, "0.0.0.0", 8765): - print("I am server") - await event.wait() - -async def client_loop(environment_name: str, seed: int = 42, host: str="ws://localhost:8765", noisy_randomization: bool = False): - uri = host - - async def send_actions(websocket, shared_state): - env = shared_state["env"] - renderer = shared_state["renderer"] - renderer.render(shared_state["obs"], mode='human') - player = shared_state["player"] - online = True - while not shared_state["done"]: - pygame_events = pygame.event.get() - mousedown_events = list(filter(lambda e: e.type == pygame.MOUSEBUTTONDOWN, pygame_events)) - keydown_events = list(filter(lambda e: e.type == pygame.KEYDOWN, pygame_events)) - action, args = create_action_from_control(env, shared_state["obs"], player, mousedown_events + keydown_events, renderer) - - # Use this to simulate disconnect - online = not (pygame.key.get_mods() & pygame.KMOD_CAPS) - - if action is not None: - if online: - encoded_action = base64.b64encode(pickle.dumps((action, args))).decode('utf-8') - await websocket.send(json.dumps(encoded_action)) - renderer.render(env.get_state(), mode='human') - - await asyncio.sleep(0) # Yield control to allow other tasks to run - - async def receive_responses(websocket, shared_state): - while not shared_state["done"]: - response = await websocket.recv() - data = json.loads(response) - shared_state["done"] = data["done"] - - shared_state["env"].set_state(pickle.loads(base64.b64decode(data["env"]))) - shared_state["obs"] = pickle.loads(base64.b64decode(data["obs"])) - - async with websockets.connect(uri) as websocket: - env, _, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) - obs, info = env.reset() - shared_state = {"done": False, "env": env, "renderer": renderer, "obs": obs, "player": None} - print("In lobby") - - opening_message = await websocket.recv() - print("In game") - opening_data = json.loads(opening_message) - player_index = pickle.loads(base64.b64decode(opening_data["player"])) - player = env.get_state().get_players()[player_index] - shared_state["player"] = player - - - sender = asyncio.create_task(send_actions(websocket, shared_state)) - receiver = asyncio.create_task(receive_responses(websocket, shared_state)) - await asyncio.gather(sender, receiver) - # Additional cleanup if necessary - def replay(recording_name: str): if not recording_name: raise ValueError("Empty recording_name supplied") From 34faf0ccd3a97541e61b8e8cd1d53990426e35e3 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Sat, 28 Sep 2024 23:19:26 -0400 Subject: [PATCH 33/41] Add README --- networking/README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 networking/README.md diff --git a/networking/README.md b/networking/README.md new file mode 100644 index 000000000..770a2a97e --- /dev/null +++ b/networking/README.md @@ -0,0 +1,40 @@ +# Robotouille Networking README + +## Overview + +Built into Robotouille is networking support for multiplayer. Robotouille uses an authoritative server, which clients can connect to. Servers record games played on them to collect data. + +## Available Modes + +Several modes are offered, which can be chosen using the `--role` argument: + +1. `server` +2. `client ` +3. `single` +4. `replay` +5. `render` +6. `simulator` + +## Simulator + +This mode runs Robotouille without any networking overhead. + +## Server + +This mode sets up an Robotouille server. Clients that connect are automatically matchmaked and put together into lobbies. Use argument `display_server` to have the server render active games. + +## Client + +This mode runs the Robotouille client. Use argument `host` to choose which host to connect to. Defaults to local host. + +## Single + +This mode runs both the server and client for a single player experience. Server features, such as game recordings, remain available. + +## Replay + +This mode replays a recorded game through a window. The recording is specified with argument `recording`. + +## Render + +This mode renders a recording game into a video. The video is exported to the recordings folder. The recording is specified with argument `recording`. \ No newline at end of file From e66a556fdadaec410b75d958df405a7d56afcbc5 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Fri, 18 Oct 2024 16:48:40 -0400 Subject: [PATCH 34/41] Add example cmds to README --- networking/README.md | 44 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/networking/README.md b/networking/README.md index 770a2a97e..fff3c3c81 100644 --- a/networking/README.md +++ b/networking/README.md @@ -8,33 +8,57 @@ Built into Robotouille is networking support for multiplayer. Robotouille uses a Several modes are offered, which can be chosen using the `--role` argument: -1. `server` -2. `client ` -3. `single` -4. `replay` -5. `render` -6. `simulator` +1. `local` +2. `server` +3. `client` +4. `single` +5. `replay` +6. `render` -## Simulator +## Local This mode runs Robotouille without any networking overhead. +E.g. +```python main.py --environment_name original``` + ## Server This mode sets up an Robotouille server. Clients that connect are automatically matchmaked and put together into lobbies. Use argument `display_server` to have the server render active games. +E.g. +```python main.py --role server``` + +To render active games: +```python main.py --role server --server_display``` + ## Client -This mode runs the Robotouille client. Use argument `host` to choose which host to connect to. Defaults to local host. +This mode runs the Robotouille client. Use argument `host` to choose which host to connect to. Defaults to local host. Note that the URL should be a websocket at port 8765. + +Connect to local host: +```python main.py --role client``` + +Connect to another host: +```python main.py --role client --host ws://example.com:8765``` ## Single This mode runs both the server and client for a single player experience. Server features, such as game recordings, remain available. +E.g. +```python main.py --role single``` + ## Replay -This mode replays a recorded game through a window. The recording is specified with argument `recording`. +This mode replays a recorded game through a window. The recording is specified with argument `recording` (exclude file extension). + +E.g. +```python main.py --role replay --recording 20241018_164547_745081``` ## Render -This mode renders a recording game into a video. The video is exported to the recordings folder. The recording is specified with argument `recording`. \ No newline at end of file +This mode renders a recording game into a video. The video is exported to the recordings folder. The recording is specified with argument `recording` (exclude file extension). + +E.g. +```python main.py --role render --recording 20241018_164547_745081``` \ No newline at end of file From 1fef98e97fe1116b7817c9e0cd55dffc0d0d44b7 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Fri, 18 Oct 2024 16:50:57 -0400 Subject: [PATCH 35/41] Reorganize main --- main.py | 10 +++++++--- robotouille/robotouille_simulator.py | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 61dbae655..c5ec03b74 100644 --- a/main.py +++ b/main.py @@ -2,13 +2,17 @@ import argparse parser = argparse.ArgumentParser() + +# Robotuille game parameters parser.add_argument("--environment_name", help="The name of the environment to create.", default="original") parser.add_argument("--seed", help="The seed to use for the environment.", default=None) -parser.add_argument("--role", help="\"simulator\" for vanilla simulator, \"client\" if client, \"server\" if server, \"single\" if single-player, \"replay\" if replaying, \"render\" if rendering video", default="simulator") +parser.add_argument("--noisy_randomization", action="store_true", help="Whether to use 'noisy randomization' for procedural generation") + +# Network parameters (see README under networking folder) +parser.add_argument("--role", help="\"local\" for vanilla simulator, \"client\" if client, \"server\" if server, \"single\" if single-player, \"replay\" if replaying, \"render\" if rendering video", default="local") parser.add_argument("--server_display", action="store_true", help="Whether to show the game window as server (ignored for other roles)") parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765") parser.add_argument("--replay", help="Recording to replay", default="") -parser.add_argument("--noisy_randomization", action="store_true", help="Whether to use 'noisy randomization' for procedural generation") args = parser.parse_args() -simulator(args.environment_name, args.seed, args.role, args.server_display, args.host, args.replay, args.noisy_randomization) +simulator(args.environment_name, args.seed, args.noisy_randomization, args.role, args.server_display, args.host, args.replay) diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 1be920b46..4d4d241ad 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -14,7 +14,7 @@ import networking.server as robotouille_server import networking.client as robotouille_client -def simulator(environment_name: str, seed: int=42, role: str="simulator", display_server: bool=False, host: str="ws://localhost:8765", recording: str="", noisy_randomization: bool=False): +def simulator(environment_name: str, seed: int=42, noisy_randomization: bool=False, role: str="local", display_server: bool=False, host: str="ws://localhost:8765", recording: str=""): if recording != "" and role != "replay" and role != "render": role = "replay" if role == "server": @@ -27,7 +27,7 @@ def simulator(environment_name: str, seed: int=42, role: str="simulator", displa replay(recording) elif role == "render": render(recording) - elif role == "simulator": + elif role == "local": simulate(environment_name, seed, noisy_randomization) else: print("Invalid role:", role) From 22c6ccf77116d134f78f37c7d6436130cbb6806d Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Fri, 18 Oct 2024 17:04:58 -0400 Subject: [PATCH 36/41] Refactor all other modes to networking folder --- main.py | 4 +- networking/server.py | 1 + networking/utils/render.py | 37 ++++++++++++ networking/utils/replay.py | 27 +++++++++ networking/utils/single_player.py | 15 +++++ robotouille/robotouille_simulator.py | 86 ++++------------------------ 6 files changed, 93 insertions(+), 77 deletions(-) create mode 100644 networking/utils/render.py create mode 100644 networking/utils/replay.py create mode 100644 networking/utils/single_player.py diff --git a/main.py b/main.py index c5ec03b74..0afc4fc0a 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ parser.add_argument("--role", help="\"local\" for vanilla simulator, \"client\" if client, \"server\" if server, \"single\" if single-player, \"replay\" if replaying, \"render\" if rendering video", default="local") parser.add_argument("--server_display", action="store_true", help="Whether to show the game window as server (ignored for other roles)") parser.add_argument("--host", help="Host to connect to", default="ws://localhost:8765") -parser.add_argument("--replay", help="Recording to replay", default="") +parser.add_argument("--recording", help="Recording to replay", default="") args = parser.parse_args() -simulator(args.environment_name, args.seed, args.noisy_randomization, args.role, args.server_display, args.host, args.replay) +simulator(args.environment_name, args.seed, args.noisy_randomization, args.role, args.server_display, args.host, args.recording) diff --git a/networking/server.py b/networking/server.py index 300c26dc3..5a84ae151 100644 --- a/networking/server.py +++ b/networking/server.py @@ -4,6 +4,7 @@ import base64 import websockets import time +import traceback from pathlib import Path from datetime import datetime from robotouille.robotouille_env import create_robotouille_env diff --git a/networking/utils/render.py b/networking/utils/render.py new file mode 100644 index 000000000..f9620707a --- /dev/null +++ b/networking/utils/render.py @@ -0,0 +1,37 @@ +import pickle +import imageio +from pathlib import Path +from robotouille.robotouille_env import create_robotouille_env + +def run_render(recording_name: str): + render(recording_name) + +def render(recording_name: str): + p = Path('recordings') + with open(p / (recording_name + '.pkl'), 'rb') as f: + recording = pickle.load(f) + + env, _, renderer = create_robotouille_env(recording["environment_name"], recording["seed"], recording["noisy_randomization"]) + obs, _ = env.reset() + frame = renderer.render(obs, mode='rgb_array') + + vp = Path('recordings') + vp.mkdir(exist_ok=True) + fps = 20 + video_writer = imageio.get_writer(vp / (recording_name + '.mp4'), fps=fps) + + i = 0 + t = 0 + while i < len(recording["actions"]): + actions, state, time_stamp = recording["actions"][i] + while t > time_stamp: + obs, reward, done, info = env.step(actions=actions, interactive=False) + frame = renderer.render(obs, mode='rgb_array') + i += 1 + if i >= len(recording["actions"]): + break + action, state, time_stamp = recording["actions"][i] + t += 1 / fps + video_writer.append_data(frame) + renderer.render(obs, close=True) + video_writer.close() diff --git a/networking/utils/replay.py b/networking/utils/replay.py new file mode 100644 index 000000000..d76c248e6 --- /dev/null +++ b/networking/utils/replay.py @@ -0,0 +1,27 @@ +import pickle +import time +from pathlib import Path +from robotouille.robotouille_env import create_robotouille_env + +def run_replay(recording_name: str): + replay(recording_name) + +def replay(recording_name: str): + if not recording_name: + raise ValueError("Empty recording_name supplied") + + p = Path('recordings') + with open(p / (recording_name + '.pkl'), 'rb') as f: + recording = pickle.load(f) + + env, _, renderer = create_robotouille_env(recording["environment_name"], recording["seed"], recording["noisy_randomization"]) + obs, _ = env.reset() + renderer.render(obs, mode='human') + + previous_time = 0 + for actions, state, t in recording["actions"]: + time.sleep(t - previous_time) + previous_time = t + obs, reward, done, info = env.step(actions=actions, interactive=False) + renderer.render(obs, mode='human') + renderer.render(obs, close=True) diff --git a/networking/utils/single_player.py b/networking/utils/single_player.py new file mode 100644 index 000000000..d8b5e56a6 --- /dev/null +++ b/networking/utils/single_player.py @@ -0,0 +1,15 @@ +import asyncio +import networking.server as robotouille_server +import networking.client as robotouille_client + +def run_single(environment_name: str, seed: int=42, noisy_randomization: bool=False): + asyncio.run(single_player(environment_name, seed, noisy_randomization)) + +async def single_player(environment_name: str, seed: int=42, noisy_randomization: bool=False): + event = asyncio.Event() + server = asyncio.create_task(robotouille_server.server_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization, event=event)) + await asyncio.sleep(0.5) # wait for server to initialize + client = asyncio.create_task(robotouille_client.client_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization)) + await client + event.set() + await server diff --git a/robotouille/robotouille_simulator.py b/robotouille/robotouille_simulator.py index 4d4d241ad..53c04d12b 100644 --- a/robotouille/robotouille_simulator.py +++ b/robotouille/robotouille_simulator.py @@ -1,96 +1,32 @@ import pygame from utils.robotouille_input import create_action_from_control from robotouille.robotouille_env import create_robotouille_env -import asyncio -import json -import pickle -import base64 -import websockets -import time -from pathlib import Path -from datetime import datetime -import imageio -import traceback import networking.server as robotouille_server import networking.client as robotouille_client +import networking.utils.single_player as robotouille_single_player +import networking.utils.replay as robotouille_replay +import networking.utils.render as robotouille_render def simulator(environment_name: str, seed: int=42, noisy_randomization: bool=False, role: str="local", display_server: bool=False, host: str="ws://localhost:8765", recording: str=""): + # We assume that if a recording is provided, then the user would like to replay it if recording != "" and role != "replay" and role != "render": role = "replay" + + if role == "local": + simulate(environment_name, seed, noisy_randomization) if role == "server": robotouille_server.run_server(environment_name, seed, noisy_randomization, display_server) elif role == "client": robotouille_client.run_client(environment_name, seed, host, noisy_randomization) elif role == "single": - asyncio.run(single_player(environment_name, seed, noisy_randomization)) + robotouille_single_player.run_single(environment_name, seed, noisy_randomization) elif role == "replay": - replay(recording) + robotouille_replay.run_replay(recording) elif role == "render": - render(recording) - elif role == "local": - simulate(environment_name, seed, noisy_randomization) + robotouille_render.run_render(recording) else: print("Invalid role:", role) -async def single_player(environment_name: str, seed: int=42, noisy_randomization: bool=False): - event = asyncio.Event() - server = asyncio.create_task(robotouille_server.server_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization, event=event)) - await asyncio.sleep(0.5) # wait for server to initialize - client = asyncio.create_task(robotouille_client.client_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization)) - await client - event.set() - await server - -def replay(recording_name: str): - if not recording_name: - raise ValueError("Empty recording_name supplied") - - p = Path('recordings') - with open(p / (recording_name + '.pkl'), 'rb') as f: - recording = pickle.load(f) - - env, _, renderer = create_robotouille_env(recording["environment_name"], recording["seed"], recording["noisy_randomization"]) - obs, _ = env.reset() - renderer.render(obs, mode='human') - - previous_time = 0 - for actions, state, t in recording["actions"]: - time.sleep(t - previous_time) - previous_time = t - obs, reward, done, info = env.step(actions=actions, interactive=False) - renderer.render(obs, mode='human') - renderer.render(obs, close=True) - -def render(recording_name: str): - p = Path('recordings') - with open(p / (recording_name + '.pkl'), 'rb') as f: - recording = pickle.load(f) - - env, _, renderer = create_robotouille_env(recording["environment_name"], recording["seed"], recording["noisy_randomization"]) - obs, _ = env.reset() - frame = renderer.render(obs, mode='rgb_array') - - vp = Path('recordings') - vp.mkdir(exist_ok=True) - fps = 20 - video_writer = imageio.get_writer(vp / (recording_name + '.mp4'), fps=fps) - - i = 0 - t = 0 - while i < len(recording["actions"]): - actions, state, time_stamp = recording["actions"][i] - while t > time_stamp: - obs, reward, done, info = env.step(actions=actions, interactive=False) - frame = renderer.render(obs, mode='rgb_array') - i += 1 - if i >= len(recording["actions"]): - break - action, state, time_stamp = recording["actions"][i] - t += 1 / fps - video_writer.append_data(frame) - renderer.render(obs, close=True) - video_writer.close() - def simulate(environment_name, seed, noisy_randomization): # Your code for robotouille goes here env, json, renderer = create_robotouille_env(environment_name, seed, noisy_randomization) @@ -118,4 +54,4 @@ def simulate(environment_name, seed, noisy_randomization): continue obs, reward, done, info = env.step(actions, interactive=interactive) renderer.render(obs, mode='human') - renderer.render(obs, close=True) \ No newline at end of file + renderer.render(obs, close=True) From aa26202eaa05689753e5b9ee36bb878b1238d794 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Fri, 18 Oct 2024 17:10:13 -0400 Subject: [PATCH 37/41] Elaborated TODOs --- networking/server.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/networking/server.py b/networking/server.py index 5a84ae151..51da1569b 100644 --- a/networking/server.py +++ b/networking/server.py @@ -49,7 +49,10 @@ async def simulator(connections): while not done: # Wait for messages from any client - # TODO(aac77): make more robust + # TODO(aac77): + # currently cannot handle disconnected clients + # cannot handle invalid messages + # pickle needs to removed for security receive_tasks = {asyncio.create_task(q.get()): client for client, q in connections.items()} finished_tasks, pending_tasks = await asyncio.wait(receive_tasks.keys(), return_when=asyncio.FIRST_COMPLETED) @@ -110,7 +113,8 @@ async def simulator(connections): pickle.dump(recording, f) async def handle_connection(websocket): - # TODO(aac77): make more robust + # TODO(aac77): + # cannot handle disconnections print("Hello client", websocket) q = asyncio.Queue() waiting_queue[websocket] = q From 0c00de9e00ea56c09ee144a9851009aff745a56f Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Sat, 26 Oct 2024 12:17:14 -0400 Subject: [PATCH 38/41] Documentation fixes --- main.py | 2 +- networking/README.md | 32 ++++++++++++++++++++++++-------- networking/server.py | 4 ++-- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/main.py b/main.py index 0afc4fc0a..933215b87 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ parser = argparse.ArgumentParser() -# Robotuille game parameters +# Robotouille game parameters parser.add_argument("--environment_name", help="The name of the environment to create.", default="original") parser.add_argument("--seed", help="The seed to use for the environment.", default=None) parser.add_argument("--noisy_randomization", action="store_true", help="Whether to use 'noisy randomization' for procedural generation") diff --git a/networking/README.md b/networking/README.md index fff3c3c81..3896cbfcc 100644 --- a/networking/README.md +++ b/networking/README.md @@ -20,45 +20,61 @@ Several modes are offered, which can be chosen using the `--role` argument: This mode runs Robotouille without any networking overhead. E.g. -```python main.py --environment_name original``` +```sh +python main.py --environment_name original +``` ## Server This mode sets up an Robotouille server. Clients that connect are automatically matchmaked and put together into lobbies. Use argument `display_server` to have the server render active games. E.g. -```python main.py --role server``` +```sh +python main.py --role server +``` To render active games: -```python main.py --role server --server_display``` +```sh +python main.py --role server --server_display +``` ## Client This mode runs the Robotouille client. Use argument `host` to choose which host to connect to. Defaults to local host. Note that the URL should be a websocket at port 8765. Connect to local host: -```python main.py --role client``` +```sh +python main.py --role client +``` Connect to another host: -```python main.py --role client --host ws://example.com:8765``` +```sh +python main.py --role client --host ws://example.com:8765 +``` ## Single This mode runs both the server and client for a single player experience. Server features, such as game recordings, remain available. E.g. -```python main.py --role single``` +```sh +python main.py --role single +``` ## Replay This mode replays a recorded game through a window. The recording is specified with argument `recording` (exclude file extension). E.g. -```python main.py --role replay --recording 20241018_164547_745081``` +```sh +python main.py --role replay --recording 20241018_164547_745081 +``` ## Render This mode renders a recording game into a video. The video is exported to the recordings folder. The recording is specified with argument `recording` (exclude file extension). E.g. -```python main.py --role render --recording 20241018_164547_745081``` \ No newline at end of file +```sh +python main.py --role render --recording 20241018_164547_745081 +``` \ No newline at end of file diff --git a/networking/server.py b/networking/server.py index 51da1569b..9f1fbc025 100644 --- a/networking/server.py +++ b/networking/server.py @@ -49,7 +49,7 @@ async def simulator(connections): while not done: # Wait for messages from any client - # TODO(aac77): + # TODO(aac77): #41 # currently cannot handle disconnected clients # cannot handle invalid messages # pickle needs to removed for security @@ -113,7 +113,7 @@ async def simulator(connections): pickle.dump(recording, f) async def handle_connection(websocket): - # TODO(aac77): + # TODO(aac77): $41 # cannot handle disconnections print("Hello client", websocket) q = asyncio.Queue() From e70703980cdfd756da1428a6192992ffbe079f97 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Sat, 26 Oct 2024 12:25:10 -0400 Subject: [PATCH 39/41] Add issues to TODO --- networking/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/networking/server.py b/networking/server.py index 9f1fbc025..dddb8eff7 100644 --- a/networking/server.py +++ b/networking/server.py @@ -113,7 +113,7 @@ async def simulator(connections): pickle.dump(recording, f) async def handle_connection(websocket): - # TODO(aac77): $41 + # TODO(aac77): #41 # cannot handle disconnections print("Hello client", websocket) q = asyncio.Queue() From d78c14ff5dcfff1660ed2b0cca8281cc9b7f0a05 Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Sat, 9 Nov 2024 11:49:54 -0500 Subject: [PATCH 40/41] Server self-updates --- logs/login.log | 18 ------- networking/client.py | 6 +-- networking/server.py | 89 ++++++++++++++++++++----------- networking/utils/single_player.py | 77 ++++++++++++++++++++++---- 4 files changed, 127 insertions(+), 63 deletions(-) delete mode 100644 logs/login.log diff --git a/logs/login.log b/logs/login.log deleted file mode 100644 index 2a07c6493..000000000 --- a/logs/login.log +++ /dev/null @@ -1,18 +0,0 @@ -2024-11-02 02:18:23,105 - Server started -2024-11-02 02:18:23,115 - WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. - * Running on http://127.0.0.1:8000 -2024-11-02 02:18:23,115 - Press CTRL+C to quit -2024-11-02 02:19:10,547 - Successful login for existing user: alan1001chen@gmail.com from 127.0.0.1 -2024-11-02 02:19:10,563 - 127.0.0.1 - - [02/Nov/2024 02:19:10] "POST /api/login HTTP/1.1" 200 - -2024-11-02 02:19:44,459 - New user registered and logged in: aac77@cornell.edu from 127.0.0.1 -2024-11-02 02:19:44,459 - 127.0.0.1 - - [02/Nov/2024 02:19:44] "POST /api/login HTTP/1.1" 200 - -2024-11-02 12:23:57,061 - Server started -2024-11-02 12:23:57,073 - WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. - * Running on http://127.0.0.1:8000 -2024-11-02 12:23:57,084 - Press CTRL+C to quit -2024-11-02 12:24:10,846 - Successful login for existing user: alan1001chen@gmail.com from 127.0.0.1 -2024-11-02 12:24:10,854 - 127.0.0.1 - - [02/Nov/2024 12:24:10] "POST /api/login HTTP/1.1" 200 - -2024-11-02 12:24:27,158 - Username updated for user alan1001chen@gmail.com to robotouille lover from 127.0.0.1 -2024-11-02 12:24:27,158 - 127.0.0.1 - - [02/Nov/2024 12:24:27] "POST /api/update_username HTTP/1.1" 200 - -2024-11-02 12:24:51,251 - Successful login for existing user: alan1001chen@gmail.com from 127.0.0.1 -2024-11-02 12:24:51,264 - 127.0.0.1 - - [02/Nov/2024 12:24:51] "POST /api/login HTTP/1.1" 200 - diff --git a/networking/client.py b/networking/client.py index 88abce9b3..a44c2597a 100644 --- a/networking/client.py +++ b/networking/client.py @@ -7,10 +7,10 @@ from robotouille.robotouille_env import create_robotouille_env from utils.robotouille_input import create_action_from_control -def run_client(environment_name: str, seed: int = 42, noisy_randomization: bool = False, movement_mode: str='traverse', host: str="ws://localhost:8765"): +def run_client(environment_name: str, seed: int, noisy_randomization: bool, movement_mode: str, host: str="ws://localhost:8765"): asyncio.run(client_loop(environment_name, seed, noisy_randomization, movement_mode, host)) -async def client_loop(environment_name: str, seed: int = 42, noisy_randomization: bool = False, movement_mode: str='traverse', host: str="ws://localhost:8765"): +async def client_loop(environment_name: str, seed: int, noisy_randomization: bool, movement_mode: str, host: str="ws://localhost:8765"): uri = host async def send_actions(websocket, shared_state): @@ -32,7 +32,7 @@ async def send_actions(websocket, shared_state): if online: encoded_action = base64.b64encode(pickle.dumps((action, args))).decode('utf-8') await websocket.send(json.dumps(encoded_action)) - renderer.render(env.get_state(), mode='human') + renderer.render(shared_state["obs"], mode='human') await asyncio.sleep(0) # Yield control to allow other tasks to run diff --git a/networking/server.py b/networking/server.py index 180f144c6..a54ed73ab 100644 --- a/networking/server.py +++ b/networking/server.py @@ -9,14 +9,19 @@ from pathlib import Path from datetime import datetime from robotouille.robotouille_env import create_robotouille_env +from backend.movement.player import Player +from backend.movement.movement import Movement +# currently unimplemented SIMULATE_LATENCY = False SIMULATED_LATENCY_DURATION = 0.25 -def run_server(environment_name: str, seed: int=42, noisy_randomization: bool=False, movement_mode: str='traverse', display_server: bool=False, event: asyncio.Event=None): - asyncio.run(server_loop(environment_name, seed, noisy_randomization, movement_mode, display_server)) +UPDATE_INTERVAL = 1/60 -async def server_loop(environment_name: str, seed: int=42, noisy_randomization: bool=False, movement_mode: str='traverse', display_server: bool=False, event: asyncio.Event=None): +def run_server(environment_name: str, seed: int, noisy_randomization: bool, movement_mode: str, display_server: bool=False, event: asyncio.Event=None): + asyncio.run(server_loop(environment_name, seed, noisy_randomization, movement_mode, display_server, event)) + +async def server_loop(environment_name: str, seed: int, noisy_randomization: bool, movement_mode: str, display_server: bool, event: asyncio.Event): waiting_queue = {} reference_env = create_robotouille_env(environment_name, movement_mode, seed, noisy_randomization)[0] num_players = len(reference_env.get_state().get_players()) @@ -49,37 +54,58 @@ async def simulator(connections): opening_message = json.dumps({"player": player_data}) await socket.send(opening_message) + last_update_time = time.monotonic() + + clock = pygame.time.Clock() + while not done: # Wait for messages from any client # TODO(aac77): #41 # currently cannot handle disconnected clients # cannot handle invalid messages # pickle needs to removed for security - receive_tasks = {asyncio.create_task(q.get()): client for client, q in connections.items()} - finished_tasks, pending_tasks = await asyncio.wait(receive_tasks.keys(), return_when=asyncio.FIRST_COMPLETED) + # will not function correctly if there are parallel instances due to global game state + current_time = time.monotonic() + time_until_next_update = UPDATE_INTERVAL - (current_time - last_update_time) - # Cancel pending tasks, otherwise we leak - for task in pending_tasks: - task.cancel() - - # Retrieve the message from the completed task - actions = [(None, None)] * num_players - for task in finished_tasks: - message = task.result() - client = receive_tasks[task] - encoded_action = json.loads(message) - action = pickle.loads(base64.b64decode(encoded_action)) + if time_until_next_update > 0: + receive_tasks = {asyncio.create_task(q.get()): client + for client, q in connections.items()} + try: + finished_tasks, pending_tasks = await asyncio.wait( + receive_tasks.keys(), + timeout=time_until_next_update, + return_when=asyncio.FIRST_COMPLETED + ) + + for task in pending_tasks: + task.cancel() + + actions = [(None, None)] * num_players + for task in finished_tasks: + message = task.result() + client = receive_tasks[task] + player_id = sockets_to_playerID[client] + player_obj = Player.get_player(obs.get_players()[player_id].name) + + # Only process action if player is not moving + if not Movement.is_player_moving(player_obj.name): + encoded_action = json.loads(message) + action = pickle.loads(base64.b64decode(encoded_action)) + actions[player_id] = action + # If player is moving, their action remains (None, None) + + except asyncio.TimeoutError: + # No inputs, self update + actions = [(None, None)] * num_players - actions[sockets_to_playerID[client]] = action - - - if SIMULATE_LATENCY: - time.sleep(SIMULATED_LATENCY_DURATION) - - reply = None + else: + # Must update immediately, use no-op inputs + actions = [(None, None)] * num_players try: - obs, reward, done, info = env.step(actions, clock=pygame.time.Clock(), interactive=interactive) + clock.tick() + obs, reward, done, info = env.step(actions, clock=clock, interactive=interactive) recording["actions"].append((actions, env.get_state(), time.monotonic() - start_time)) if display_server: renderer.render(obs, mode='human') @@ -87,15 +113,14 @@ async def simulator(connections): print("violation") recording["violations"].append((actions, time.monotonic() - start_time)) - env_data = pickle.dumps(env.get_state()) - encoded_env_data = base64.b64encode(env_data).decode('utf-8') - obs_data = pickle.dumps(obs) - encoded_obs_data = base64.b64encode(obs_data).decode('utf-8') - reply = json.dumps({"env": encoded_env_data, "obs": encoded_obs_data, "done": done}) + env_data = base64.b64encode(pickle.dumps(env.get_state())).decode('utf-8') + obs_data = base64.b64encode(pickle.dumps(obs)).decode('utf-8') + reply = json.dumps({"env": env_data, "obs": obs_data, "done": done}) - if SIMULATE_LATENCY: - time.sleep(SIMULATED_LATENCY_DURATION) - websockets.broadcast(connections.keys(), reply) + await asyncio.gather(*(websocket.send(reply) for websocket in connections.keys())) + + last_update_time = time.monotonic() + recording["result"] = "done" except BaseException as e: traceback.print_exc(e) diff --git a/networking/utils/single_player.py b/networking/utils/single_player.py index 94cb04898..bfde48a81 100644 --- a/networking/utils/single_player.py +++ b/networking/utils/single_player.py @@ -1,15 +1,72 @@ +import threading +import time import asyncio import networking.server as robotouille_server import networking.client as robotouille_client -def run_single(environment_name: str, seed: int=42, noisy_randomization: bool=False, movement_mode: str='traverse'): - asyncio.run(single_player(environment_name, seed, noisy_randomization, movement_mode)) - -async def single_player(environment_name: str, seed: int=42, noisy_randomization: bool=False, bool=False, movement_mode: str='traverse'): - event = asyncio.Event() - server = asyncio.create_task(robotouille_server.server_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization, movement_mode=movement_mode, event=event)) - await asyncio.sleep(0.5) # wait for server to initialize - client = asyncio.create_task(robotouille_client.client_loop(environment_name=environment_name, seed=seed, noisy_randomization=noisy_randomization, movement_mode=movement_mode)) - await client +def run_single(environment_name: str, seed: int, noisy_randomization: bool, movement_mode: str): + event = threading.Event() + + server_thread = threading.Thread( + target=run_server_in_loop, + args=(environment_name, seed, noisy_randomization, movement_mode, event) + ) + + client_thread = threading.Thread( + target=run_client_in_loop, + args=(environment_name, seed, noisy_randomization, movement_mode) + ) + + server_thread.start() + time.sleep(0.5) # wait for server to initialize + + client_thread.start() + + client_thread.join() + event.set() - await server + server_thread.join() + +def run_server_in_loop(environment_name: str, seed: int, noisy_randomization: bool, movement_mode: str, event: threading.Event): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async_event = asyncio.Event() + + def set_async_event(): + loop.call_soon_threadsafe(async_event.set) + + # Watch the threading event + def check_thread_event(): + if event.is_set(): + set_async_event() + else: + loop.call_later(0.1, check_thread_event) + + loop.call_soon(check_thread_event) + + loop.run_until_complete( + robotouille_server.server_loop( + environment_name=environment_name, + seed=seed, + noisy_randomization=noisy_randomization, + movement_mode=movement_mode, + display_server=False, + event=async_event + ) + ) + loop.close() + +def run_client_in_loop(environment_name: str, seed: int, noisy_randomization: bool, movement_mode: str): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + loop.run_until_complete( + robotouille_client.client_loop( + environment_name=environment_name, + seed=seed, + noisy_randomization=noisy_randomization, + movement_mode=movement_mode + ) + ) + loop.close() \ No newline at end of file From dc1c7d93423bdf24de99f4029e4eb580dacad3eb Mon Sep 17 00:00:00 2001 From: AlanAnxinChen <99108296+AlanAnxinChen@users.noreply.github.com> Date: Sat, 9 Nov 2024 12:18:53 -0500 Subject: [PATCH 41/41] Players exchanged between server and client --- networking/client.py | 2 ++ networking/server.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/networking/client.py b/networking/client.py index a44c2597a..c30db2601 100644 --- a/networking/client.py +++ b/networking/client.py @@ -6,6 +6,7 @@ import pygame from robotouille.robotouille_env import create_robotouille_env from utils.robotouille_input import create_action_from_control +from backend.movement.player import Player def run_client(environment_name: str, seed: int, noisy_randomization: bool, movement_mode: str, host: str="ws://localhost:8765"): asyncio.run(client_loop(environment_name, seed, noisy_randomization, movement_mode, host)) @@ -44,6 +45,7 @@ async def receive_responses(websocket, shared_state): shared_state["env"].set_state(pickle.loads(base64.b64decode(data["env"]))) shared_state["obs"] = pickle.loads(base64.b64decode(data["obs"])) + Player.players = pickle.loads(base64.b64decode(data["players"])) async with websockets.connect(uri) as websocket: env, _, renderer = create_robotouille_env(environment_name, movement_mode, seed, noisy_randomization) diff --git a/networking/server.py b/networking/server.py index a54ed73ab..dcd305f2c 100644 --- a/networking/server.py +++ b/networking/server.py @@ -16,7 +16,7 @@ SIMULATE_LATENCY = False SIMULATED_LATENCY_DURATION = 0.25 -UPDATE_INTERVAL = 1/60 +UPDATE_INTERVAL = 1/25 def run_server(environment_name: str, seed: int, noisy_randomization: bool, movement_mode: str, display_server: bool=False, event: asyncio.Event=None): asyncio.run(server_loop(environment_name, seed, noisy_randomization, movement_mode, display_server, event)) @@ -43,6 +43,7 @@ async def simulator(connections): obs, info = env.reset() if display_server: renderer.render(obs, mode='human') + renderer.render_fps = 0 done = False interactive = False # Adjust based on client commands later if needed @@ -115,7 +116,8 @@ async def simulator(connections): env_data = base64.b64encode(pickle.dumps(env.get_state())).decode('utf-8') obs_data = base64.b64encode(pickle.dumps(obs)).decode('utf-8') - reply = json.dumps({"env": env_data, "obs": obs_data, "done": done}) + player_data = base64.b64encode(pickle.dumps(Player.players)).decode('utf-8') + reply = json.dumps({"env": env_data, "obs": obs_data, "players": player_data, "done": done}) await asyncio.gather(*(websocket.send(reply) for websocket in connections.keys()))