From 4f3babb4072a3820e9019f9033d70372da4e3434 Mon Sep 17 00:00:00 2001 From: Michael Voitovich Date: Tue, 2 Jun 2026 01:40:33 +0200 Subject: [PATCH 1/6] feat: commit NPC puppet master slice Add the first NPC Puppet Master gameplay slice with scene log persistence, SAY speech handling, NPC runtime objectives, PM DSL execution, move scheduling, and debug peek support. Include the supporting content and fixtures for the test room, NPC text assets, system prompt, sprite assets, and targeted tests covering parser, scene log, and NPC planning behavior. --- NPCsys.md | 61 ++ public/assets/girl.png | Bin 0 -> 22910 bytes public/scenes/test_room.json | 222 +++++-- public/sprites/girl.json | 9 + public/text/objects/NPC.json | 9 + public/text/system/npc-pm-system.md | 31 + src/components/ConsoleOverlay.tsx | 9 +- src/components/UIOverlay.tsx | 11 +- .../editor/properties/SectionComponents.tsx | 84 ++- src/core/Console.ts | 13 +- src/core/Game.ts | 77 ++- src/core/TextAssetManager.ts | 11 + src/mechanics/ActorPlanExecutor.ts | 248 ++++++++ src/mechanics/NpcPuppetMaster.ts | 392 ++++++++++++ src/mechanics/NpcWorldModelBuilder.ts | 202 ++++++ src/mechanics/Parser.ts | 17 + src/mechanics/npcTypes.ts | 99 +++ src/scene/Scene.ts | 4 + src/scene/SceneLog.ts | 163 +++++ src/scene/SceneManager.ts | 4 + src/systems/ComponentSystem.ts | 30 + tests/fixtures/sceneFactory.ts | 2 + tests/fixtures/textAssetFactory.ts | 6 + tests/npc/puppet-master.test.ts | 590 ++++++++++++++++++ tests/parser/llm-parser.test.ts | 20 + tests/parser/say-command.test.ts | 34 + tests/scene/scene-log.test.ts | 69 ++ 27 files changed, 2357 insertions(+), 60 deletions(-) create mode 100644 NPCsys.md create mode 100644 public/assets/girl.png create mode 100644 public/sprites/girl.json create mode 100644 public/text/objects/NPC.json create mode 100644 public/text/system/npc-pm-system.md create mode 100644 src/mechanics/ActorPlanExecutor.ts create mode 100644 src/mechanics/NpcPuppetMaster.ts create mode 100644 src/mechanics/NpcWorldModelBuilder.ts create mode 100644 src/mechanics/npcTypes.ts create mode 100644 src/scene/SceneLog.ts create mode 100644 tests/npc/puppet-master.test.ts create mode 100644 tests/parser/say-command.test.ts create mode 100644 tests/scene/scene-log.test.ts diff --git a/NPCsys.md b/NPCsys.md new file mode 100644 index 00000000..883b4efb --- /dev/null +++ b/NPCsys.md @@ -0,0 +1,61 @@ +Одной из главных особенностей (USP) игры является система умных NPC, контролируемых ИИ (LLM), которые отыгрывают свои роли, и с которыми можно общаться естественным языком. ИИ управляет перемещениями Actor, и его действиями, поэтому теоретически NPC могут всё то же самое, что и персонаж игрока. +Чтобы у Actor появилась управляемая ИИ "личность", ему надо присвоить компонент "NPC". + +За управление NPC и диалоги с ними отвечает "кукловод" -- модуль Puppet Master (PM) на базе LLM. Он отыгрывает, "role-play" каждого NPC используя: + +* Свой промпт с инструкцией; +* Scene Context -- описание сцены от World Builder, сходное с тем, получает Parser но гораздо более компактное (мы используем кэш, если LLM провайдер поддердивает, так что части 1-2 для сцены в оптимальном варианте токенизируются один раз). +* Описание каждого NPC из их TA (поле "Lore") +* Список текущих целей каждого NPC из их TA (новое поле "Objectives") +* Память NPC о предыдущих событиях (см. ниже) + +Примечание: подаваемый на вход LLM промпт надо оптимизировать с учётом того, что повторяющиеся данные могут кэшироваться провайдером LLM. + +## Диалоговая модель + +Любой NPC в игре может произносить реплики, которые слышат все остальные NPC, находящиеся в текущей локации (включая персонажа игрока). Если игрок хочет что-то сказать, в самом начале своей команды он должен указать символ "-". Весь остаток строки будет считаться текстом реплики. Можно также использовать команду SAY. На самом деле "- Hello" это просто более удобная форма команды "SAY Hello". +Свои и чужие реплики игрок видит в консоли другим цветом (отличающимся от цвета команд и системных сообщений), в формате "Имя: Реплика\n". + +### Лог сцены, реплики, действия + +Есть временный текстовый *лог сцены*, куда попадают все *реплики* и *действия*, совершённые NPC и игроком в этой сцене, до того, как они будут обработаны ИИ. +В качестве "действий" выступают строки текста в квадратных скобках, типа "[вежливо улыбается]", или "[внимательно просматривает пропуск Майлза и наконец возвращает обратно]". Эти описания действий могут генерироваться: + + 1. LLM в ходе написания реплик для персонажей как часть этих реплик или "flawor text". + 2. движком для значимых действий. Это просто сокращённые и обезличенные аналоги некоторых сообщений, которые игрок получает от парсера в ответ на свои команды, вызывающие изменение мира или состояния. + +КОМАНДА | ЧТО ВИДИТ ИГРОК В КОНСОЛИ | ЧТО ПИШЕТСЯ В ЛОГ СЦЕНЫ +-------------------------------- | ------------------------------ | ----------------------- +GIVE ID TO SECURITY OFFICER |"Ты протянул пропуск охраннику" | "[Майлз передал пропуск охраннику]" | + +* Hello there! |You: Hello there! | Miles: Hello there! + +Все записиси в логе имеют метку времени (на основании текущего игрового времени). Они хранятся временно, в течении заданного интервала, например 10 минут, по истечении которого удаляются. Также в логе хранится контрольная отметка времени последней обработки лога со стороны +Лог сцен сохраняется вместе с игрой. + +В целом, лог сцены похож на облегчённую версию того, что игрок видит в своей консоли. Но, в отличии от консоли, в лог попадают исключительно значимые для мира и модели события, произошедшие конкретно в этой сцене. +В лог попадают не вообще все события, а лишь те, что известны хотя бы одному NPC, в том числе те, про которые игроку не известно. Записи лога, известные не всем, имеют спец. аттрибут со списком NPC которые знают про событие. + +При появлении новых реплик PM вызывается для обработки сразу всех присутствующих в сцене NPC одним вызовом. Он обрабатывает ислючительно те реплики, что в логе находятся ПОСЛЕ контрольной отметки времени (хотя видит весь лог сцены). После обработки контрольная отметка обновляется. +За одну сессию PM их NPC могут сказать только одну реплику. + +В ходе сессии обработки PM решает какие из NPC сцены должны что-то ответить, пишет их ответы (возможно вместе c flawor text) а также strucured Plan действий, котоыре те должны совершить. Если NPC имеет что сказать, как минимум plan будет включать команду SAY. + +Plan выполняется отдельно, и в целом это унифицировано для всех Actor, в том числе персонажа игрока. То есть, когда игрок что-то говорит, перемещается по клику мыши или поднимает предмет, его Actor самом деле отрабатывает точно такой же Plan, что и другие. C точки зрения модели мира все Actor равноправны, в том числе персонаж игрока. + +## NPC Memory + +Это персистентная область заметок NPC, куда PM может заносить самые значимые фразы, факты, сведения, которые NPC необходимо помнить. Эти заметки хранятся независимо от того, в какой сцене NPC находится. +Если бы этой области не было, NPC бы помнили лишь то, что есть в логе сцены, и даже это бы забывали перейдя в дпугую локацию. + NPC Memory сохранятся вместе с игрой. + +## Вызов PM + +PM вызывается: + + 1. Для пакетной обраюотки NPC когда в логе сцены имеются необработанные реплики/действия. При этом, он не вызывается когда у фразы/действия нет NPC-слушателей. Допустим, если в сцене есть игрок и NPC, и NPC произнёс реплику, эту реплика не считается основанием для вызова PM, поскоьку тот NPC, котоырй её произнёс, не является её слушателем и не должен на неё реагировать (это вызвало бы бесконечную петлю), а игрок это не NPC и PM ему не нужен. То же самое, если в сцене есть лишь один игрок, который произносит реплику, либо совершает действие, которое видеть/слышать некому. + + 2. Для индивидуальной обработки одного NPC по событию. Такими событиями могут быть: + *Конец отработки перемещения по MoveTo для NPC, задавнного PM. То есть, NPC либо дошёл до нужной точки, либо столкнулся с препятствием, и есть необходимость обработать ситуация и возможно дать ему другое задание. + * По таймеру, заданному PM для этого персонажа (WAIT) + * При приближении объектов с заданными тэгами на заданное расстояние (например, ИИ кошки активируется и будет реагировать, если к ней приблится любая собака). diff --git a/public/assets/girl.png b/public/assets/girl.png new file mode 100644 index 0000000000000000000000000000000000000000..955d0921b14358ed0a3c416fed8d7e13bde65205 GIT binary patch literal 22910 zcmaI7byOv>vo4CeyUWJiZR76l?rs}*cXxMpXMkaFcV}>S8(@GLoHxI7@45H=@y_eD zx|8&(@2g5uNxD+02qm*3s13Qq9uT!pCLOQUDALlG;{7$6ZHJ0ch^*$ZYx_8D?)s&_6U7 zn1HZ1$kg24(w)rA(%RNZ5ODjWA3$bnAqddsQe;&GiCfy(%J{lks{1NwnETqB^H~6d zg~$ZFf&T~`E!|DYyd52!+<@MKfdAqJ{>%U8HVc64zeL>a1p)udC>=!=GI3{DOEMm2 zb|!OHR#q||K4uF`OAb>W9##%UGImyWb{1A%7B*fcHf|snE0BYg?0+ACe+peKtbl3~ zQvX}szmg!p#@!tRWMT30@?!SlV0L!3W?|#w<6~iEXJKb&`X|BU=HujU>doZjM)BVa z5|(b}uC^d|TW2S-|1g@GIeWMZ0{$8MziQzK`aiTzZvVTb{%1yEE3$~w8Zn>v|W%1Q_V{!uX7+FAfv zdBnM-*reFFB-nV^{-vVa;-b8K66_ot-0b4wlDz+om2fuqaI|!C|8K0t|HZQZkJ$gX zgCpplW(iAITTe?1DOYDlvj3_x(Dr|{MN;a2g}Y& zlo3M~6@kJ+lNd29uf8e;G`?wTGu9pMeY0(saPM@$&m~fAT&H(#tg0 zIMwb;ov_9etox6hX3J@u=utB=I0)VXrRedaX5XjFZ?LE&23+jyOZZ4IESFbT@>FTE z3tY%D$Z@Gqj*gDZ{&QgCF7pK*KXv)LM z;RBNqakD=P3JL~yQw&W#&feZ#xH%L=ydW|i6-_ir^sYm<-!hMDMt%vOm0bpngqoUy zpqG=Zz3Bei@jdXIc0IIFA%B0Ipe7Rd8Y^lYjDv?4u!;kwu&}TqX)6aZ*$E5w_-B+X zOl%g|QnJuWd-eIVy9LaHduobh3yV43?*_jH?VEZ%E5er^4&zvMZ|=SOmY0{^q`C7N zNFp^Drb^Z2YpF0|1{M|;ga!Ysn;VzT(@(Rf;k?=kB?~^UpB*K@p^g^G__eAWs#x_-abK|Z+Yf;iYW8_JWl1S#&2Wdf zq(XxOtL@gmyu5Tc!o*BeR~F=wYZ`l44Rai^TPpe~89$JQwAJDG?v zfcQmeMq6s;v@Tu;{iw@V_R%x7Cnm9vrnQsFm{?R>_tVCozol&kN=iyhbMXbdwwrj; z7Rvve-_8qvy?b3{r+zNR(i<`O*b+y;7`{khrl)7RI>Wx|(m zH(U&2|AG+!>x*OQd!Cw{oUFZ*mH-m30*K3NlhJQCyIhVEzJ2>f z^OW!C1zJnyijNm67?Gq*8nt_1-Gb~PabYndjKd)82k1)2uu3TjUKOp{Xq%e zq14y{Rw;(gr8`rs|2<8)NwFg)uv8+jrKQEiUR{0p7Bfad@)ZSL~>`)le-Q9|guffKSEgLLhb2@+eS;V&NnU-tJ%C)P0pTI?v8 z@q;2ES4@F6N1pAEh2;gcBWsos>WGm|Re3?)(VUff-mBTH721X0Gq$d2xXp61Mz z-0>q(^`!}v3AOOhb3t~W3}U#dnJ{sTq(un8!MmLCpb1)JRMc8pEm`Dw+d48psK82c z6;C{03AU%(UU=JocxzyQYN zGMz^3KxtLp>eIaCuwZls&3{NMDd{nJPbPn;S|l#9qw5l>5=cRWlE+2`{7`5hB4mVm zhoE;mL~15?*xBNNJ1f{lEc#keyeri2N9mplv8ir8XkP|jG6!u=d7j(ZikKY_IA)!u zqjYXbDr^V{utR)L8$mOZpl%YOt`likLuvS?*xb4Z+hY_Qc7)%f%#R?+9+^DX2$Lbn zMrFl6B?G(s@oY_1B$c12RImjJX>JMv+~2;Pc$r#w!xj4dv7Df%_kSrP4Oiy}?%yR6 zFKjF#>=ZEFQSP4E)pMy5wh?cuB55NacyAklz6)+~DO$%1vq^zrsX%Bz;m>{pw`qD3 z*B2+Yi6AQNN9v*^;d~c6Dc7oRYHH#Ye-9LnkdaDyRg+4=j?U$@SOR%t{B-XWLy6(x z<%E)BM@bTPCB;~T&)T~~ml>np|Kl8p;`BK&4*r{>I&Aa);@ke;b#eh#IK>1HvQpm( z%hqHC>*7A{5fL!i?Lx=^&;eDJ6DpU-HDwa9#TL9QbdAPfWMX6e@M|IPZW2P`zPVZs z92JX)Mx`cpA8_F)fHw~?7mBfo!`GZPU0nc+PfQx_6!PGBL{1#|`q&H-llJ3yf6Wq( z{AQgG-(omFxSHh@+~0lugeGCz|GDWU_N4=L{an%i=R$uVuJ$r4=#4vDf&Omg=Z=Dh zhiCi1!~`#Z9bM_dCbZ5th(e}B>!iLB7#-n2^Jtiz?grt$Cc{b;6Eb@dsQ?5AI)_C( z=V^wtp4&M(1~gYyoh|&E!PX>AF{oKN{S9=oK;uXvLUVSQG71 z&je*-SqnCV5{c(u>wz0kh)K;Pw1Gd{tnMNqIw^72&_z|zr=Zyo-&uYeCbm&A?uoiS;Kn)5_wY7*+Od=2QzqF!u6&tP3%K}vICPh~Op8Y8i9`Qc=WcCuR@>%< z-mWGk4omk0p9cunJi`Z~Qd>WJ5Q`fdQ5G6tiD$|&rVF+#RcMC5I(Yproms+yy3B}q z?Q_8a@LJDgF#?=CM-Ta)3up)DDqwvT$U;$etT|^W+wJW8VI{##FQ(Of1iwv}}|e#f4`2Yuny8}{S$z2%_(3Z!!A(VMU>a*KVR4@NA zCFh1x5-1v4p46B`XLtq6=vj=Bt~_>q@fQ;j^R*J8F}+8f+Y%Mk>TcFiYjg! z5D1#Z4fP6q*20Uozl+-w0;foeSu7~4>D@=W)P8%sVz*TiABMWpUg^CU;$rF_ZEGgv zYe~>jbcu_G=i0KRGb>40L&IBL;z&Jybe7|5tdZiJohVOX) z^>@F)KKNUmFPXRLgK+njb|uHbq~DLQRKZkfi*)rtoc%?Xku|!zUT8%TY4#5suA&V? zp?#Ss=_*E2Z`B!EN{D=SGYmD6^uqxbty^|tfM!#DIhr`-d#HQaJbCHVg2%_|;^cAX z-AJh(gjT0KUy{osPxV9sn>3FmZXGF2)lx0?-FI?QlBglKy#0Da?tw5=K_t08&>cnR z?rU7b2gP4vqBsXw&fQP2LH$H@JQawV%4O!V1sOH6`a3jr>s{XHYKUWKLVz>n0) zNOok#JD=i+@UzP3XlnA_JAJv)1_auph!l_6nxWqfA&0Jn-3t;TIB_xAfYPo`PF1WNCCtRLT>D0wqzpCyQW?RHGW>2YM#l%<>|UMq^x)xF2k zRn?4T;6nYRpdH{M6umVnh}1HzMUAGQE?IeV{QK$G2?^g1u}K#Htq26h;;U65D54r! zGuQTi2FPf){)VB65Am!G9H)fcoyM{B%r1<6%1|O*b#L7Xx+>B;x5e2!YCdXb!=_HR zhG)%e6|0_VQ`;LdENa~xRQ-N<_L0fBY{h>Ph*?uLRg9xcn7{(MW2+j&jQ&wkoID?m zqc3#FcCP`$lzBeTK{49od0eG1|K9#Gxj4MM*!2LXtU5G!1~ohyLJ%YJC!!!Zzy&s% zK#24M%SDraJ=Ext{3&esNPIyLH@K0@I;84shQqrSb8IcGhk2reVoEhHbQ&HQh~iuZ zixhP>MDcHmsRogZ%DR?V8%K3O=6I!29^t{$S;iUy^4J~EBTnjvSZBT#gDOIlf!SF4 zYRK#ECV;Cs>xaMX&^NcuY+MXt;L09>AguTlIGbTrcM@y~Ms^#Y$9zJu{d1cr8_KY1 z+N|oolVnHQa?<7lbr+(;O5}KnY!c$J)z1Mpp0Kq{7QD3*{px*u%r(N)w2WIHCT?B# zC)gZ6M+>&QJ6gZX$*V=v9VqNR_GiY>q&KT-(krPfs%&OolHtuL0SFx7;rHESK$HSL zI|r)20;=4fcX?Ai4KU(Rx#|h|;0oiBdJH9GTUxR+#7=~I4bw58xUj?Afv6ble(w8V za1;T_vvgbSjbjGdehtLzd=}wxPnjw%;nDFh??g;og^dSy7C%xI9{qYe&R;OojdJY@ zbT{f!%gJ6_OLQ)$H5R4A|N8E*5+n{3arYwedlH92N{^96bQe%ntC&r!BRqe<=0?}7 z^&LMLy#C=CUro4diz1MVyqXaID|}!dB$NhCC}yB<$h05F3Y9UB9X$M(juuFX1(_f~ zy1UEs^1)jxo(l6@l5#OIK5JGyTFopgd?7Zo`%J*;udrpZD1z@@<@6*IsW@IpJ<=|_ z9b@630i;?D5)w+n;Jyx2B+b#j7Z~UBj<+zuvIPkOblBidJ)l@P`|AhU{NG9UKk#7t z535*gWrW*R4OW+kNrF)Mfc91IfrLMUkr)(Y8lDuG$pc0HfqUm!h_w^hLx=#1F8C^R zgJLVYT2#)0{>C$T2Eu?JDb&uBSe#?oX^Mrl_OIMl^pmkLYi%OOqZWMwKp=y^G zQOV(4g#)_zw6(%s)@m9SC&z~*Z2#)RrD`V(x@7H+u(SGa@#rw4WT1e_>G+ zkICxP%JCdAfMMq$A`a}Y5sr|Pe`~v~gB>tH>p?_8l>kuXolTnn*Aew@0&i!$%FNeTV+ZFPU@ zExe)92jO^sJ_Wluq9SrGC1A#~Q46eAwTJKG%GbS5SRC$2Cfb``>hP5Rc|DyW{d#_$ z|E1X~?04qj{G(^|?7gViu-00M`jxSAYC&;K&YM@Pahc#_ZW-fZn~4J6nZ4?`KRv0E zJ1$~K$wJArMe6T-D7kcvYQZ2Ah~KgJ0nYknNH;L*-i?#B^hXu(%1`QKv?T~x=n~G~ z>v1VqoWOI1*t>3ZJ?C7RV<&d^DL4Ct-CZd2mUx#h>n~OyyFV|75PIL+W~Y&hjiR+% zFDljYl?Qt;*v#Gm-X@Hq!Y=NYH4xG6{Tvl-{%ChgiS(yPlunz6bj&IS}ygy@MNFMT$*c>N~zJ{3|Sho9;ph zU3e|p2ZD`MSkl6?dC-?nV-Vx^I!0}XG-=>@$BrNBK;GV+Z`b>wTS;9Dju^8$J~}H{ zMvhX%2QsE3Ho^>7HS$KqX7Q0;S+QZ>^u?ae>LNX#GxW6?f z858Qa=6t${N1jJT+SYv#Fi+{pXHdm;Pu7pfBGeN_I-X{s^TmbQ*xRuqTU(QT#ICl) zEb23aqG-G8KB?$q>`aJrAVAp~avm2e?C%z>`_mL^_!G9(dyGU$l{%Qb|2PC>)9=7_iLdZ zHs+HjF;N4hf5g@MD!efoKiwELYIqhFmAKn$`X>|NpX&{9(*76U=Tp>vSTsUdiWHav zjykcf!-qswNOjOkc3;UyaHLhGRp8e$|3PNcJKmBHvG3NEC@i*GW%t67MOdEdkvfT)im**HKW;2(zTP>zu7D#!J0}GsTNWb`>)c9-z=f-G?NjHsfnkIJC8LyK?6wgo%R zd8JYmYuF*`rjE(5%dl@A+R>^H8o?)~?#$e-2Vl=3lDRt=TUnU>kT>crdy$M<`2AG1>((nt9Fag9{Oi>H`yJ?EbbgxSmM= zw{xkn9SH-gl-b%$;O|?qNT`AS(85?;^=_^62=jOVwuC8O5)0HIS!!^G_Ywz^6hk8v z24P!GHvUNQ8u5A%-tOOD{h`=r`G~B|u*PZ9ns!lQMyBW{ztgT!e%M|=@r6=6`rP?} zTEQ9g?VX5IL%PHJ=;vTR(cOyAt2Uwzlv*{=2G6Bz?3ODPh5cVIs^%+<(Amp1FeEU< zH1C%MLy4}Qif=Dnl$1TQn`D=1e=4wrO%uoUvn&O-Gvcby2X9d5|viVbX|KRPCk^FFf?V*qBYciN|W;u={WhpK7 zhuugBc>A;SJ^+dOPK+Gg5;>JFH*V0>;vlMAE^53N_`L7e7`FR#r3&;^6y2>Mks_do zsMQvKHlU@Q`B4P{hY+1xl+lNnVSudz3|?An(IJVH9^1uC8Vg+HCKX4Z#}?Q!(R9A$ zj8ZDiEzLF3P_6sb`*+tF1JVWwQx^KeaOjN=P^UQ=4m~v(^)BT7g}g>qQf9RSKAv&9 zvi>E*g;2ky%4wfPcRulsp0*D0O<5LY!UADoPx$rfgQ8`ABYrz1xCd=~fprz9CgI3Y zwNN=iMHy}tn}Q>djh+3l;wR7ys)oS~g)l@_GR#FPN_ro&=oKs9b@vLK+AB;()8k)k zhU?@KOA}CsdnD4%uTzn#>roO~ikCcPGYJy-y;T6YxP?sBHW^YoM?Fa^i72WIMYC9p zs`ibHt0bQiVsWyRyF;49=Q!kMtFwuQ(5%8fhED3fcSEEV;cmo^qb+i2p(|z|WCBEG7*fl1dx#~ATk*0f=B1TXN!-bz0 zGSw2m*gG^E)0Ho_K;v~tj0OspP9{r)u}Cs9n#PnFp@vdS_1|v6^=*jhnlYz#(@I|F})YghIyn^eyJ028aEeQme-Q zHJoaM;2S3!o6DcFB%xRFy3XWP5$s{=@Tls3Gd@@XosM7>7+Gdn21V~;HbRX@#h`!N z>4@C?JHHUy9VEVyuX6rK$5I7eV^}i!oMMh~=&g_1?^V9W24F(#Xfjk8dcqqZjJwiW z@udyS43vh&rMHyP4-N-N51e@xF4R!{U=TJ_HQs_VYIKnC39S zt6v({Y1+KgBaH0Yl*7G(9-UG9{uTs{_C7T^2MfZ!nMz6{x=A4G1X^eUwd2Y|koef( za)#fj>Nsoc@XXjEgfPD;!R2?`R29TmEuR?=TT>V5^>&19xql_?=l=DF=(rJtve)*^ z#lD?3K(u-x8i68MEpKL54a4`j9OOIzp}X&w9Psu1MruJ~DRl$AKn@(%jZb3#Y^|)` zcZ=5MzbQpT`4u*{N__YHAsl?Q7esjdZ`U}1E8`-svzTXeIr{FWj_#CE>X96MePbiZ zb_Y-e@r8<^L6A9eK_}WkG|GbVTQ#J@Zl7TXygkrf2n=G#5dCTI0Q}DV?~t|d+uu8$ z+Yc;k_Ut2?%$L^Ed5Pz;F$A0~3Yk!?27|$&n*a*cbDO(1U1}*R{GnHX3O5`Kj{7g~ zs*YB4$tn+E|1URq;$(GQ(clo60`5a)nweXdUMETQb4-l^E4+NZn2;Kw#FNokzPJoT zTD?@ZEe*iwyDBOCr}LLddc;rd_BHad_%s}N+{L`|7lj;X%uKu?Ta&Q&P-S*O`b=#T zOX+E@Kpr{p3zM<(8`ERNNZsD{gIxp~#6ygx0^vlDcMgl5TJaoOHh^?7(;_=`^7(Gr34YmR#)6j34dNVUBk}i)`=rv@r3zBb zv5fU)OqM6D%q~D6N^ow~&_-WW5WZW^OpHHdOU>A{T|UllfELB)xeY{Wx{)E;`w>~n z^w4YL0xl$Zl`b2vqpU75*0?O1tW}#g=S{s_1BB!YV{+`D_lKB27kK+Uu_J=*LD_|Xc| zN=4Uo(Kv=To*SZfE*81Yd#y>)92QHEJOjB!b;HI3B66u3b>@^ZtQo?}8&oBi&DdHg zJ|NiF(#+wRzuORrWdtW%SD-})k?ZRSp1Nq0;I)Lt2A52ecj^}i${8BI;~M`}%A37! z)HKbR1LrrsTqwR4c+v*`Y|CODkYmb{vGpWJ9j}Kr0+_9sH%b(^d-hP%hX3^a-*Cx9 zIkH!**pmZ(a5H+3oMCo1I0zvMVJR)nZY20)_)17L9t$Se^aVhSy5Z)KkI| z&n^s12{#Nd?|jI2LBaPdi|0(npa5J(>Uxyg(I2M^5U909sVr7i-K{4NddgaiI!idv zvd13HE~%$NsmVV2M=^@|IU=vRgzcS$QAocQz29y*Cox%V@~nnUKKriq-pIkdh9yrk zIVD$~yKO*;xAK*~K-fPNTumMPmA9ASR0@sAZmcgIydW;T>LJPb2A6%XblAh40*Re6 z8Itl$Tzrb8s412%PL}VCTua$1kwHZHpu6IUu*ry$&&~h5;B(aoFABCQ-!Kj>T170- z8vf-OO!##wDut;obE|8WSO(!}0cS~R`^yaF`x>tNkl}m}l8=9KBhDRhm@jjdRZIQo zda({8?3U-R$hbLS=S5ot_t=DP8MzlJfr8!{YWRmcv|Q z?j>tlipr71miVu*>Vy<2&u=utW$E%+x~nig)79Cqhwu}}?9H{*Uio*fKuQ0)N-J-lEt0OA@&1ASlC-te z!k{btz1KE%CBBG;bNy>}i1`umh6KvN*7hX%>#nU*Wd7J}830*A zi$1@t^prw~1%pXn16)e=hyuASx*%%I%=oZf00NrCe-{kXPqkHa?~c{LYt9z5%x5je z&pvR4nv-$0GAi-nq`^b~Stzwy(~>2b5x#&~zoCP&k7GPe*Wy^UxW1i z+9HkK-@Whb-1TYgnfIP#vohYm`_qgiYk81*Nf(neaZi05^J0jF;Q=P@c+RqG!Wd2E z3uxMy;zeZf0Iy?+`@XQr{iS)`j#D5~IeW1p=eh1>bDa`nR0ZiJcauZqJvkzQQNw$6v8PS1earu5Javb8a+y|A7*B(6ckTFh9%hXLT5qiU@>F znONjL0}@C!BXD83jw}EJ~3Bqj);BMhomJFXhNLI z=Ncqn6j*@6I3>FH<>DhRxYoD`5`WBG0&(-VA{3ODWJK_|N%-X@x^?+t^0&1D7~;6`adLG72}u8%^A zIZ@ts)MhF9De{su#3bj+watG@5%1p)21OztTJ0B+O2Se_E(q0qWXgHr7T-CQU|}`O z?zVgIRcj%b?^4TPB>!+KsT*NWi5S!0SFvrtTpHK)SXKs19%D6jmUyP^O%^H7m`Hk7 zpi*!FwoEJ-J2WS)e!1{laCAi@q44>HHvsCpt_^IQ;mArSaeU5~MTaCG~ng>fai=%yutqGWP zXFO5g00pmksW4hu&8Dml<4&K6oa|RSa#ak9NA3LK>1_ykhzV9FEW=|{3{x6L|E-Hm zJUAk}VHqfQlDvlwibeya3ZidthZrb9HqFeY$CUN6JMWI`o_rek%)jIH>Jc3sd#nqJ zHxUVWQvzS=esPZ{%={3VR9~C@BtW6Q>x=;*p_J0sjfP#IyOYg}ioIf0rlC=bBjjQc z5|O+I>nB`cIP;&zC4VSk^IBa~=CCl7@lj1T*dPb8iJ+K>(qZ_Gu}Sjs19LaBm8?n1 zU#_Gkua-x6k?CfJaKlp_FzhSxuBzFMj?!$sH}W0fwQPY;*?y|nR2+9xaX$`9h{KvK zX<<-Oew1uo^{kn;eMzY*eTI|j#~k5F%PQ|nf3ptJ+*g4XOj#j6SAXg~?0_u$0_H^R zZCD5&TN82EZyU)?=QONa_H~Wr+Le2`bHM<0Eo9c>?lSY#G-oDCDdUx;`T?2chq6zT z7{FW z>UH@p_B|lz7|v}!>$@)TgM(2-_iJt^bdM4N3b!O~n@j&W)3SeE$;6m9;13jEX>JLQ zeB9l45|)*tNlSgVfn592uqnhg2}p%?vEdQ+7OZvgki)lh87jUMxRVR{G3nB~k;0w3 zk-0!#&KwDE$Ee(H0<`n(gEu`0-{=5e$V+y21W>#Qf}>p2P`$sa>T- zU{;W7M5`;{l#F>W2%|X{6Umwl31EQ&>NVva<&~OvLQ!ME4_tw6TFh6XS?jzyOIZ;T zSVmji2=g}t5sn{6tT}VdlVT0e-ie)R?E?aK{DI>(0`LLiM_=gmH6Ki&mw0?h(*=FU zl?gx|`9Kk9Q58|9yS7M-R@%cSWX0QtCm@lEh=t_>++-9OVKTnH#b?rK!uYzkTEZJ6nk;)EJp$NOQ zlMZFiLi0A;FdIs`07_$gXKnQ>UO(hbN*LpJ9y5;9dlPGC{F4ma>1qA=>)jTY+EGBEnb^)Q_9$b2{|gpQDOu8%T1Brqrb+fr61ENHS-1 zb=C}H>u_@RjJ4AoGVz66&KRk>=!@Z#>8PEh9X~b5?dXaL4OeI-c_n!`2d)>FBaQMPhYM)i5RJ$iLnEE|-$ zRQBCFsw1XS5uUR(RH<^>M!V>!bs|>M&x+T`G$qHjYo!<)0(m&%o?lUDhzJmiVmpp+ zA-PJNt#DIB1;`^+>Ov4#6{^Rqt(dL&(24YiMX{rYG#vxAVQEhVXr)|#*Gs~8+yGmh z87l%uj~l&zF>sB>=VT76i*)pc;gQ8~o!Atu6q*~C{m8|E$yQWOj! znsi|ce;PbEL-wiWNLQ-hwr?N^08arXTwd-<(~C`CDKv3(lDd4RgiLgVL6+hA5Y2h> znPJi+R3SvyW8suD{^ho%(kE52ZcL|wWzeUpRseAk#{{E#2y2#OnqtC-$wCzPnuS(` zGHP;VW#;^-4L2W4x6( z%x$X)qG3RQzk?MK*?}^Z-~W7KBF_PU8)YVaqv|YC)(g*dFzHoh$DsWvQJd$@9>j8RGs9itKo+ZXeHBLGzut4@a2q{~3f^S}8ku zK-WK>*`21c+SLnyQxT#(^jx?5gM<Vk9F;@&^%F3&!pgf%XZ zG0RV+e9KR$7?I4EvAi$xm6G&Ilvsq9+Y*P&p(7U=Zzq5xIooX3o7Wm?^@hXYJ4&o< zW1&l2w{_56xyxWU3+Q(5H#ECg%QEKo_v3prx z5oq?MBTX)b3s4GLGPUJguOLYS{aTq>?>N&HN=!BNS%AG)(xH%(d7>j`N4no8;`}1} z^Cqo?rFL63vI028X+1L}RY|qHx#z=J+*~(mP7M9MUElX?bIOPXQD6BCiBvgfGQnAJ z=2`}ssp6X{gFl>zZn7v-;c-+u92Qh>jhzTcu}^gSDP-1#x&LSnZVp`e=&rY?!xVBc zD9%1Ic4JD#G@?%ql$>{XaekSj8WPNfw5i26iSej2Y%>Sg6}Uf{iqPEnel z^jvs=uB@G+L&`10RHnT8H$4!0<}D))&$KkI(AK0D@o`N59;*s1dj6ynP|-N)TIXt` z>`lwzBqAXB^wxHJpr*<)Zlw=!X*{QxrC50&@bdMWwbt@n+fz`2jllQDI_evooc;H) zH>s;?MQjP{)7Y#@bo>m_JXkmvp}wx;`AMFMjj=3q+MB>8Rg{I7f@Ij4m@MNd^m2;1 zJWx)5k?~d_E-E@(6vR6ndmVA4VW@%6GJI}vom(k#bHz8eTw?D@1)RTh8C`nnGTrX7V;zvZk>qGlqyIMVGfzrsAblX34-eLEA>CPO!L1ic;hV zVplI9&_|k?$A{>A-N@-+P`ak$ddx(iXe!7TMF6Nt0!r8jkU;*UAb~g%%9NI2bBbFB zxM!Zl#1uzbm$oBkBOV789lYBqsiGa z^eW3o@<31PG`qnu^F@8L8u2UJ-?^qB6?S(orARzUY^vx(_vbzvaTx@gd-(W zwlGRdwu~5yG`L4AtP>{iPWg!%Pe-~+>=+J2UO3f?*6!`a{ebs;^~?G4Zi6ZPJl`D$ zghg*3%Kh{X-s6oYfgQ;TR?JugZlyfI9j4gNgS%&*C0a?6&ZN=i0lV*o_VqF?J~BLV zuqMCNYh$C8S+k`?E0%5l{IC>H%u3d(j1=%Lz|^Bkx+RjxnP(XtDW!`%za|wpzHjs7 z^d&LquRT>Tk+ijfm>|NOZVOOuUt-SEo&r?TnJvF=xZ|irZzBv{OK7s|{+)R4N=UF^ zpV=?u(}>c?tB^zTw;gAE%1Tof`>LG$_q^A|j#B6GdKN%fie4)DySxT1?3A8avM5Ui zZ!a_e_=?3e`oRmG03fK4kQQq^9Ll5SlvNVd_rtdd8}KADX!nG-U(fiG;*r zeR|0t1Sbt*EP7bl9GVilvQ9^`uw?qSuP22B{~i4I4EtD>fiNDUR_bbe;jyU6KNIdDQqLQk=5o@`PG&fYgYNA|CCpP8W~UV zZS@>YLnfyt=8)<+r`4OMkdR7h^F_9;hhYmI;utYlG=(JvYSC$gY+^_rkM5oV@hE7s zTXs4ECC|S57r;$DqghBDf`wrg9|g?yAg_pUoS$`;irf4YQ$!ACv<9gH*a;#A46)o8 z?Gdu88tkpDA2zXj$dqrJ3hwb*q00lKWwdlg0KjjQY0I*Tya3I5g}-EgcApyn%0g zI^WY#GF}wUg0kRhH;DB+MZ4Qq&5k);;Mvr&i3czQDx-MbF!-kictYD*py*k= zg<1eI`-}XK%O2qeeqzh0k$?aF9Fhw4VWT8U>fR4BRxTSa#OqZ#SVv&HS<}AGIFJ+0s%@++Hn+|Gl;8N6t)+ zX-09UW*Z-_<5cOnVV@E2xmUA#cC46L7~Y5r zGT{P`5*pT_#Y zm18aOGA(Yb+X|PjFb;JIyx4gg9FX@01$5w+MvI=VokSjH*|D`f2oeA2Tr`htxKmfs zfD8^_Dmj&;E|4hhS6nKj2=9rOoFl3cRV77TOotg(oC*d2IJo7 zqxUH7U*|-%L0R?i2f3U>Kl2|Fd&(aPAqusBWjmsd%fNm^i&rWqORx*o$N*MZs4E$m zP)Rtrp?}io6wKsn@&UqjY^95al<^6*Dka1rmu%f=uw+F9NX$nnZ&l+wO&uKK@lTKm z=R!!Oc#)+29md2tYd#G(tOe=S&NAP}63{OzftKM8* zMpmBsMi(QPaS+{qlQ#68Qhmx76oRW7Lk+1)=A??)l@j%ik3Z`cw|TN3M6U&pd6hkv zAb*d*3MA1eF%%-FcLg$9Qg!g&@TJ4I5;X7Am1t08$egL{m8MZK*)VU?F8x)KcnQjr zo)n{#c$H^-Z&c|)N}>8tno`)GLFQ|(l3ltlU*?1v%~w>u;v~?|E&1L+3}m;pA;uS- z-$q>YPT$8DS3n5NPh8<;H^(D*5VskiioW#Rx9w+oB~z2fgj1wN7b?o>kP2&};TT2B z*_fIoQk%=~>P^2vpm9thzd|eJk|63Fh(%2VPH^AneikMwP_EW0c)JUH_>jazV@NIO z?NiB6bju<#<4m!TDJX72xq%C=rjq+EEub88K?_1&cBd9s*+U=y#-Avuotk9!CxXR| zzmDRH>74;nsx?w^!;lMsbRhO1>)?cl`V&PYDwM(DWpu9vTBh}g(UBBL;H~$KR3ViW zD6qbi!@wnh9Bn&ego_AzpiPnYH50$`h>@l~Cu4~GUW#H&&k|j{e}x&Q23K*^N{G=4 z3E;*hgBpEHY3a#XXf1hdL07$>w9SLvRKo-f+6X_psmc_mWr6d^c!=J)uLL)I$AQWt zc9$EcW@mJ*b8C|fn$?OFAP6LhzqE#XL8N!vt*#C+2H`RgJZ`yhYbLHIaLICZ^M$s< z5U4~zMuZl&6DNiS!0_1+baA0{(88yC!3aeZ~ACR+n1W( zy$%Lce~qR1kn9uc4=n$Lo3nbHK?mWjh!@Ey&knPl*1(r4xNzrvJ($1+^l%caGd}f@ z?o}@tl$acAZk?+ z5&C|04voFjj${!#3!0yXn3O|Rkk9b2+I$xHuw4D>E>+#mk;`z2v4d2WjrbG2tR&=Y zS+`gY&6yJ3a}N)am|%?`_-wUsNJxX0^6-FM&pz z6_%i0oxII!Uz3jZxuop}adjnQgMzj*pHB9WrQZAya3`fXKvU9SXtzwH%m%M&iG$2BF=J=_OtjL~56XqS> z7v+3_hxf%$u|P@Z@+urptUGhXg|;?)<4!-OH1^M!1qzAzySnd|OsXraOM_DBi#t*@>hMNx{jvew< zKW)fqfqutn>pQSkWlFA{1tNI1#-`P_k!z3zzw+Ubgb`h8eL~^-w0LZtpL4mr$21g{ zF4wDuM6jYIT3su01vaM`@~1$-h$p-hSbG6sB?i?C{zddfM&d(S@Z1i0S9wJv$(WY0 zvX@MOkuB$IL`dE{ewZ~e%jk^331c_aV}}X+`cGM5gDEZqqTkAAsttQ-Bk>p&#B9nM zTNWLpg$CsUNkKFlK&^dwHZ~l` zmNt$xUK-g14D}5n8p`2eK6!{ElT$!;dBGcW!92O&xxxkzLueX_M&W{58>$%h5rgWJ zDGa@+wVUV=rc~H^IAX{GEcqO*i`Es}DodbFgnH!P=zxB{(cJbJ1y&ZUr3{%^*Y&5*$B<%&5=Aq- z9w+!iPFS)v=QQa-{?lPP;M(pzP?RcM)A=FOkM9~e{3NSuC&h-3J zUR1~H_WHr;aJ96J5}hkeDBG+7O;2+EP?fwoZ_3i}7hV*dNX&4i6{TT;b)o zR?o)}R&rn{5RPVGP+R&PoYY8KQ?Nlx69?vukK-XC4_OqEk$4_$k&aGlnr#kkZnS}i zQoSXEYK~e=HiaVrj!Nd^;b;%oK40r-n72lG8I8IQy4?wcal_hjM)K1VGP3BO}j9zP`Yo)gl@q`_{N?&#@gjnj&Y$%zT{{tM8U)1qYX(~%@35*hTPY8cU{ zVXVP4qaxekPrrW>&OfaO+$&c>Hk^cAyT?%73ClL=`uuVXG2?nXcsx4ToC-|D@}Mu{ zOkt`G=EV>=ToO|=1rgPz4N(rBY~V{iE-@kGg~8Er;InCH>TZW_xBb;_Jm|5Q0(-nQ zjKRKI*JcQ{pZm(T-@}JK^ksPBq1&Ohy9GVRh`CUV47Rfcp84H!!*dVc4ZX;r zyC2y2A1TA$XA_rCU7i1?i)S*#?Dw-f+^C{*kXC76CAX~WX){?g!$E#eQ$t%TotIC4 z_>*w)#pl9X|N2sZzQH0>thEyXc<*%|hx0b9g)e>i6Y$iX_kzPAL*5Wz(}5^t(57hV z*v7R@?teINWDIuhKM0@s;`iYv-~KdPE*1~tEs=s)W*27IyNV;DFx4Lei7oMqaaUpyDs0wW_3NMeXahF;-zbQyW!4;tqJ-)qf8TY9d8vG>VTI9}k1y?}g5lr@+C1Q3%!4!!_5xdov}ZsBO9; z3^CWLqFPR;*j#pBFfi**s-?UrZ=FeJGT;8;e?vN(ffe0dG-(bld-tbc!@AWV^PE|7 zik!Rui5H=JRVVDilTSmLzx;*&TcFt{)C@`^W~tGWN|Uy2+Xf1%LYhDk@Ht^LmO%3p zW!^WvUvqmWONz0H+4%UL*3PZGYHS%D99j1L?xJ}r%}NY!um%E>9aeXBu=R6>VJtgC z=nyy@j`-d^d*IpUo@WiAEe$oWd*14`}ghLxM$BU za5-Gi)zvfABE4hBYiN!XR`N>fH|TNBKxmqo>!F!0_|K-s#;uYdfy7J8|NUj!3&@Ee zrgb=YsE;WGo~zudc|tUSKw!7a<@y6M<0^A8Ib~wO@64Usd+KKjC(|mV#wgWgG{W| zOcn-*hFN>@a>Z?CyJ$9vEXz+jozAODy=P-eJj~rzY=#{TW=8mn&l&6N?2Of>YMErb z`sy~w=knlkyI$sbZZAG=-tY;{XR#yvt*@_#+S=Nw-u7i>$VlH2bLbZuMw;)zaR3$2 zR7X3AsM{MF8lES{P&chKN2Il6e8!fYJ9lcV!Bes^R7}scJM7fqF2xV5D0!%ciwI%2+n@3Ie1`@H2fHkW95wHuq*Z|?0#OU}`t|D}jEIS5Z@HZw z_Bv$b!{IP&du1#5{Bze;QCoKb4L>!I(j$fdMvFgZ>_w*v(WK&09qK@vz(DK6Sp>72vWewI9%R?|2)M$c(>BJi_G%$n&&0522va%%4TWHM{QQo0LhhTVk zm@#zO8RGT$tBM(<<7qC*lTSW<>+sMhG&MC%!*8yP6RB%J+euffSi$x7_OjV0jHymH z^Rh|O_8^4bvSka~wlil$pCzTTG?y#5a^*@UZOhJ(v9WMfF@)yhMk0}Owr<&S8L6AO zd?At$8bH(8*`=Tq+;{NcL1qJJ!NCKE z7y=`|1R1<*{-}e=C`p!}zrPQjd+xcZ5rNBf{mr|sqUD-K)i8woA3X7%ef#&{K%*^K zd(AwfV##i}T<%}J@WKlUF@*j-g3R3V@>Z~R`y%7^5_l=7`FRfnJ&qW$fB$|4o0nZe z2u)PWMWb9%-@kWY;7EvOAI^7J2nCUiShsHNc0yX#Xr}S-1at0aMx!;$H;$4*9=|wG zOBzWPdFkZYYs<7%=O~6$!f?%XI`L#)P3XuokNu#FK6_VJ_nx!QUjHbmUgoiT+!UOI zOe%w~;W1a^q1xNCd$-xC1;U&Yjo2*V;&PaZ#PXJVE!##2JRKw=T4!hH1B8Z1TBavko7>pf&Mahz6`v!B%z-pdJWFG$uBN7D*>fR`A&cFq zak4M6Wd{x%=%OLT6n~meI>_*%uP}y$f6_ujtc=LzhT59?Dg93jA!blqY} z|3cMC{!c~x5IPCfaFKED@9#gI?U8cL-=I(Gq@$zbMg08A^q^!j3T&yblHlVoGKH2A z&KoBphESw;&6+hZIXSuf3?XN#8ZJ^hLqkI>z{kzg)J6<&v+=O52Pn)z&LLfPyA8(1 z#$nga-BXaas18RFksf+3c! zpYI|flWs>pr+Gj^_#r1owt*y+D zS?-DxMu%$g)Q{(L5aZCGKZYkhO3q>FVz9}{h&eXaIcA*IE* z!O~KqG0D=6DM_;~Vwx<;VaraGRfDu!mxWo3C-?2!H(IHXk_c6Fv6QSadkl$~K_?gy z)>C`;?wx|W7SWT*6l{9>8BlTxTf|Zd7!YMWi%#&Rb2)`n2vJiL$`J^t6#5kP<*cNh z$G4a=&a=dwNJTX^Hs(;h{gI^OsLxJY;l{=w7LBuNL|xHKoJegd_BFc?VlW9o&wsnM zBB>$_sYJ|wHb10LvS@4O{4%yAGL?qL`i7k~q0k69l1Hf}^gn24VpCJ&lwFyY9veiX z5f}@Pfh3n#3M82hv|%=z?ZC*$2uplbgdx=gd*@y}53Clqxw-jX+ASi_7zstEn@%;Y z`X&h}y~w6yvza+py3%2A+MN*a2llv}u8Pz(v+q$U(JV)h**=T4Le+(;_w2_DOzT17QU&WS+vDg-0RwY`80MXU{ zWIADPR;ZMOR3pkN2X`r?QCnO402%*s`kjbQLQ`&MAw8aQ?n1G}V&?tUQc83n_fg2C zl83aaa_STov5FFAhD(rGIV9*qeSLk$lm=0ZN|x=+## ztr)^$3|a2lquDOf;pG@&8T2#HJacD5L&I>nq>+`jHE+kBZjumzO%t760udA#2rM>J z+E1q>AWPPOZ@I_4W0?ojqwpK}E{L^JSqeRYOXm1hY+ZEEe0P5RFY*ODikJ z5X#b3!)2)zB6ul7texnnvK}Uft=#EVsdq3yUbdh8eyn|w*iuT4D<8|E*9-E-vF zN~|>fTM>q=Teq$%7(&k5fddERNF*|~|CW{XAlz+hYa2od*>3H(ovE(qx#VjQ!OI0p z>2I?IQRzKMrQ!2;u+U2^R#}Ezdg-NAVbDEsdN2&Hi-E8&Dt^d&O9<%mVA_IS2~ zYH@Qe>2;|oR1m}$so|#*wOB_nWcBLR6=w*gSoiJQclzMqU~OsGgWxDJfle-k-OKqO zY}BG=6n7w&pBy^fFUXYz6M;YwrLbj;R9~fot)kW7Qb~11Qm|{+t}h|t8egGD)@%tVVg~i#mj)i`y-b9~ zrbZl~uG?y;;uNVuuTmF9aFG~wMEG*EMd3Ep_hpZ+`r{`{0=D(53mOSG!$uzUAj zRDb!hYK!%rpnb54WDm+AEK1Gp-o1MRI^u}L0wvXv94H8vYeTir z($suUbTY!c`Tz9{A=rsJJX@|vt~fV~I-1~#C!Q#)lE`kXTGh)ss%GAA6)&LlTy7X} zjJjf}TbDD2Ui?R;2Ox)!)Cv)^ z96>m9reYnQ^j;+1)|ocXbPPqt*qN~|*2;F|$W;WFXELO;AP*r^ zRP+N-VcMomo30>Vg93ar?Fcc6Yy(Qro6$~eF3nlcW9fb(b~*clK8`n$h`*UC7H2C6 zq`zCzQ56zWY46l5l1_<43{pqsh(ZM0zkk1th+pa)S{X%}t7VPQD3z?fzP{7L;c$@r zkJ&DI4l&${r&XLGbV4h6k)B_&p*9vkrjOn^F~?dO2rnIWKyDguXvVTlSV`o_XfFa-%+o z=;(_ypfua?B`+f5K+i3TzKJy zA45cR2|9o^B(x--R0~{sk)-aj*X4&BFU=? z38{v;y~Sd;k+;A7?Kk}V=Rbd(0*QpEEJBim5Ol@Auj}aO*gP}$J+rl&)F9fAt0=Ti zwq$0lu=SG|i3|zju={#>czK zBe#HvrE+Eh0b&BGv2L;r1RBZ-h{X`JUw=ZUGgpbpKV=zGEj@-rAhIX*_4T)3bkRk| z_U+qmC&CvEIy2JE&CSmdu}g~~=zcQjmJOksIcz3*$#j;HJLbIztr5d}dwai5>Z%&- z2V)3S(M3ch;-D1#H=gV#@C09jOlU$?HHZ>)Uu#?25hAd8>WyNPj>d>FSwe|PN4erTyblTA^I5zNBHCJs z>MM!hK<@7Dc>>kd&nq>0X+bKxT0hwT){FlO3I4*6zg&M|$X^)pmur#N{}*5YGRZt- Tr;*m700000NkvXXu0mjfA!}g= literal 0 HcmV?d00001 diff --git a/public/scenes/test_room.json b/public/scenes/test_room.json index 6762de85..f8b22485 100644 --- a/public/scenes/test_room.json +++ b/public/scenes/test_room.json @@ -59,6 +59,11 @@ "id": "test_1", "x": 555, "y": 214 + }, + { + "id": "tv_rc", + "x": 326, + "y": 247 } ] } @@ -1006,23 +1011,23 @@ "capacity": 9007199254740991, "groups": [], "protected": false, - "items": ["test", "tv_rc"], + "items": ["test"], "relation": "in" } ], "layer": 0, "visible": true, "hidden": false, - "parallax": 1.0409334104436379, - "x": 839.2252670877428, - "y": 248.0374520832024, - "width": 119.88, - "height": 289.34, - "baseWidth": 162, - "baseHeight": 391, + "parallax": 1.039692645910645, + "x": 743.936959170698, + "y": 246.12667470239316, + "width": 71.03999999999999, + "height": 290.08, + "baseWidth": 96, + "baseHeight": 392, "colliderWidth": 88, "colliderHeight": 4, - "spriteName": "miles_ds-idle-down.json", + "spriteName": "miles_ds-idle-right.json", "color": "#00ffff", "scale": 0.74, "refScale": 0.74, @@ -1034,7 +1039,7 @@ "blur": 0, "isPlayer": true, "speed": 0.24, - "direction": "down", + "direction": "left", "animSets": { "idle": { "id": "idle", @@ -1417,7 +1422,7 @@ "hidden": false, "parallax": 1.0791811402100033, "x": 223.0428633931454, - "y": 306.93895592340493, + "y": 306.938955923405, "width": 1008.8000000000001, "height": 90.39999999999999, "baseWidth": 1261, @@ -1574,29 +1579,29 @@ "visible": true, "hidden": false, "parallax": 1, - "x": 776.9860395378569, - "y": 228.97896068838534, + "x": 681.6156395767036, + "y": 227.06665183791887, "ignoreScaling": false, "vertices": [ { - "x": 776.9860395378569, - "y": 228.97896068838534, - "p": 1.0285224905588148 + "x": 681.6156395767036, + "y": 227.06665183791887, + "p": 1.0272476933667722 }, { - "x": 871.0127356977046, - "y": 227.92308025108423, - "p": 1.0278611077054283 + "x": 774.8363353771408, + "y": 226.0209967142044, + "p": 1.0265927557234915 }, { - "x": 920.0042056975744, - "y": 259.5373424015853, - "p": 1.0484008716893412 + "x": 848.8943259125697, + "y": 257.31881232273514, + "p": 1.046960267742036 }, { - "x": 866.4916523175558, - "y": 258.11861792043845, - "p": 1.0474796220262588 + "x": 794.2593990178761, + "y": 255.91434237135977, + "p": 1.0460482742671169 } ], "color": "#2b019d", @@ -1842,7 +1847,7 @@ "id": "power", "valueType": "string", "initialValue": "off", - "value": "on", + "value": "off", "parserNoteTextAssets": { "off": "power_off", "on": "power_on" @@ -2083,7 +2088,7 @@ "name": "tv_anim", "type": "Entity", "locked": false, - "disabled": false, + "disabled": true, "groupID": "#tv_glow", "customName": "", "textRedirects": {}, @@ -2120,7 +2125,7 @@ "name": "tv_anim_glow_1", "type": "Quad", "locked": false, - "disabled": false, + "disabled": true, "groupID": "#tv_glow", "customName": "", "textRedirects": {}, @@ -2158,7 +2163,7 @@ ], "color": "#52779aff", "sortMode": "ignore", - "opacity": 0.28553271858866913, + "opacity": 0.24953602443603395, "blendMode": "lighter", "isGrid": false, "gridLinesX": 5, @@ -2172,7 +2177,7 @@ "name": "tv_anim_glow", "type": "Quad", "locked": false, - "disabled": false, + "disabled": true, "groupID": "#tv_glow", "customName": "", "textRedirects": {}, @@ -2210,7 +2215,7 @@ ], "color": "#52779aff", "sortMode": "ignore", - "opacity": 0.28553271858866913, + "opacity": 0.24953602443603395, "blendMode": "lighter", "isGrid": false, "gridLinesX": 5, @@ -2224,7 +2229,7 @@ "name": "tv_aim_glow3", "type": "Quad", "locked": false, - "disabled": false, + "disabled": true, "groupID": "#tv_glow", "customName": "", "textRedirects": {}, @@ -2262,7 +2267,7 @@ ], "color": "#7c62ac", "sortMode": "ignore", - "opacity": 0.28553271858866913, + "opacity": 0.24953602443603395, "blendMode": "screen", "isGrid": false, "gridLinesX": 5, @@ -2294,17 +2299,17 @@ "relation": "in" }, "parallax": 1, - "x": 839.2252670877428, - "y": 248.0374520832024, - "width": 15.368666227146264, - "height": 21.839683585944687, + "x": 743.936959170698, + "y": 246.12667470239316, + "width": 15.333005334145621, + "height": 21.78900758010167, "baseWidth": 19.69986357435198, "baseHeight": 27.994542974079128, "colliderWidth": 0, "colliderHeight": 0, "spriteName": null, "color": "#AAAAAA", - "scale": 0.7801407440788234, + "scale": 0.7783305339285831, "refScale": 0.8, "modelScale": 0.8, "ignoreScaling": false, @@ -2328,24 +2333,24 @@ } ], "layer": 0, - "visible": false, + "visible": true, "hidden": false, "spatial": { - "parentNodeId": "Hero_1", - "relation": "in" + "parentNodeId": "Walk_main", + "relation": "on" }, "parallax": 1, - "x": 839.2252670877428, - "y": 248.0374520832024, - "width": 25.35457418256176, - "height": 37.3979969192786, + "x": 326, + "y": 247, + "width": 25.322631578947366, + "height": 37.350881578947366, "baseWidth": 199.99999999999997, "baseHeight": 295, "colliderWidth": 0, "colliderHeight": 0, "spriteName": "tv_rc", "color": "#AAAAAA", - "scale": 0.1267728709128088, + "scale": 0.12661315789473684, "refScale": 0.13, "modelScale": 0.13, "ignoreScaling": false, @@ -2353,6 +2358,54 @@ "opacity": 1, "blendMode": "source-over", "blur": 0 + }, + { + "name": "NPC", + "type": "Actor", + "locked": false, + "disabled": false, + "groupID": null, + "customName": "", + "textRedirects": {}, + "interactions": {}, + "components": [ + { + "type": "Actor" + }, + { + "type": "NPC", + "enabled": true, + "memory": "Hero_1 asked me to take the remote and turn on the TV. I'm helping them get the Muppet Show started.", + "objectives": ["TV is ON"], + "objectivesInitializedFromTA": true + } + ], + "layer": 0, + "visible": true, + "hidden": false, + "parallax": 1.0746753892935719, + "x": 1168.0012191544956, + "y": 300.00009951210046, + "width": 146.9613259668508, + "height": 282.37569060773495, + "baseWidth": 146.9613259668508, + "baseHeight": 282.37569060773495, + "colliderWidth": 0, + "colliderHeight": 0, + "spriteName": "girl", + "color": "#36d87fff", + "scale": 1, + "refScale": 1, + "modelScale": 1, + "ignoreScaling": false, + "animationSpeed": 150, + "opacity": 1, + "blendMode": "source-over", + "blur": 0, + "isPlayer": false, + "speed": 0.1, + "direction": "down", + "animSets": {} } ], "folders": [ @@ -2441,5 +2494,82 @@ "panningModel": "HRTF", "distanceModel": "linear", "defaultReverbIR": "/sounds/ir/room_drum_medium.wav" + }, + "sceneLog": { + "entries": [ + { + "id": "1780248398951-1", + "kind": "speech", + "timestamp": 1780248398951, + "actorId": "Hero_1", + "displayName": "Hero_1", + "text": "Hello", + "knownByNpcIds": ["NPC"] + }, + { + "id": "1780248400642-2", + "kind": "speech", + "timestamp": 1780248400642, + "actorId": "NPC", + "displayName": "Linda", + "text": "Oh, hi there! I was just about to put on the Muppet Show. Have you seen it?", + "knownByNpcIds": [] + }, + { + "id": "1780248413407-3", + "kind": "speech", + "timestamp": 1780248413407, + "actorId": "Hero_1", + "displayName": "Hero_1", + "text": "Not Yet", + "knownByNpcIds": ["NPC"] + }, + { + "id": "1780248415193-4", + "kind": "speech", + "timestamp": 1780248415193, + "actorId": "NPC", + "displayName": "Linda", + "text": "Oh, you should really stick around and watch it with me then! It's starting soon.", + "knownByNpcIds": [] + }, + { + "id": "1780248447688-5", + "kind": "speech", + "timestamp": 1780248447688, + "actorId": "Hero_1", + "displayName": "Hero_1", + "text": "Ok. Come closer to me", + "knownByNpcIds": ["NPC"] + }, + { + "id": "1780248449472-6", + "kind": "speech", + "timestamp": 1780248449472, + "actorId": "NPC", + "displayName": "Linda", + "text": "Sure! Let's sit together and watch the show. It's about to start!", + "knownByNpcIds": [] + }, + { + "id": "1780248520675-7", + "kind": "speech", + "timestamp": 1780248520675, + "actorId": "Hero_1", + "displayName": "Hero_1", + "text": "Please take the rc and turn tv on", + "knownByNpcIds": ["NPC"] + }, + { + "id": "1780248522411-8", + "kind": "speech", + "timestamp": 1780248522411, + "actorId": "NPC", + "displayName": "Linda", + "text": "Of course! Let me grab that remote for you.", + "knownByNpcIds": [] + } + ], + "lastPmProcessedAt": 1780248522411 } } diff --git a/public/sprites/girl.json b/public/sprites/girl.json new file mode 100644 index 00000000..498717fd --- /dev/null +++ b/public/sprites/girl.json @@ -0,0 +1,9 @@ +{ + "id": "girl", + "imageFile": "public/assets/girl.png", + "x": 0, + "y": 0, + "width": 100, + "height": 222, + "frames": 1 +} diff --git a/public/text/objects/NPC.json b/public/text/objects/NPC.json new file mode 100644 index 00000000..6e53b1a3 --- /dev/null +++ b/public/text/objects/NPC.json @@ -0,0 +1,9 @@ +{ + "title": "Linda", + "description": "You see a young woman.", + "details": "She has short, messy brown hair. She is wearing a t-shirt with a faded band logo and jeans. A worn-looking messenger bag is slung over her shoulder. She appears to be waiting for someone or something.", + "lore": "Linda wants to watch her favorite TV show -- Muppet show.", + "objectives": ["TV is ON"], + "takeFailure": "Linda is quite heavy. Besides, what would you do with her?", + "synonyms": ["girl"] +} diff --git a/public/text/system/npc-pm-system.md b/public/text/system/npc-pm-system.md new file mode 100644 index 00000000..e4facb36 --- /dev/null +++ b/public/text/system/npc-pm-system.md @@ -0,0 +1,31 @@ +You are the Puppet Master for NPCs in a retro adventure game. + +You role-play the NPCs listed in the context. Each NPC has its own knowledge, lore, objectives, and memory. Do not let one NPC use facts that are only available to another NPC. + +Respond with exactly one JSON object and no extra text: + +{ +"kind": "pm_response", +"plans": [ +{ +"npcId": "real_npc_id", +"steps": [ +{ "type": "SAY", "text": "short in-character line" }, +{ "type": "OBJECTIVES_SET", "objectives": ["current goal"] } +], +"memory": "optional durable note for that NPC" +} +] +} + +Supported steps: + +- SAY: make the NPC speak once. +- MEMORY_SET: replace that NPC's durable memory note. +- OBJECTIVES_SET: replace that NPC's current runtime objectives. Use an empty array only when the NPC intentionally has no current objectives. +- WAIT: pause this NPC for a number of milliseconds; when the timer elapses, you will be called again for that NPC with a wait_elapsed trigger. +- MOVE_TO: move this NPC to a point or visible entity. Use either `{ "type": "MOVE_TO", "x": 100, "y": 200 }` or `{ "type": "MOVE_TO", "targetId": "object_id" }`. With `targetId`, the engine moves the NPC to the nearest walkable position from which the target can be approached/reached, not onto the object's center. When movement ends, you will be called again for that NPC with a move_completed trigger containing the move result. + +In the current engine slice, reliable actions are SAY, MEMORY_SET, OBJECTIVES_SET, WAIT, and MOVE_TO. Use MOVE_TO when physical repositioning matters for the NPC's current objective. + +Keep speech concise, in character, and responsive to the unread scene log. If no NPC should respond, return an empty plans array. diff --git a/src/components/ConsoleOverlay.tsx b/src/components/ConsoleOverlay.tsx index d507db91..d792b06b 100644 --- a/src/components/ConsoleOverlay.tsx +++ b/src/components/ConsoleOverlay.tsx @@ -124,7 +124,14 @@ export const ConsoleOverlay: React.FC = ({ game }) => { key={i} style={{ marginBottom: '4px', - color: line.type === 'command' ? '#aaa' : line.type === 'error' ? '#f55' : '#fff', + color: + line.type === 'command' + ? '#aaa' + : line.type === 'error' + ? '#f55' + : line.type === 'dialogue' + ? '#7dd3fc' + : '#fff', whiteSpace: 'pre-wrap', overflowWrap: 'break-word', userSelect: 'text', diff --git a/src/components/UIOverlay.tsx b/src/components/UIOverlay.tsx index 6727646f..cbb7acfb 100644 --- a/src/components/UIOverlay.tsx +++ b/src/components/UIOverlay.tsx @@ -269,16 +269,7 @@ export const UIOverlay: React.FC = ({ game }) => { if (firstWord.startsWith('#')) { game.console.processCommand(val); } else { - const preprocessed = game.console.preprocessGameplayInput(val); - - // 1. Log Command to Buffer - game.console.log(preprocessed, 'command'); - - // 2. Add to History - game.console.addHistory(preprocessed); - - // 3. Send to gameplay parser - void game.parser.parse(preprocessed); + void game.submitGameplayInput(val); } e.currentTarget.value = ''; diff --git a/src/components/editor/properties/SectionComponents.tsx b/src/components/editor/properties/SectionComponents.tsx index a4762a1f..a1a78757 100644 --- a/src/components/editor/properties/SectionComponents.tsx +++ b/src/components/editor/properties/SectionComponents.tsx @@ -218,7 +218,12 @@ export const SectionComponents: React.FC = () => { { value: 'WalkBox', label: 'WalkBox (Collider)' }, ] : []), - ...(selectedObjectType === 'Actor' ? [{ value: 'Shadow', label: 'Shadow' }] : []), + ...(selectedObjectType === 'Actor' + ? [ + { value: 'NPC', label: 'NPC' }, + { value: 'Shadow', label: 'Shadow' }, + ] + : []), ].map((opt) => ({ ...opt, icon: getIconUrl(opt.value) }))} placeholder="+ Add Component" onChange={(value) => { @@ -320,6 +325,18 @@ export const SectionComponents: React.FC = () => { offsetY: 0, triggerId: '', }); + } else if (type === 'NPC') { + const initialObjectives = game.textAssets.getResolvedObjectListField( + o, + 'objectives' + ); + o.components.push({ + type: 'NPC', + enabled: true, + memory: '', + objectives: initialObjectives, + objectivesInitializedFromTA: true, + }); } else if (type === '3d-parallax') { o.components.push({ type: '3d-parallax' }); } else if (type === 'WalkBox') { @@ -424,6 +441,71 @@ export const SectionComponents: React.FC = () => { )} + {comp.type === 'NPC' && ( + <> +
+ Enables Puppet Master dialogue, NPC memory, and runtime objectives. +
+
+ +
+
+ +