From c302e661f8711500afc0dfc81e77fe110e09b817 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:51:35 -0800 Subject: [PATCH 01/26] Add dock terminal lobby bezel art and bezel classes Resizable bezel pieces at 1x (9-slice frame, deck with hazard-post ends, keycaps in idle/hover/pressed/disabled, lamps, grille, button tray), bezel.json describing their insets and positions, and lobby_terminal.css classes that assemble them at an integer scale. Ready lamps are cropped from icons/hud/lobby/collapse_expand.dmi. --- .../modules/lobby_terminal/html/bezel.json | 146 ++++++++++ voidcrew/modules/lobby_terminal/html/deck.png | Bin 0 -> 450 bytes .../lobby_terminal/html/frame_9slice.png | Bin 0 -> 429 bytes .../modules/lobby_terminal/html/grille.png | Bin 0 -> 97 bytes .../html/key_character_disabled.png | Bin 0 -> 469 bytes .../html/key_character_hover.png | Bin 0 -> 487 bytes .../html/key_character_idle.png | Bin 0 -> 508 bytes .../html/key_character_pressed.png | Bin 0 -> 490 bytes .../lobby_terminal/html/key_join_disabled.png | Bin 0 -> 382 bytes .../lobby_terminal/html/key_join_hover.png | Bin 0 -> 396 bytes .../lobby_terminal/html/key_join_idle.png | Bin 0 -> 404 bytes .../lobby_terminal/html/key_join_pressed.png | Bin 0 -> 401 bytes .../html/key_notready_disabled.png | Bin 0 -> 401 bytes .../html/key_notready_hover.png | Bin 0 -> 413 bytes .../lobby_terminal/html/key_notready_idle.png | Bin 0 -> 420 bytes .../html/key_notready_pressed.png | Bin 0 -> 418 bytes .../html/key_observe_disabled.png | Bin 0 -> 440 bytes .../lobby_terminal/html/key_observe_hover.png | Bin 0 -> 459 bytes .../lobby_terminal/html/key_observe_idle.png | Bin 0 -> 465 bytes .../html/key_observe_pressed.png | Bin 0 -> 462 bytes .../html/key_ready_disabled.png | Bin 0 -> 401 bytes .../lobby_terminal/html/key_ready_hover.png | Bin 0 -> 413 bytes .../lobby_terminal/html/key_ready_idle.png | Bin 0 -> 420 bytes .../lobby_terminal/html/key_ready_pressed.png | Bin 0 -> 418 bytes .../lobby_terminal/html/lamp_notready.png | Bin 0 -> 177 bytes .../modules/lobby_terminal/html/lamp_off.png | Bin 0 -> 143 bytes .../lobby_terminal/html/lamp_ready.png | Bin 0 -> 179 bytes .../lobby_terminal/html/lobby_terminal.css | 256 ++++++++++++++++++ .../modules/lobby_terminal/html/strip_top.png | Bin 0 -> 157 bytes voidcrew/modules/lobby_terminal/html/tray.png | Bin 0 -> 194 bytes 30 files changed, 402 insertions(+) create mode 100644 voidcrew/modules/lobby_terminal/html/bezel.json create mode 100644 voidcrew/modules/lobby_terminal/html/deck.png create mode 100644 voidcrew/modules/lobby_terminal/html/frame_9slice.png create mode 100644 voidcrew/modules/lobby_terminal/html/grille.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_character_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_character_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_character_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_character_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_join_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_join_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_join_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_join_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_notready_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_notready_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_notready_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_notready_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_observe_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_observe_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_observe_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_observe_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_ready_disabled.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_ready_hover.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_ready_idle.png create mode 100644 voidcrew/modules/lobby_terminal/html/key_ready_pressed.png create mode 100644 voidcrew/modules/lobby_terminal/html/lamp_notready.png create mode 100644 voidcrew/modules/lobby_terminal/html/lamp_off.png create mode 100644 voidcrew/modules/lobby_terminal/html/lamp_ready.png create mode 100644 voidcrew/modules/lobby_terminal/html/lobby_terminal.css create mode 100644 voidcrew/modules/lobby_terminal/html/strip_top.png create mode 100644 voidcrew/modules/lobby_terminal/html/tray.png diff --git a/voidcrew/modules/lobby_terminal/html/bezel.json b/voidcrew/modules/lobby_terminal/html/bezel.json new file mode 100644 index 00000000000..90ab078da92 --- /dev/null +++ b/voidcrew/modules/lobby_terminal/html/bezel.json @@ -0,0 +1,146 @@ +{ + "_note": "All numbers are 1x pixels. Multiply by the integer scale s. Built by ~/code/tg-tools/lobby-terminal-art/make_pieces.py.", + "scale": { + "rule": "s = clamp(min(floor(paneH / 360), floor(paneW / min_width)), 1, 4)", + "min_width": 472, + "base_height": 360 + }, + "frame": { + "file": "frame_9slice.png", + "size": [ + 132, + 28 + ], + "slice": { + "top": 18, + "right": 64, + "bottom": 6, + "left": 64 + }, + "note": "Covers the pane from the top down to the deck. Centre is transparent; draw it over the screen." + }, + "screen": { + "inset": { + "top": 15, + "left": 13, + "right": 13, + "bottom": 52 + }, + "note": "Glass rect inside the whole bezel. The frame paints rounded corner pixels on top of it." + }, + "strip_top": { + "file": "strip_top.png", + "size": [ + 61, + 8 + ], + "top": 3, + "center_x": true + }, + "deck": { + "file": "deck.png", + "height": 49, + "slice": { + "left": 26, + "right": 26 + }, + "note": "Full width, bottom of the pane. Hazard posts and bottom screws live in the end caps." + }, + "keys": { + "left": 30, + "top": 6, + "gap": 4, + "sizes": { + "join": [ + 68, + 34 + ], + "ready": [ + 68, + 34 + ], + "notready": [ + 68, + 34 + ], + "observe": [ + 84, + 34 + ], + "character": [ + 100, + 34 + ] + }, + "files": "key__.png", + "order": [ + "join or ready/notready", + "observe", + "character" + ], + "note": "Images include the 2 px dark well. ready = player readied (green legend), notready = red legend." + }, + "lamp": { + "files": "lamp_.png", + "size": [ + 10, + 9 + ], + "after_key": "ready/notready", + "margin_left": 2, + "margin_right": 1, + "top": 17, + "note": "Sits between the READY key and OBSERVE (pre-round only). Cropped from collapse_expand.dmi ready_blip_*." + }, + "grille": { + "file": "grille.png", + "tile": [ + 4, + 14 + ], + "max_width": 40, + "top": 14, + "min_gap": 12, + "note": "Centred between the key bank and the tray; hidden when there is no room." + }, + "tray": { + "file": "tray.png", + "size": [ + 142, + 31 + ], + "right": 29, + "top": 7, + "buttons": { + "first": [ + 3, + 3 + ], + "step": 28, + "size": [ + 24, + 24 + ], + "order": [ + "wiki", + "poll", + "crew_manifest", + "settings", + "changelog" + ], + "source": "icons/hud/lobby/bottom_buttons.dmi (+ _highlighted/_pressed/_disabled)" + }, + "new_poll": { + "source": "icons/hud/lobby/poll_overlay.dmi new_poll", + "size": [ + 24, + 26 + ], + "over": "poll", + "offset": [ + 0, + -1 + ] + } + } +} diff --git a/voidcrew/modules/lobby_terminal/html/deck.png b/voidcrew/modules/lobby_terminal/html/deck.png new file mode 100644 index 0000000000000000000000000000000000000000..883f896bed5ecb7f89da7333190ec6e5d997d8db GIT binary patch literal 450 zcmeAS@N?(olHy`uVBq!ia0vp^7C>ys!3HGzJF~7aFfev{x;TbZ%y~O&qZhNIfP?zs zvR~XuKACU%Be?3CdgUi5xkw*$JLtCN9rL9htV%H}Cuj+r;AipwD841^+X=bwiqr4T z1thKPN*33cJ0ofe1C#L943?}wCJvt~ ziv(A=aw-@EcDpSQQ*AilqAS%%SV73AnhiBIS;yRux2trw)jeD`YX{GXizU7?eEzRL zCX_FKXZe!rLMY4E93>OCjJ{*vA1s<-;<)78nWc|K=E>|8+xow2c7}CZopN)7Onr>w z?ki;{@4BqKI!VRk_`KJl#pchCEV{e;neo&3B{7oU>PxJ8jcRNYUH7t^&vo(I$GAEv W>E7RS@&drPVeoYIb6Mw<&;$U#Fu#NV literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/frame_9slice.png b/voidcrew/modules/lobby_terminal/html/frame_9slice.png new file mode 100644 index 0000000000000000000000000000000000000000..a4827c1781390d4fc93c02d92029be0379d65f9c GIT binary patch literal 429 zcmV;e0aE^nP)tErcEcW{|7B8P3V;Q$$NQ;7X*jD24Kmjx5@NA{tP>++q*O`PtX5>AP=Qqi|nzzde9r|CKp89zQb~^3Fc58ocG2u8qIb#1X=)>*l z${}Moyfg2gUz@8EDp%Cg+(1x@;zZHq2-F{^ryea#(K_VNFJBeZM(IFgMbWFbo>E{Pq>nR*CY1<-KGL382LfTxOVS&wKp+hI+4czQ z5RigEKnel@@<8B8ZT)O}WPOG}DD;x_Ml@s~6#7VeB3`LPDBR-P+$aHe`DJdQf^ps- XDdy;}Tg!Dr00000NkvXXu0mjff@{4d literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/grille.png b/voidcrew/modules/lobby_terminal/html/grille.png new file mode 100644 index 0000000000000000000000000000000000000000..18e74dd6ae59bfc40e543ca7478ee24a72165922 GIT binary patch literal 97 zcmeAS@N?(olHy`uVBq!ia0vp^EI`c1!3HEbvi3>FS$pHZue(@jT2{^!_ v#B8xrfo1J%^1)AX6;%4kN>QMy`*pC+f}u^)Pt4`njxgN@xNA(To?( literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_character_disabled.png b/voidcrew/modules/lobby_terminal/html/key_character_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..6b0cce1d7944dabbab548fefb3c21b81557e9c10 GIT binary patch literal 469 zcmV;`0V@89P)gM9#_dAW;OVi1>0%91@OETP#od-}#k-5E#%_-Ox@it_yi%c=O@=LfK!lyJkPhrk|gPTyeU{i z+EO6}hbPD7v3_`5i>-cEZQJx;r5ZQ&cm4h9cUjpD1M1Hn+QlTov+p_8ssJmm@MVqi0pb&Rhw&yZ|KZe3+Tp{#$1o6m% z(9=u6i<2O(kCK5qSVM#XL4*N8gaJW>(W9-Tv~#gP*o&P@r}_H)_`h@U;Aj*#qmGj; z_bP0)tk;(eZN~91TGl8XTXg_@6+)JUMO_%>8iM^+f&`&b0ZIfv&LcPGq;W+z00000 LNkvXXu0mjflyKIt literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_character_hover.png b/voidcrew/modules/lobby_terminal/html/key_character_hover.png new file mode 100644 index 0000000000000000000000000000000000000000..bc23c91a29bdbee5992daa7d6645a38831e9cdde GIT binary patch literal 487 zcmVrcnV$tu|&+Uu^=%sFe9gM7<_s5MqNRsVu1;h~1OT4xkIuD?f%3k091R)NN4lyYK8DC!g zzUP7v`+K{g@>y!GZ}0NtDh zey4)iOt|a#v?lJ_*X_EzP3Pls{_K|J;|k$KdVXFk%fzCxZyQeymc)UJC5jZp#gld1 z)>1gcv9T;4R>fjjTvu!Qf6{%=&$&X%iEw&!+Tu#|k)Q2K%2%a0TUK2Hbt|SW`h?8{ z`B-7u9?Jy&7z(3xh0x;>!~+Y`=IJG91}sY$5JVUdL>Lf67*W-B6cC01Z6&3hi^FIo zPzr$f`uv#PxwyVD(oGNmy;q^~9`f=$L7#CxjFvS@wyXvKz6v4B!YWG``8@>ttpo`| dr2>=)egLCD5q9-;Tp<7e002ovPDHLkV1lK(-^~C3 literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_character_idle.png b/voidcrew/modules/lobby_terminal/html/key_character_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..3b3f478f115f79b5f494ddcce6d567e361dc2bcc GIT binary patch literal 508 zcmVrcn2gV9)pQ)JOfXF#85VnkUUmA0TU9hQI#U75T4jr5TI*u zl%{QB$GQ5H{@>8ry|}&Px6eJ>tt2`8jerfZSf+ zOy5&Mh?C=^Q2uN*506i2c}@^w^zyoHe?W$IwgSQfL4>govWn>U@Ay1vAXk?c&QpMs zAjD)mbZr7S1uyC0wXXAfC*^bk&@`&UcIDr4EOuWDB%A&CQ@kSJ0R zpO|dywjqT>9Gj3;N2p>6SzNtU{oi!c{qr6nc}F;7w5re(>+sL^pHy8VrA0f2v_VDt zA+-EnlAqEtEH3S^LV-+LK{>_424lSWSL$7dq6>G^%C&nBnWmB z`N13_3rcn73TJVPD;HlBeeKw>BxNUS^qQzc#lRUoJknb=qmphGxH z(>AtaUwlgbZ>a2Eot%BS-)`(dnjZcrAchfrBs<%&{Qyd-{QW}>F%q&sKqN{QadLJV z*OQ)eb9KEO5aM`p6w9Apb3c11%1Z$u=Fcx}`vXc~4iN?dB8(Nus$)96<@2P2Twa_z zPXSH=Ar?=Mu1x@EKs0%tZ?q*z(tCSTFo$#{Lo|2uSy9gRCo8t{ch$O0`!ZF#slDs< ztG{uzue*P%0kIiy-}YIJ-M6mGb$*-rad1VcFix1pXKbqj84N>k-%^2ZY{U0$!W~g55-ZFo#Iakn(0bh{J%6lG4e= z;b<$83xN3k`usb&SY*iV-dGnw1oTeq#yRBeb%8qLco-#XoE}&;0DKvO!`1>R<`A4F g5fl(g6`(}$3kV7qGDs86m;e9(07*qoM6N<$f<@lbZU6uP literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_join_disabled.png b/voidcrew/modules/lobby_terminal/html/key_join_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..5138356a4f91eae6f42ec84d32fecd0cfda3d9cf GIT binary patch literal 382 zcmV-^0fGLBP)uub)=j9nlREf*>IX5`yrH z>Nkvr6VQonrw!xjEIuKE&1wm0LRyH2>$(sIV@!GcS5OU6l_5cjwW|M}&lexZK4}}g zhp)BG5Pt7u*nqqvIQXcx{~~cyruagl(L#7gW*I9)wG=B!=^R8=jj>fdIsT%<_{qLo zWmK#nu`IhhVh}QM98)1Iku@X{8Nwo7pH;`T5Yp|mA#F$r8B6}u))A^92oi!IAqWzJ z$cU{>v2$nHSumN?@$kHJS16ajWW4`|L#NDky-D?T cqzVC7F7KtJ^Cno5y8r+H07*qoM6N<$g1!%>+W-In literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_join_hover.png b/voidcrew/modules/lobby_terminal/html/key_join_hover.png new file mode 100644 index 0000000000000000000000000000000000000000..74c4164c05da7a7e1236689a55d9c475856aca0b GIT binary patch literal 396 zcmV;70dxL|P)7&?E@rjQ7zBXM~MhLJr_NH4v> zkjpYFums|W4v+{ODQm!FaQ9Cpp@SOLq}x*0000Mo+ zg!Ix247n^5hb0h4bbu_vk+Mc?fY3Tnn1)hvMh3JhV_0YvbBnMz7(Hh3)iSiGK?bDS zT|~eWl*psY(IUWxWEnCfQivFV<}!FaQAe-k$N}c>KZu0000=XqXq~FxH;G| zj6|^Oz+pHWB|t#n-owWXBN>dAA!uMUK+ph2mLUa9o;;snFoFJ_I*PMAMF9c=PaoW2 z7*2rV0D*y5uU;|W0%BrfG?%9q7%f9+l|qogihBp(>nHsKw%n6$r%~Ys*GWwRm??TaWHz!;;Ut7 zQG*OfwHrae6O_oK%h4jhhGZEsBvObNf#x%<QVj){enVI+fpf4+lf?%*jzQbw9#Bm>yUAI>u5`ID!^nolsxNGqr? vjPw*TI@yf{ungDHbBY24lvG9m0~i1R+UJ^^VnL2Z00000NkvXXu0mjfh&G+O literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_notready_disabled.png b/voidcrew/modules/lobby_terminal/html/key_notready_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..c3442d2fdb0b793b2454000a24c9b59c1f6d3fad GIT binary patch literal 401 zcmV;C0dD?@P)B=W{HZQZhy*&&=$xSfK7E4e}m5-~f9PPyDNUiUfccbP&tL@}?&dB^V}$e`Z? z+tCtatobQjMq~{kh#&+Jgdl4%P)DT++i5bfaCyyfmg3y zG2jAXVqya>Pc9fOL#UNPK;V8;4+An#tSiAQPe=_R{n+G@#nAb54-gO_` zXtLK==AselnQl?j@;V`AZC7|Gz@pYNzMcak#F3?mu9 z#)@&4Anxg<+(p5KxvGt!2;v%Sf4TAt^vWNo5o;fB^tSHo<7M{9^O~ O0000=XqXq~FxH;G| zj6|^Oz+pHWB|t#n-owWXBN>dAA!uMUK+ph2mca)mPoB>(lt6z^9mz!iNdW=^PaoW2 z7|wv?0D*y5uU;|W0%Brf11?W47%fAnl|n$^ep3$vGEl55!7EQl4I%y5-gLmg0@LI|Z4LNx^;Kq$)%ssK?WP@|BNUC7Z-s=8rc%M7X%B1WKjJkauV36TDt zI)>p6ND7d9BVaUbv<$(QLUthywvJT52%5?S&D=4ua59W!@bAxe@XQ@Ng-FUsGmK;a z8wtc&hCF}rbXfBVW*KP(6^4h8lGMY|OfPj+9C}02s04Eu_mcUejwg3PC M07*qoM6N<$g7Xlh%m4rY literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_observe_disabled.png b/voidcrew/modules/lobby_terminal/html/key_observe_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..1a97fedb99ad6ef1ad94ca9004f9590d3b5d500b GIT binary patch literal 440 zcmV;p0Z0CcP)_1B z@sT+AenbC#@%8e_cWx8MMX~&h2sBc7V2eY%hyf#B-+l}QRu|{fW;1hcuW!Delc^K= zZ;+wLkfF$sp~$q0k_q>Bk5NcmtuLd8r{}*j6sWfw+aW<=ZD@lKLa8yvX0H#0dZ;fm zv>`h?ZC~@UQfghg?ig9q>0RG1A8!+XbVGFs1G*E^oq&yQ63;fiEmZQpmAp$!;APdT z>&BbtF8O(BD0gTHvaX6hq|{C02U+r5n(1AiyO=)O40SEp%<)%1S1ziyq8eu!f3%YC zOMoM;;3j&X*+b$7Tk<|Jc!ui!p0SzhqZkTTUkF4)Z7*eo2=!28$WUa+P-MtZWMbHg z13O2O^Z+|{P<-s)fA1VQI?kufXy!Ec+LR^#va9KRk008S&x;eI0fc&nLdnyjgiPB* i>3%z+p)gg62!$^&oCXV0u?1fD*)L$W0! z36LD1F!1WtE1Ys-Vq!yHo?I|mh7Oh#3Ii+d9e}eVlpJu%VUs7M23?MjJU08$)stdA z-2;@6gF%3hhX{Fq6nSFIBh`FT!DyBrY+0U?A|XP_0k1qE{lv&q;=Vx{pb-=l3Fz{; z%qJ(S6Knp6%<>UvDIJ?QC4o-26iR89T|r?h0GnHI6)U*(5oSC?EsC=Gt(Uq0gT@9p&Q*4}w`#002ovPDHLkV1i5Y Bw`KqU literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_observe_idle.png b/voidcrew/modules/lobby_terminal/html/key_observe_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..fbb5c3b1da704dabf830c2f78bdc2e127afed383 GIT binary patch literal 465 zcmV;?0WSWDP)W8X{Z7 zcz5<^Slik8ip0tDPR36=W5?@WnjU{83>gD>5eNHp5hEslecKocOwY4sv*|du*Ec`! zrJ=yt=}A-hbej47L%F?WD6n{b37yYQ(Qi6|U4soJ=1I7# z9&CpCmh8myDxfYGRa;SwGmRgt+tVMB>wL;tgf{$37c+~~ohIPm01f3W8mf&o51-nX9|86J*`O~!#@txefO z*UJ@u*7$*z{3ty#8o;P$D3m;(<%ubKD6h8@8VXaDNl5qxd)n|o8ki5?00000NkvXX Hu0mjfh%VLO literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_observe_pressed.png b/voidcrew/modules/lobby_terminal/html/key_observe_pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..52b36c5ec25e5b53ae4478ae15d38ebbe2c38e22 GIT binary patch literal 462 zcmV;<0WtoGP)%d_+8zn$N;NHW>^mfm%0;6RpmBDC$QW;=md3<2<gZ%87}I4S9KT!DtyeSW+kqthjdo&W=!Wz$u4Ko{$=J zIYRQ->_=BmiurU8P(ls{0YV-kB=W{HZQZhy*&&=$xSfK7E4e}m5-~f9PPyDNUiUfccbP&tL@}?&dB^V}$e`Z? z+tCtatobQjMq~{kh#&+Jgdl4%P)DT++i5bfaCyyfmg3y zG2jAXVqya>Pc9fOL#UNPKw!na0}RL@LdgNIJRvoN^kb7p7DMOLJwQN!kc03!kQfh9 z+rz^;%S0$Sz*7;m0*m4lvI0+%Csm%3K%rL3p-PrnfyY6F9EzSL1{yF?JLwK}KyeBo zlu`)Q6odewEH|hEB!WPVLP~ZaM?b0RhJ7tFs8Wa+f#&f*%hM%5`pM`$4mB{EWzfKA zfS`fV06_zz0fMhkU4}FqKT-iBXetvlbH~KO$uN?^zdzqmXYM3rq!~ssfQ=R7EJL0@ zc{;551hb5^f(pY(Pa&WzGg`}_0hW<6-$GJ=fRf56U;qOEwr{Uhpve0A00000NkvXX Hu0mjf-j1Fp literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/key_ready_idle.png b/voidcrew/modules/lobby_terminal/html/key_ready_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..223cefc4e99660d596a6cb75dea906f99435c3bf GIT binary patch literal 420 zcmV;V0bBlwP)F9;SNX& zkb5IwG;OpDK?9=!f(Awd1Ya4l3u!ohqyk3JR3>QVj){enVI+fpf4-y6+)2tvGmK;a z8!N_HhCF}rbXfBVW*KP(6^4=XqXq~FxH;G| zj6|^Oz+pHWB|t#n-owWXBN>dAA!uMUK+ph2mca)mPoB>(lt6z^9mz!iNdW=^PaoW2 z7|wv?0D*y5uU;|W0%Brf11?W47%fAnl|n#Z#k~Uz$RI+=0k1qEHH7qIlSdXq=hHnv zK!A{g@Hvne4^i90!#c}EC^^7W5w!w~;uNw1Pm(89o{~VJR?4ADmRW(vL4+KNo+Snv zFi|_{4s}3r3L%tI2-OsX0HG{5r~)K{K#f95b|FVUsp^J(EiKKMQASpoZjeya#(J};I3fYA;*g8@HBWNlUG;_zq!pSg_!M{J>!83R86e1}j%`lPy zY$On88S?ze(_zggm}R6DR2W8j3K^a3#sXMI%4j-C0Rl=YqksVn0G*<>5on2FgnO~TJ@jTSNG$}WQ+P=7R=mpieE{DxwfT+B}lEiFgLi<@vXGF^TcM$8TZQ{ zs2tW^_nv3{f=@mbZ(W$KgmaWX){f=eP~BIvpHa}@d3r(uLn224OKY-Ikk-9|7=wH> aWrlSk3Xz9p&Nu?yz~JfX=d#Wzp$Pzqb3aA^ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/lamp_off.png b/voidcrew/modules/lobby_terminal/html/lamp_off.png new file mode 100644 index 0000000000000000000000000000000000000000..2737a103a5559915ef93f1f0b72167afdaf6e45c GIT binary patch literal 143 zcmeAS@N?(olHy`uVBq!ia0vp^AT}ol8<6B)wpSQPg?YL-hE&W+wkdePaCq(U@BfV- zFlKG)>gnO~TJ@jTSNG$}WQ+P=7R=mpieE{DxwfT+B}lCc@bTeUztX27HFK4~JGV?$ r(LG#}jt^%{n9^k~BYtM~Sq_Hqr6O}#Co5|JZD8bP0l+XkK6v{6~ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/lamp_ready.png b/voidcrew/modules/lobby_terminal/html/lamp_ready.png new file mode 100644 index 0000000000000000000000000000000000000000..437e1fac4b920fe98bd39a960b463aa158e5f9eb GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^AT}ol8<6B)wpSQPRe8ENhE&W+wkdePaCq(U@BfV- zFlKG)>gnO~TJ@jTSNG$}WQ+P=7R=mpieE{DxwfT+B}lCcI1}6ZaQ}9Z=576QGwMrE ztFj$idtO4~p7WELA8vKBtXMDb&h6!QB_1B{I>VZnBDOQr87C_hwDv40@bU0ED&5EZ c>}(nf!?lG9yM;Snf}F+R>FVdQ&MBb@0Ir)vBme*a literal 0 HcmV?d00001 diff --git a/voidcrew/modules/lobby_terminal/html/lobby_terminal.css b/voidcrew/modules/lobby_terminal/html/lobby_terminal.css new file mode 100644 index 00000000000..bde1355098d --- /dev/null +++ b/voidcrew/modules/lobby_terminal/html/lobby_terminal.css @@ -0,0 +1,256 @@ +/* + * Dock terminal lobby: bezel pieces. + * Every size is a 1x pixel count times --s, the integer pixel-art scale set on .lt-bezel + * (see bezel.json "scale"). Image URLs are relative: this file is served in the same asset + * namespace as the PNGs next to it. + * + * Structure: + * .lt-bezel (--s; fills the pane) + * .lt-screen glass rect; the terminal draws inside it + * .lt-frame 9-slice frame, drawn over the screen + * .lt-strip service lamps, top centre + * .lt-deck + * .lt-deck-row + * .lt-keys > .lt-key.lt-key--join (or --ready/--notready + .lt-lamp), --observe, --character + * .lt-grille-slot > .lt-grille + * .lt-tray > .lt-sbtn x5 (spritesheet icon inside; .lt-sbtn-mark for the new-poll lamp) + */ + +.lt-bezel { + --s: 1; + position: absolute; + inset: 0; + overflow: hidden; + background: #9aa39f; + image-rendering: pixelated; + user-select: none; +} + +.lt-bezel * { + image-rendering: pixelated; +} + +/* Glass rect. bottom = 3 (recess) + 49 (deck). */ +.lt-screen { + position: absolute; + top: calc(var(--s) * 15px); + left: calc(var(--s) * 13px); + right: calc(var(--s) * 13px); + bottom: calc(var(--s) * 52px); + overflow: hidden; + z-index: 1; +} + +.lt-frame { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: calc(var(--s) * 49px); + box-sizing: border-box; + border-style: solid; + border-width: calc(var(--s) * 18px) calc(var(--s) * 64px) calc(var(--s) * 6px); + border-image-source: url('frame_9slice.png'); + border-image-slice: 18 64 6 64; + border-image-repeat: stretch; + pointer-events: none; + z-index: 2; +} + +.lt-strip { + position: absolute; + /* relative to the frame's padding box, which starts inside the 18px top border */ + top: calc(var(--s) * -15px); + left: calc(50% - var(--s) * 30px); + /* snap the centre to the pixel grid so an odd width doesn't split a pixel */ + left: calc(round(down, 50%, var(--s) * 1px) - var(--s) * 30px); + width: calc(var(--s) * 61px); + height: calc(var(--s) * 8px); + background: url('strip_top.png') 0 0 / 100% 100% no-repeat; +} + +.lt-deck { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: calc(var(--s) * 49px); + z-index: 2; +} + +.lt-deck::before { + content: ''; + position: absolute; + inset: 0; + box-sizing: border-box; + border-style: solid; + border-width: 0 calc(var(--s) * 26px); + border-image-source: url('deck.png'); + border-image-slice: 0 26 fill; + border-image-repeat: stretch; + pointer-events: none; +} + +.lt-deck-row { + position: absolute; + top: calc(var(--s) * 6px); + left: calc(var(--s) * 30px); + right: calc(var(--s) * 29px); + display: flex; + align-items: flex-start; +} + +/* Keycaps */ +.lt-keys { + display: flex; + align-items: flex-start; + flex: none; +} + +.lt-key { + display: block; + flex: none; + margin: 0; + padding: 0; + border: 0; + outline: 0; + height: calc(var(--s) * 34px); + background: transparent 0 0 / 100% 100% no-repeat; + cursor: pointer; +} + +.lt-key + .lt-key { + margin-left: calc(var(--s) * 4px); +} + +.lt-key--join, +.lt-key--ready, +.lt-key--notready { + width: calc(var(--s) * 68px); +} + +.lt-key--observe { + width: calc(var(--s) * 84px); +} + +.lt-key--character { + width: calc(var(--s) * 100px); +} + +.lt-key--join { background-image: url('key_join_idle.png'); } +.lt-key--join:hover { background-image: url('key_join_hover.png'); } +.lt-key--join:active, +.lt-key--join.is-pressed { background-image: url('key_join_pressed.png'); } +.lt-key--join.is-disabled, +.lt-key--join:disabled { background-image: url('key_join_disabled.png'); } + +.lt-key--ready { background-image: url('key_ready_idle.png'); } +.lt-key--ready:hover { background-image: url('key_ready_hover.png'); } +.lt-key--ready:active, +.lt-key--ready.is-pressed { background-image: url('key_ready_pressed.png'); } +.lt-key--ready.is-disabled, +.lt-key--ready:disabled { background-image: url('key_ready_disabled.png'); } + +.lt-key--notready { background-image: url('key_notready_idle.png'); } +.lt-key--notready:hover { background-image: url('key_notready_hover.png'); } +.lt-key--notready:active, +.lt-key--notready.is-pressed { background-image: url('key_notready_pressed.png'); } +.lt-key--notready.is-disabled, +.lt-key--notready:disabled { background-image: url('key_notready_disabled.png'); } + +.lt-key--observe { background-image: url('key_observe_idle.png'); } +.lt-key--observe:hover { background-image: url('key_observe_hover.png'); } +.lt-key--observe:active, +.lt-key--observe.is-pressed { background-image: url('key_observe_pressed.png'); } +.lt-key--observe.is-disabled, +.lt-key--observe:disabled { background-image: url('key_observe_disabled.png'); } + +.lt-key--character { background-image: url('key_character_idle.png'); } +.lt-key--character:hover { background-image: url('key_character_hover.png'); } +.lt-key--character:active, +.lt-key--character.is-pressed { background-image: url('key_character_pressed.png'); } +.lt-key--character.is-disabled, +.lt-key--character:disabled { background-image: url('key_character_disabled.png'); } + +.lt-key.is-disabled, +.lt-key:disabled { + cursor: default; +} + +/* Ready lamp, between the READY key and OBSERVE. Overrides the key gap that follows it. */ +.lt-lamp { + flex: none; + width: calc(var(--s) * 10px); + height: calc(var(--s) * 9px); + margin: calc(var(--s) * 11px) calc(var(--s) * 1px) 0 calc(var(--s) * 2px); + background: transparent 0 0 / 100% 100% no-repeat; +} + +.lt-lamp + .lt-key { + margin-left: 0; +} + +.lt-lamp--ready { background-image: url('lamp_ready.png'); } +.lt-lamp--notready { background-image: url('lamp_notready.png'); } +.lt-lamp--off { background-image: url('lamp_off.png'); } + +/* Speaker grille: fills up to 40px of the gap between keys and tray, whole dots only. */ +.lt-grille-slot { + flex: 1 1 0; + min-width: 0; + display: flex; + justify-content: center; + padding: calc(var(--s) * 8px) calc(var(--s) * 12px) 0; + overflow: hidden; +} + +.lt-grille { + width: 100%; + max-width: calc(var(--s) * 40px); + height: calc(var(--s) * 14px); + background: url('grille.png') 0 0 / calc(var(--s) * 4px) calc(var(--s) * 14px) space no-repeat; +} + +/* Small lobby buttons. The icon inside each slot comes from the bottom_buttons spritesheet + * at 1x; .lt-sbtn-icon scales it by --s. */ +.lt-tray { + flex: none; + position: relative; + display: flex; + margin-top: calc(var(--s) * 1px); + padding: calc(var(--s) * 3px); + width: calc(var(--s) * 142px); + height: calc(var(--s) * 31px); + box-sizing: border-box; + background: url('tray.png') 0 0 / 100% 100% no-repeat; +} + +.lt-sbtn { + position: relative; + flex: none; + margin: 0; + padding: 0; + border: 0; + background: none; + width: calc(var(--s) * 24px); + height: calc(var(--s) * 24px); + cursor: pointer; +} + +.lt-sbtn + .lt-sbtn { + margin-left: calc(var(--s) * 4px); +} + +.lt-sbtn-icon, +.lt-sbtn-mark { + position: absolute; + left: 0; + top: 0; + transform: scale(var(--s)); + transform-origin: 0 0; + pointer-events: none; +} + +.lt-sbtn-mark { + top: calc(var(--s) * -1px); +} diff --git a/voidcrew/modules/lobby_terminal/html/strip_top.png b/voidcrew/modules/lobby_terminal/html/strip_top.png new file mode 100644 index 0000000000000000000000000000000000000000..17366f8bee19954341d4069fa82ef982259c6f22 GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^wm{6m!3HF~u73Xxq*6Rx978JRB=hj}|2}Wf! z3`!odyZ!uVjSe(Z*qe>bhWU4CH6Z_9b>kH4|jd-M6;@weB#Kg@4& zR+15VIDw Date: Tue, 10 Nov 2026 12:01:24 -0800 Subject: [PATCH 02/26] Add dock terminal lobby shell and section seams A BROWSER control over the map pane renders the LobbyTerminal tgui interface for new players. The terminal datum declares the section procs for crews, shipyard and housing, shared code-lockout and join-lock helpers, and the data contract in types.ts. term.tsx holds the text-grid primitives the tabs draw with. The classic HUD lobby stays underneath as the fallback. --- interface/skin.dmf | 9 + tgstation.dme | 7 + .../interfaces/LobbyTerminal/Bezel.tsx | 237 +++++++++++++ .../interfaces/LobbyTerminal/CrewsTab.tsx | 28 ++ .../interfaces/LobbyTerminal/Header.tsx | 108 ++++++ .../interfaces/LobbyTerminal/HousingTab.tsx | 28 ++ .../interfaces/LobbyTerminal/Notice.tsx | 57 +++ .../interfaces/LobbyTerminal/YardTab.tsx | 25 ++ .../interfaces/LobbyTerminal/index.tsx | 213 ++++++++++++ .../interfaces/LobbyTerminal/layout.ts | 90 +++++ .../interfaces/LobbyTerminal/term.tsx | 316 +++++++++++++++++ .../interfaces/LobbyTerminal/types.ts | 269 +++++++++++++++ .../modules/lobby_terminal/_lobby_terminal.dm | 324 ++++++++++++++++++ .../modules/lobby_terminal/lobby_assets.dm | 35 ++ .../modules/lobby_terminal/lobby_bezel.dm | 178 ++++++++++ .../modules/lobby_terminal/lobby_crews.dm | 2 + .../modules/lobby_terminal/lobby_housing.dm | 2 + voidcrew/modules/lobby_terminal/lobby_tgui.dm | 119 +++++++ voidcrew/modules/lobby_terminal/lobby_yard.dm | 2 + voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../modules/unit_tests/lobby_terminal_bill.dm | 1 + 21 files changed, 2051 insertions(+) create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts create mode 100644 voidcrew/modules/lobby_terminal/_lobby_terminal.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_assets.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_bezel.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_crews.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_housing.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_tgui.dm create mode 100644 voidcrew/modules/lobby_terminal/lobby_yard.dm create mode 100644 voidcrew/modules/unit_tests/lobby_terminal_bill.dm diff --git a/interface/skin.dmf b/interface/skin.dmf index 3b46689c881..c6c56fc9805 100644 --- a/interface/skin.dmf +++ b/interface/skin.dmf @@ -137,6 +137,15 @@ window "mapwindow" saved-params = "" text = "" align = left + elem "lobby_browser" + type = BROWSER + pos = 0,0 + size = 640x480 + anchor1 = 0,0 + anchor2 = 100,100 + is-visible = false + background-color = #0b0b0e + saved-params = "" window "info_and_buttons" elem "info_and_buttons" diff --git a/tgstation.dme b/tgstation.dme index 1938407f825..f64dbcd5cad 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7242,6 +7242,13 @@ #include "voidcrew\modules\lich\lich_spells.dm" #include "voidcrew\modules\lich\lich_thrall.dm" #include "voidcrew\modules\lich\lich_wards.dm" +#include "voidcrew\modules\lobby_terminal\_lobby_terminal.dm" +#include "voidcrew\modules\lobby_terminal\lobby_assets.dm" +#include "voidcrew\modules\lobby_terminal\lobby_bezel.dm" +#include "voidcrew\modules\lobby_terminal\lobby_crews.dm" +#include "voidcrew\modules\lobby_terminal\lobby_housing.dm" +#include "voidcrew\modules\lobby_terminal\lobby_tgui.dm" +#include "voidcrew\modules\lobby_terminal\lobby_yard.dm" #include "voidcrew\modules\loot\abandoned_crate.dm" #include "voidcrew\modules\loot\loot_guns.dm" #include "voidcrew\modules\loot\zone_loot.dm" diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx new file mode 100644 index 00000000000..76795c08df6 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx @@ -0,0 +1,237 @@ +/** + * The terminal's bezel: frame, top strip, deck with the big keys, grille and the tray of + * small lobby buttons. Class tree from the art's lobby_terminal.css: + * + * .lt-bezel (--s) > .lt-screen, .lt-frame > .lt-strip, .lt-deck > .lt-deck-row > + * .lt-keys > .lt-key..., .lt-grille-slot > .lt-grille, .lt-tray > .lt-sbtn... + * + * Without the art (static.bezel null) the same tree is drawn by PLACEHOLDER_CSS. + */ +import { type ReactNode, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import type { LobbyData } from './types'; + +type KeyId = 'join' | 'ready' | 'notready' | 'observe' | 'character' | 'start'; + +const KEY_LABEL: Record = { + join: 'JOIN', + ready: 'READY', + notready: 'READY', + observe: 'OBSERVE', + character: 'CHARACTER', + start: 'START', +}; + +function Key(props: { + id: KeyId; + disabled?: boolean; + title: string; + onClick: () => void; +}) { + const { id, disabled, title, onClick } = props; + return ( + + ); +} + +type SmallId = 'wiki' | 'poll' | 'crew_manifest' | 'settings' | 'changelog'; + +function SmallButton(props: { + id: SmallId; + title: string; + disabled?: boolean; + mark?: boolean; + onClick: () => void; +}) { + const { id, title, disabled, mark, onClick } = props; + const [hover, setHover] = useState(false); + const state = disabled ? '_disabled' : hover ? '_highlighted' : ''; + return ( + + ); +} + +export function Bezel(props: { s: number; children: ReactNode }) { + const { s, children } = props; + const { data, act } = useBackend(); + const art = !!data.bezel; + const preRound = data.phase === 'loading' || data.phase === 'pregame'; + const ready = !!data.ready; + const showReady = preRound || data.phase === 'setup'; + + return ( +
+ {!art && } +
{children}
+
+
+
+
+
+
+ {showReady ? ( + <> + act('ready')} + /> + + + ) : ( + act('join')} + /> + )} + act('observe')} + /> + act('character')} + /> + {data.localhost_admin && preRound ? ( + act('start_now')} + /> + ) : null} +
+
+
+
+
+ act('wiki')} + /> + act('polls')} + /> + act('manifest')} + /> + act('settings')} + /> + act('changelog')} + /> +
+
+
+
+ ); +} + +/** + * Keycap legends are baked into the art; only the placeholder (and the START key, which has + * no art) shows the text legend. + */ +const LEGEND_CSS = ` +.lt-key-legend { display: none; } +.lt-key--start { width: calc(var(--s) * 52px); height: calc(var(--s) * 34px); position: relative; + background: #9aa39f; box-shadow: inset 0 0 0 calc(var(--s) * 2px) #20202a; } +.lt-key--start .lt-key-legend, .lt-ph .lt-key-legend { display: block; position: absolute; + left: calc(var(--s) * 6px); right: calc(var(--s) * 6px); top: calc(var(--s) * 9px); height: calc(var(--s) * 13px); + background: #36373a; color: #7effbf; font-family: 'VCR', monospace; font-size: calc(var(--s) * 9px); + line-height: calc(var(--s) * 13px); text-align: center; letter-spacing: calc(var(--s) * 1px); } +.lt-key--start:hover .lt-key-legend { color: #ffc41f; } +.lt-key-legend { pointer-events: none; } +`; + +export const BEZEL_EXTRA_CSS = LEGEND_CSS; + +/** Stand-in for the art: the same layout in flat lobby colours. */ +const PLACEHOLDER_CSS = ` +.lt-ph.lt-bezel { --s: 1; position: absolute; inset: 0; overflow: hidden; background: #9aa39f; user-select: none; } +.lt-ph .lt-screen { position: absolute; top: calc(var(--s) * 15px); left: calc(var(--s) * 13px); + right: calc(var(--s) * 13px); bottom: calc(var(--s) * 52px); overflow: hidden; z-index: 1; } +.lt-ph .lt-frame { position: absolute; top: calc(var(--s) * 12px); left: calc(var(--s) * 10px); + right: calc(var(--s) * 10px); bottom: calc(var(--s) * 49px); box-sizing: border-box; + border: calc(var(--s) * 3px) solid #51575e; border-bottom-color: #e0e3dc; border-right-color: #e0e3dc; + pointer-events: none; z-index: 2; } +.lt-ph .lt-strip { position: absolute; top: calc(var(--s) * -10px); left: calc(50% - var(--s) * 30px); + width: calc(var(--s) * 61px); height: calc(var(--s) * 6px); background: #36373a; } +.lt-ph .lt-deck { position: absolute; left: 0; right: 0; bottom: 0; height: calc(var(--s) * 49px); z-index: 2; + border-top: calc(var(--s) * 2px) solid #e0e3dc; box-sizing: border-box; } +.lt-ph .lt-deck-row { position: absolute; top: calc(var(--s) * 6px); left: calc(var(--s) * 30px); + right: calc(var(--s) * 29px); display: flex; align-items: flex-start; } +.lt-ph .lt-keys { display: flex; align-items: flex-start; flex: none; } +.lt-ph .lt-key { display: block; flex: none; margin: 0; padding: 0; border: 0; outline: 0; position: relative; + height: calc(var(--s) * 34px); background: #bec4bf; box-shadow: inset 0 0 0 calc(var(--s) * 2px) #20202a; cursor: pointer; } +.lt-ph .lt-key + .lt-key { margin-left: calc(var(--s) * 4px); } +.lt-ph .lt-key--join, .lt-ph .lt-key--ready, .lt-ph .lt-key--notready { width: calc(var(--s) * 68px); } +.lt-ph .lt-key--observe { width: calc(var(--s) * 84px); } +.lt-ph .lt-key--character { width: calc(var(--s) * 100px); } +.lt-ph .lt-key:hover { background: #e0e3dc; } +.lt-ph .lt-key:active { background: #9aa39f; } +.lt-ph .lt-key.is-disabled { cursor: default; background: #9aa39f; } +.lt-ph .lt-key.is-disabled .lt-key-legend { color: #33676a; } +.lt-ph .lt-key--notready .lt-key-legend { color: #ff4f6a; } +.lt-ph .lt-lamp { flex: none; width: calc(var(--s) * 10px); height: calc(var(--s) * 9px); + margin: calc(var(--s) * 11px) calc(var(--s) * 1px) 0 calc(var(--s) * 2px); background: #20202a; } +.lt-ph .lt-lamp + .lt-key { margin-left: 0; } +.lt-ph .lt-lamp--ready { background: #7effbf; } +.lt-ph .lt-lamp--notready { background: #ff4f6a; } +.lt-ph .lt-grille-slot { flex: 1 1 0; min-width: 0; } +.lt-ph .lt-tray { flex: none; position: relative; display: flex; margin-top: calc(var(--s) * 1px); + padding: calc(var(--s) * 3px); width: calc(var(--s) * 142px); height: calc(var(--s) * 31px); + box-sizing: border-box; background: #36373a; } +.lt-ph .lt-sbtn { position: relative; flex: none; margin: 0; padding: 0; border: 0; background: none; + width: calc(var(--s) * 24px); height: calc(var(--s) * 24px); cursor: pointer; } +.lt-ph .lt-sbtn + .lt-sbtn { margin-left: calc(var(--s) * 4px); } +.lt-ph .lt-sbtn-icon, .lt-ph .lt-sbtn-mark { position: absolute; left: 0; top: 0; + transform: scale(var(--s)); transform-origin: 0 0; pointer-events: none; image-rendering: pixelated; } +.lt-ph .lt-sbtn-mark { top: calc(var(--s) * -1px); } +`; diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx new file mode 100644 index 00000000000..81e6a2beacf --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx @@ -0,0 +1,28 @@ +/** + * CREWS tab. STUB from the shell package: package P2 owns and replaces this file. + * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2 + * (rows - 1 is the shell's notice line). data.crews: see types.ts. + */ +import { useBackend } from 'tgui/backend'; + +import { Frame, Put, TOP, useGrid } from './term'; +import type { LobbyData } from './types'; + +export function CrewsTab() { + const { data } = useBackend(); + const { cols, rows } = useGrid(); + const crews = data.crews || []; + return ( + + + NO CREWS ARE SIGNING ON. + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx new file mode 100644 index 00000000000..1eca673b51b --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Header.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { Put, pad, RPut, Rule, useGrid } from './term'; +import type { LobbyData, TabId } from './types'; + +const TABS: [TabId, string][] = [ + ['crews', 'CREWS'], + ['shipyard', 'SHIPYARD'], + ['housing', 'HOUSING'], +]; + +const two = (n: number) => String(Math.floor(n)).padStart(2, '0'); + +function hms(seconds: number) { + const t = Math.max(0, Math.floor(seconds)); + return `${two(t / 3600)}:${two((t % 3600) / 60)}:${two(t % 60)}`; +} + +function ms(seconds: number) { + const t = Math.max(0, Math.floor(seconds)); + return `${two(t / 60)}:${two(t % 60)}`; +} + +/** Seconds since the last server value arrived, ticking once a second. */ +function useElapsed(stamp: number) { + const [since, setSince] = useState(() => Date.now()); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + setSince(Date.now()); + setNow(Date.now()); + }, [stamp]); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + return Math.max(0, (now - since) / 1000); +} + +function clockText(data: LobbyData, elapsed: number): string { + const { phase, clock } = data; + switch (phase) { + case 'playing': + return hms(clock / 10 + elapsed); + case 'loading': + case 'pregame': + if (clock < 0) { + return 'DEPARTURE DELAYED'; + } + return clock > 0 ? `DEPARTURE ${ms(clock - elapsed)}` : 'DEPARTURE SOON'; + case 'setup': + return 'DEPARTING'; + default: + return 'PORT CLOSED'; + } +} + +/** Title bar (row 0), tab bar (row 2) and its rule (row 3). */ +export function Header(props: { tab: TabId; setTab: (tab: TabId) => void }) { + const { tab, setTab } = props; + const { data } = useBackend(); + const { cols, narrow } = useGrid(); + const elapsed = useElapsed(data.clock); + + const title = data.port + ? ` ${data.port.toUpperCase()} : BERTH OFFICE` + : ' BERTH OFFICE'; + let right = clockText(data, elapsed); + if (data.phase !== 'playing' || !narrow) { + right = `${data.online} ONLINE ${right}`; + } + if (data.admin_ready) { + const [ready, admins, adminTotal] = data.admin_ready; + right = `${ready} READY ${admins}/${adminTotal} ADM ${right}`; + } + + let x = 1; + const tabs = TABS.map(([id, label]) => { + const text = ` ${label} `; + const at = x; + x += text.length + 3; + return ( + setTab(id)} + > + {text} + + ); + }); + + return ( + <> + + {pad(title, cols)} + + + {right} + + {tabs} + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx new file mode 100644 index 00000000000..2c393ebbf79 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx @@ -0,0 +1,28 @@ +/** + * HOUSING tab. STUB from the shell package: package P5 owns and replaces this file. + * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2. + * data.outposts: see types.ts. + */ +import { useBackend } from 'tgui/backend'; + +import { Frame, Put, TOP, useGrid } from './term'; +import type { LobbyData } from './types'; + +export function HousingTab() { + const { data } = useBackend(); + const { cols, rows } = useGrid(); + const outposts = data.outposts || []; + return ( + + + NO ROOMS TO LET. + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx new file mode 100644 index 00000000000..90cd0935028 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Notice.tsx @@ -0,0 +1,57 @@ +import { useBackend } from 'tgui/backend'; + +import { Btn, Frame, Put, pad, TOP, useGrid, wrap } from './term'; +import type { LobbyData } from './types'; + +/** The CONFIRMED box (kind "ok"), drawn instead of the tab content. */ +export function Confirmed() { + const { data, act } = useBackend(); + const { cols } = useGrid(); + const notice = data.notice; + if (!notice) { + return null; + } + const w = Math.min(44, cols - 2); + const x = Math.floor(cols / 2) - Math.floor(w / 2); + const lines = wrap(notice.text.toUpperCase(), w - 6).slice(0, 3); + const h = 7 + lines.length; + return ( + + {lines.map((line, i) => ( + + {line} + + ))} + + STAND BY. + + act('dismiss')} + /> + + ); +} + +/** One amber or red line on the bottom row. Click to clear it. */ +export function NoticeLine() { + const { data, act } = useBackend(); + const { cols, rows } = useGrid(); + const notice = data.notice; + if (!notice || notice.kind === 'ok') { + return null; + } + return ( + act('dismiss')} + > + {pad(notice.text.toUpperCase(), cols - 2)} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx new file mode 100644 index 00000000000..b4021a975be --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx @@ -0,0 +1,25 @@ +/** + * SHIPYARD tab. STUB from the shell package: package P4 owns and replaces this file. + * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2. + * The catalog arrives in static data only after `yard_open`. data/static keys: see types.ts. + */ +import { useEffect } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { Frame, Put, TOP, useGrid } from './term'; +import type { LobbyData } from './types'; + +export function YardTab() { + const { act } = useBackend(); + const { cols, rows } = useGrid(); + useEffect(() => { + act('yard_open'); + }, []); + return ( + + + THE YARD IS CLOSED. + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx new file mode 100644 index 00000000000..0bfbbfe45b3 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx @@ -0,0 +1,213 @@ +/** + * Dock terminal lobby. Rendered into the `lobby_browser` control over the map pane, not + * a popup window. + * + * Never wrap this in : Window winsets size and position on Byond.windowId, which + * here is the skin control, and would wreck the map pane. The root is a bare full-viewport + * div that follows the control's size. + * + * UI-local state (tab, selections, pages, fits) lives in React; only actions round-trip. + */ +import { useEffect, useMemo, useState } from 'react'; +import { resolveAsset } from 'tgui/assets'; +import { useBackend } from 'tgui/backend'; + +import { BEZEL_EXTRA_CSS, Bezel } from './Bezel'; +import { CrewsTab } from './CrewsTab'; +import { Header } from './Header'; +import { HousingTab } from './HousingTab'; +import { computeLayout } from './layout'; +import { Confirmed, NoticeLine } from './Notice'; +import { type Grid, GridContext, TERM_CSS } from './term'; +import type { LobbyData, TabId } from './types'; +import { YardTab } from './YardTab'; + +const FONT = 'VCR'; +/** Advance of one character at 1px, for when the font hasn't loaded yet. */ +const FALLBACK_ADVANCE = 0.6; + +function usePaneSize() { + const read = () => ({ + w: document.documentElement.clientWidth || window.innerWidth, + h: document.documentElement.clientHeight || window.innerHeight, + dpr: window.devicePixelRatio || 1, + }); + const [size, setSize] = useState(read); + useEffect(() => { + const update = () => { + const next = read(); + setSize((prev) => + prev.w === next.w && prev.h === next.h && prev.dpr === next.dpr + ? prev + : next, + ); + }; + window.addEventListener('resize', update); + let observer: ResizeObserver | undefined; + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(update); + observer.observe(document.documentElement); + } + return () => { + window.removeEventListener('resize', update); + observer?.disconnect(); + }; + }, []); + return size; +} + +function measureAdvance(family: string): number { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + return FALLBACK_ADVANCE; + } + ctx.font = `100px ${family}`; + const w = ctx.measureText('M'.repeat(50)).width / 50 / 100; + return w > 0.3 && w < 1 ? w : FALLBACK_ADVANCE; +} + +/** Loads VCR OSD Mono from the lobby asset and returns the measured advance. */ +function useTerminalFont() { + const [advance, setAdvance] = useState(FALLBACK_ADVANCE); + useEffect(() => { + let cancelled = false; + let tries = 0; + const load = () => { + const url = resolveAsset('VCR_OSD_Mono.ttf'); + const face = new FontFace(FONT, `url(${url})`); + face + .load() + .then((loaded) => { + if (cancelled) { + return; + } + document.fonts.add(loaded); + setAdvance(measureAdvance(`'${FONT}'`)); + }) + .catch(() => { + // The asset mapping can arrive a moment after the first render. + if (!cancelled && tries++ < 5) { + setTimeout(load, 1000); + } + }); + }; + load(); + return () => { + cancelled = true; + }; + }, []); + return advance; +} + +export function LobbyTerminal() { + const { data, act } = useBackend(); + const pane = usePaneSize(); + const advance = useTerminalFont(); + const layout = useMemo( + () => computeLayout(pane.w, pane.h, pane.dpr, advance), + [pane.w, pane.h, pane.dpr, advance], + ); + const grid: Grid = useMemo( + () => ({ + cols: layout.cols, + rows: layout.rows, + cw: layout.cw, + ch: layout.ch, + s: layout.s, + narrow: layout.narrow, + }), + [layout], + ); + + const [tab, setTab] = useState('crews'); + const request = data.tab_request; + useEffect(() => { + if (request) { + setTab(request.tab); + } + }, [request?.serial]); + + // Report display metrics once the font settles (Windows scaling, pane size), for tuning. + useEffect(() => { + const id = setTimeout( + () => + act('metrics', { + dpr: layout.dpr, + w: pane.w, + h: pane.h, + s: layout.s, + cols: layout.cols, + rows: layout.rows, + }), + 3000, + ); + return () => clearTimeout(id); + }, [advance]); + + const notice = data.notice; + const confirmed = !!notice && notice.kind === 'ok'; + const { s } = layout; + const scan = Math.max(2, Math.round(s)); + + let body; + if (confirmed) { + body = ; + } else if (tab === 'crews') { + body = ; + } else if (tab === 'shipyard') { + body = ; + } else { + body = ; + } + + return ( +
+ + +
+
+ +
{ + setTab(next); + if (confirmed) { + act('dismiss'); + } + }} + /> + {body} + + +
+
+
+
+ +
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts new file mode 100644 index 00000000000..04257aefb65 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/layout.ts @@ -0,0 +1,90 @@ +/** + * Terminal geometry: bezel scale, the screen rect and the text grid, from the pane size. + * + * Bezel: integer pixel-art scale s = clamp(min(floor(H / 360), floor(W / 472)), 1, 4), + * snapped to whole device pixels so Windows display scaling doesn't blur the art. + * Grid: font size aims at about 110 columns, independent of s. + */ + +/** 1x pixel numbers of the bezel (bezel.json). */ +export const BEZEL = { + baseHeight: 360, + minWidth: 472, + screenTop: 15, + screenSide: 13, + screenBottom: 52, + deck: 49, +} as const; + +/** Below this many columns or rows, tabs use their narrow (one panel at a time) layout. */ +export const NARROW_COLS = 96; +export const NARROW_ROWS = 22; + +export type Layout = { + /** CSS pixels per art pixel (a whole number of device pixels). */ + s: number; + dpr: number; + /** Screen rect in CSS px, relative to the pane. */ + screen: { x: number; y: number; w: number; h: number }; + fs: number; + /** Cell size in CSS px. */ + cw: number; + ch: number; + cols: number; + rows: number; + /** Grid origin inside the screen. */ + ox: number; + oy: number; + narrow: boolean; +}; + +const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + +/** + * @param advance width of one character at 1px font size (measured from the real font). + */ +export function computeLayout( + paneW: number, + paneH: number, + dpr: number, + advance: number, +): Layout { + const ratio = dpr > 0 ? dpr : 1; + const sCss = clamp( + Math.min( + Math.floor(paneH / BEZEL.baseHeight), + Math.floor(paneW / BEZEL.minWidth), + ), + 1, + 4, + ); + const sDevice = Math.max(1, Math.round(sCss * ratio)); + const s = sDevice / ratio; + + const sx = BEZEL.screenSide * s; + const sy = BEZEL.screenTop * s; + const sw = Math.max(0, paneW - 2 * sx); + const sh = Math.max(0, paneH - sy - BEZEL.screenBottom * s); + + const fs = clamp(Math.floor(sw / (0.6 * 110)), 11, 24); + const cw = fs * advance; + const ch = Math.round(fs * 1.2); + const cols = Math.max(20, Math.floor((sw - 2 * cw) / cw)); + const rows = Math.max(8, Math.floor((sh - ch) / ch)); + const ox = Math.floor((sw - cols * cw) / 2); + const oy = Math.floor((sh - rows * ch) / 2); + return { + s, + dpr: ratio, + screen: { x: sx, y: sy, w: sw, h: sh }, + fs, + cw, + ch, + cols, + rows, + ox, + oy, + narrow: cols < NARROW_COLS || rows < NARROW_ROWS, + }; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx new file mode 100644 index 00000000000..8cb04e6070c --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx @@ -0,0 +1,316 @@ +/** + * Text-grid primitives for the dock terminal, ported from the approved mockup. + * + * Everything on the screen is placed on a cell grid: x is a column, y a row (fractions are + * allowed, as in the mockup). Tabs draw only through these, so every tab matches the + * mockup look without touching the shell. + * + * Colour classes: '' (phosphor), 'dim', 'faint', 'amb', 'red', 'inv' (inverse phosphor), + * 'inva' (inverse amber). + */ +import { + type CSSProperties, + createContext, + type ReactNode, + useContext, + useState, +} from 'react'; + +export type Grid = { + cols: number; + rows: number; + /** Cell size in CSS px. */ + cw: number; + ch: number; + /** Bezel pixel scale; line weights follow it. */ + s: number; + /** Too small for side-by-side panels: use one panel at a time with [ BACK ]. */ + narrow: boolean; +}; + +export const GridContext = createContext({ + cols: 110, + rows: 30, + cw: 9, + ch: 19, + s: 2, + narrow: false, +}); + +/** {cols, rows, cw, ch, s, narrow} of the current screen. */ +export const useGrid = () => useContext(GridContext); + +/** Rows used by the header and tab bar. Tab content starts here. */ +export const TOP = 4; + +export type Cls = '' | 'dim' | 'faint' | 'amb' | 'red' | 'inv' | 'inva'; + +const cx = (cls?: string) => (cls ? cls : undefined); + +type PutProps = { + x: number; + y: number; + /** Colour class. */ + cls?: Cls | string; + children: ReactNode; + onClick?: () => void; + /** Class while hovered (clickable keys light up). */ + hoverCls?: Cls | string; + /** Paint the terminal background behind the text (over frame lines). */ + bg?: boolean; + title?: string; +}; + +/** Text at a cell. */ +export function Put(props: PutProps) { + const { x, y, cls, children, onClick, hoverCls, bg, title } = props; + const { cw, ch } = useGrid(); + const [hover, setHover] = useState(false); + const style: CSSProperties = { + left: Math.round(x * cw), + top: Math.round(y * ch), + }; + if (bg) { + style.background = 'var(--lt-bg)'; + } + if (onClick) { + style.cursor = 'pointer'; + style.zIndex = 5; + } + const shown = hover && hoverCls ? hoverCls : cls; + return ( + { + e.stopPropagation(); + onClick(); + } + : undefined + } + onMouseEnter={hoverCls ? () => setHover(true) : undefined} + onMouseLeave={hoverCls ? () => setHover(false) : undefined} + > + {children} + + ); +} + +/** Text ending just before column x (right-aligned). */ +export function RPut(props: PutProps & { children: string }) { + return ; +} + +type BtnProps = { + x: number; + y: number; + label: string; + /** Right-align: the button ends just before column x. */ + right?: boolean; + /** Lit (inverse) at rest, like the mockup's primary keys. */ + lit?: boolean; + /** Amber variant (CODE / APPLY rows). */ + amber?: boolean; + disabled?: boolean; + onClick?: () => void; +}; + +/** A `[ LABEL ]` key on the grid. Disabled keys draw faint and ignore clicks. */ +export function Btn(props: BtnProps) { + const { x, y, label, right, lit, amber, disabled, onClick } = props; + const text = `[ ${label} ]`; + const at = right ? x - text.length : x; + if (disabled) { + return ( + + {text} + + ); + } + const rest = lit ? (amber ? 'inva' : 'inv') : amber ? 'amb' : ''; + const hover = amber ? 'inva' : 'inv'; + return ( + + {text} + + ); +} + +type FrameProps = { + x: number; + y: number; + w: number; + h: number; + title?: string; + /** Dim border. */ + dim?: boolean; + children?: ReactNode; +}; + +/** Double-line box from cell (x, y), w columns by h rows, with an optional title. */ +export function Frame(props: FrameProps) { + const { x, y, w, h, title, dim, children } = props; + const { cw, ch, s } = useGrid(); + const bw = Math.max(3, Math.round(s) + 1); + const style: CSSProperties = { + borderWidth: bw, + left: x * cw + cw / 2 - bw / 2, + top: y * ch + ch / 2 - bw / 2, + width: Math.max(0, (w - 1) * cw - bw), + height: Math.max(0, (h - 1) * ch - bw), + }; + return ( + <> +
+ {!!title && ( + + {` ${title} `} + + )} + {children} + + ); +} + +/** Horizontal rule through the middle of row y. */ +export function Rule(props: { x: number; y: number; w: number }) { + const { x, y, w } = props; + const { cw, ch, s } = useGrid(); + return ( +
+ ); +} + +/** n filled cells (block characters drawn as boxes on the grid). frac = height share of a row. */ +export function Blocks(props: { + x: number; + y: number; + n: number; + cls?: '' | 'dim' | 'faint' | 'ablk' | 'rblk'; + frac?: number; +}) { + const { x, y, n, cls, frac = 0.7 } = props; + const { cw, ch } = useGrid(); + return ( +
+ ); +} + +/** Invisible click target over a block of cells (whole list rows). */ +export function Hit(props: { + x: number; + y: number; + w: number; + h: number; + onClick: () => void; + onHover?: (over: boolean) => void; +}) { + const { x, y, w, h, onClick, onHover } = props; + const { cw, ch } = useGrid(); + return ( +
onHover(true) : undefined} + onMouseLeave={onHover ? () => onHover(false) : undefined} + /> + ); +} + +/** Pads or cuts to n characters; a cut ends in '.'. */ +export function pad(str: string | number, n: number): string { + const s = String(str); + if (n <= 0) { + return ''; + } + return s.length > n ? `${s.slice(0, n - 1)}.` : s + ' '.repeat(n - s.length); +} + +/** Left-pads to n characters. */ +export function lpad(str: string | number, n: number): string { + const s = String(str); + return ' '.repeat(Math.max(0, n - s.length)) + s; +} + +/** Word-wraps to lines of at most w characters. */ +export function wrap(text: string, w: number): string[] { + const out: string[] = []; + let line = ''; + for (const word of text.split(' ')) { + if (`${line} ${word}`.trim().length > w) { + if (line.trim()) { + out.push(line.trim()); + } + line = word; + } else { + line += ` ${word}`; + } + } + if (line.trim()) { + out.push(line.trim()); + } + return out; +} + +/** Terminal CSS. Scoped under .lt-tui so the short class names can't leak. */ +export const TERM_CSS = ` +.lt-crt { position: absolute; inset: 0; overflow: hidden; + background: radial-gradient(ellipse at 50% 45%, #0c2018 0%, var(--lt-bg) 60%, #040a07 100%); } +.lt-tui { --lt-ph: #7effbf; --lt-dim: #3f9a76; --lt-faint: #2b6450; --lt-amb: #ffc41f; --lt-red: #ff5f6f; + position: absolute; font-family: 'VCR', 'Consolas', monospace; color: var(--lt-ph); white-space: pre; + text-shadow: 0 0 3px rgba(126,255,191,.55), 0 0 9px rgba(126,255,191,.18); user-select: none; } +.lt-tui span { position: absolute; } +.lt-tui .dim { color: var(--lt-dim); text-shadow: 0 0 3px rgba(63,154,118,.45); } +.lt-tui .faint { color: var(--lt-faint); text-shadow: none; } +.lt-tui .amb { color: var(--lt-amb); text-shadow: 0 0 3px rgba(255,196,31,.55), 0 0 9px rgba(255,196,31,.2); } +.lt-tui .red { color: var(--lt-red); text-shadow: 0 0 3px rgba(255,95,111,.55), 0 0 9px rgba(255,95,111,.2); } +.lt-tui .inv { background: var(--lt-ph); color: var(--lt-bg); text-shadow: 0 0 6px rgba(126,255,191,.6); box-shadow: 0 0 8px rgba(126,255,191,.35); } +.lt-tui .inva { background: var(--lt-amb); color: #160f02; box-shadow: 0 0 8px rgba(255,196,31,.35); text-shadow: none; } +.lt-tui .lt-box { position: absolute; border-style: double; border-color: var(--lt-ph); + box-shadow: 0 0 5px rgba(126,255,191,.35), inset 0 0 5px rgba(126,255,191,.18); } +.lt-tui .lt-box.dimb { border-color: var(--lt-dim); box-shadow: 0 0 4px rgba(63,154,118,.3); } +.lt-tui .lt-rule { position: absolute; border-top-style: solid; border-color: var(--lt-faint); } +.lt-tui .lt-blk { position: absolute; background: var(--lt-ph); box-shadow: 0 0 6px rgba(126,255,191,.5); } +.lt-tui .lt-blk.dim { background: var(--lt-dim); box-shadow: none; } +.lt-tui .lt-blk.ablk { background: var(--lt-amb); box-shadow: 0 0 6px rgba(255,196,31,.5); } +.lt-tui .lt-blk.rblk { background: var(--lt-red); box-shadow: 0 0 6px rgba(255,95,111,.5); } +.lt-tui .lt-blk.faint { background: var(--lt-faint); box-shadow: none; } +.lt-glass { position: absolute; inset: 0; z-index: 2; pointer-events: none; + background: radial-gradient(ellipse at 28% 18%, rgba(255,255,255,.045), transparent 38%), + radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.55) 100%); } +.lt-scan { position: absolute; inset: 0; z-index: 2; pointer-events: none; } +.lt-vscreen { position: absolute; box-sizing: border-box; overflow: hidden; background: #050607; + border: 1px solid #1d3a2e; outline: 3px solid #0a1410; outline-offset: 0; + box-shadow: 0 0 0 4px #1d3a2e, inset 0 0 18px rgba(0,0,0,.9), inset 0 2px 6px rgba(0,0,0,.8); } +.lt-vscan { position: absolute; inset: 0; pointer-events: none; z-index: 3; + background: repeating-linear-gradient(to bottom, rgba(0,0,0,.10) 0 1px, transparent 1px 3px), + radial-gradient(ellipse at 30% 15%, rgba(255,255,255,.04), transparent 45%); + box-shadow: inset 0 0 14px rgba(0,0,0,.75); } +`; diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts new file mode 100644 index 00000000000..c7c785fe236 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts @@ -0,0 +1,269 @@ +/** + * Data contract for the dock terminal lobby (DM: voidcrew/modules/lobby_terminal/). + * + * Shared keys are fixed. Section owners may ADD optional keys in their own section; never + * rename a shared key (a drifted key reads as undefined, i.e. "off", forever). + * + * DM has no booleans: every flag arrives as 0/1 (BooleanLike). Never write `flag && `. + */ +import type { BooleanLike } from 'tgui-core/react'; + +// --------------------------------------------------------------------------- +// Shell (2.1): header, bezel, notice. Owner: P1. +// --------------------------------------------------------------------------- + +export type Phase = 'loading' | 'pregame' | 'setup' | 'playing' | 'ended'; + +export type NoticeKind = 'ok' | 'warn' | 'bad'; + +export type Notice = { + text: string; + /** ok: the CONFIRMED box. warn: one amber line. bad: one red line. */ + kind: NoticeKind; + /** world.time when it was set. */ + at: number; +}; + +export type TabId = 'crews' | 'shipyard' | 'housing'; + +/** bezel.json from the art package (all numbers are 1x pixels). Null: placeholder bezel. */ +export type BezelSpec = { + scale?: { min_width?: number; base_height?: number }; + [key: string]: unknown; +}; + +export type ShellData = { + phase: Phase; + /** playing: deciseconds since round start. loading/pregame: countdown seconds, -1 = delayed. Else 0. */ + clock: number; + online: number; + ready: BooleanLike; + prefs_ready: BooleanLike; + can_observe: BooleanLike; + /** 0 none or disabled, 1 open polls, 2 new polls. */ + polls: 0 | 1 | 2; + /** Position in the popcap queue, 0 when not queued. */ + queue: number; + /** Admins before the round: [players ready, admins ready, admins]. Null otherwise. */ + admin_ready: [number, number, number] | null; + notice: Notice | null; + /** The server asks the client to switch tab; act on each new serial. */ + tab_request: { tab: TabId; serial: number } | null; +}; + +export type ShellStatic = { + wiki: BooleanLike; + localhost_admin: BooleanLike; + /** Port name for the header, or null (header reads "BERTH OFFICE"). */ + port: string | null; + bezel: BezelSpec | null; +}; + +// --------------------------------------------------------------------------- +// CREWS (2.2). Owner: P2. +// --------------------------------------------------------------------------- + +export type CrewJob = { + /** REF(job), a key of ship.job_slots. */ + ref: string; + title: string; + /** Open slots. */ + open: number; + /** FALSE when banned or short on playtime: SIGN ON greys out. */ + ok: BooleanLike; +}; + +export type Crew = { + ref: string; + name: string; + /** Hull class (source_template.short_name). */ + cls: string; + /** Crew aboard (length(manifest)). */ + crew: number; + memo: string; + locked: BooleanLike; + cleared: BooleanLike; + applied: BooleanLike; + /** Crew-only airlocks. */ + doors: BooleanLike; + /** Seconds until PING CREW is allowed again. */ + ping_wait: number; + /** Seconds until a cryo rejoin is allowed. */ + rejoin_wait: number; + /** Seconds left on a wrong-code lockout. */ + code_wait: number; + jobs: CrewJob[]; +}; + +export type CrewsData = { + crews?: Crew[]; +}; + +// --------------------------------------------------------------------------- +// SHIPYARD (2.3). Owners: P3 (DM), P4 (UI). +// --------------------------------------------------------------------------- + +export type PartClass = 'combat' | 'science' | 'trade' | 'misc'; +export type Parts = Partial>; +/** [job title, slots] */ +export type CrewLine = [string, number]; + +export type Finish = { + id: string; + name: string; + desc: string; + cost: Parts; + def: BooleanLike; + /** Slot keys this finish offers. */ + slots: string[]; + jobs: CrewLine[]; +}; + +export type HullSlot = { + key: string; + label: string; +}; + +export type Module = { + id: string; + name: string; + desc: string; + slot: string; + cost: Parts; + def: BooleanLike; + /** Finish ids this module is offered with, or null for every finish. */ + themes: string[] | null; + /** Crew per finish id ("" = any finish). */ + jobs: Record; +}; + +export type Hull = { + /** "[type]" */ + id: string; + name: string; + short: string; + desc: string; + cost: Parts; + free: BooleanLike; + finishes: Finish[]; + slots: HullSlot[]; + modules: Module[]; + /** build_ship_preview_data(hull), unchanged. */ + preview: unknown; +}; + +export type Owned = { + hulls: string[]; + finishes: Record; + modules: Record; +}; + +/** Saved ship card (2.5). Field names follow the mockup; the ship-saving PR maps to them. */ +export type SaveCard = { + slot: number; + name: string; + /** Class line, e.g. "Goon-class Scout". */ + class: string; + mass?: number; + crew?: number; + /** Why it can't load now, or null. */ + denial: string | null; + /** UNDER WAY: the original hull is still in service. */ + locked: BooleanLike; + funds?: number; + research?: number; + /** Encoded floor plan for the line drawing. */ + plan?: string; + /** [label, count, colour class] */ + fit?: [string, number, string][]; + lab?: number; + cargo?: number; +}; + +export type YardData = { + account?: Parts | null; + owned?: Owned | null; + free_ships?: BooleanLike; + can_requisition?: BooleanLike; + /** Name of the crew the player would leave by founding a new one, or null. */ + crew_change?: string | null; + /** Saved ships, null entries for empty slots. Null hides the SAVED SHIPS box. Always sent by the shell. */ + saves: (SaveCard | null)[] | null; +}; + +export type YardStatic = { + /** Only after yard_open. */ + catalog?: Hull[] | null; +}; + +// --------------------------------------------------------------------------- +// HOUSING (2.4). Owner: P5. +// --------------------------------------------------------------------------- + +export type OutpostMode = 'open' | 'password' | 'approved' | 'closed'; + +export type Outpost = { + ref: string; + name: string; + mode: OutpostMode; + residents: number; + cleared: BooleanLike; + /** Short in-character reason MOVE IN is refused, or null. */ + denial: string | null; + code_wait?: number; +}; + +export type HousingData = { + outposts?: Outpost[]; +}; + +// --------------------------------------------------------------------------- + +export type LobbyData = ShellData & + ShellStatic & + CrewsData & + YardData & + YardStatic & + HousingData; + +/** + * Every action and its params. Shell actions are handled in lobby_bezel.dm; prefixed ones + * go to their section (crew_* P2, yard_* P3, home_* P5). + */ +export type LobbyActions = { + join: Record; + ready: Record; + observe: Record; + character: Record; + settings: Record; + wiki: Record; + polls: Record; + manifest: Record; + changelog: Record; + start_now: Record; + dismiss: Record; + /** Client display metrics, logged once per load. */ + metrics: { + dpr: number; + w: number; + h: number; + s: number; + cols: number; + rows: number; + }; + crew_sign_on: { ship: string; job: string }; + crew_code: { ship: string; code: string }; + crew_apply: { ship: string; note: string }; + crew_ping: { ship: string }; + yard_open: Record; + yard_preview: { hull: string }; + yard_commission: { + hull: string; + finish: string; + modules: Record; + leave_crew: 0 | 1; + }; + yard_requisition: { leave_crew: 0 | 1 }; + yard_load: { slot: number; leave_crew: 0 | 1 }; + home_move_in: { outpost: string; code?: string }; +}; diff --git a/voidcrew/modules/lobby_terminal/_lobby_terminal.dm b/voidcrew/modules/lobby_terminal/_lobby_terminal.dm new file mode 100644 index 00000000000..581087f6744 --- /dev/null +++ b/voidcrew/modules/lobby_terminal/_lobby_terminal.dm @@ -0,0 +1,324 @@ +/** + * # Dock terminal lobby + * + * The new-player lobby drawn as a dock terminal: tgui rendered into the `lobby_browser` + * BROWSER control that sits over the map pane (interface/skin.dmf, window "mapwindow"). + * + * One /datum/lobby_terminal per client. It owns the unpooled tgui_window bound to that + * control, the open /datum/tgui, the per-client notice and cached DB values. + * + * The classic HUD lobby (/datum/hud/new_player) is still built underneath, unchanged. If + * the browser never comes up (for example during a 516 resource download stall), the + * control stays hidden and the classic HUD keeps working. + * + * Sections: the shell (this file, lobby_tgui.dm, lobby_bezel.dm) declares every section + * proc below. Section owners override them in their own file without the `proc/` keyword: + * lobby_crews.dm (crew_* actions), lobby_yard.dm (yard_* actions) and lobby_housing.dm + * (home_* actions). Those files sort after this one, so their overrides win. + */ + +/// How long to wait for the browser to report itself visible before offering a reload link. +#define LOBBY_TERMINAL_LOAD_TIMEOUT (30 SECONDS) +/// Wrong codes allowed per ckey per lock (a ship or an outpost) before a lockout. +#define LOBBY_CODE_MAX_FAILURES 5 +/// How long a code lockout lasts. +#define LOBBY_CODE_LOCKOUT (60 SECONDS) +/// Lockouts in one round for one ckey before admins are told. +#define LOBBY_CODE_ADMIN_NOTICE_LOCKOUTS 3 + +/client/var/datum/lobby_terminal/lobby_terminal + +/// Code failures keyed "[ckey]|[lock key]" = list(failures, locked_until). Keyed by ckey, so a reconnect does not reset it. +GLOBAL_LIST_EMPTY(lobby_code_failures) +/// Lockouts this round per ckey, for the admin notice. +GLOBAL_LIST_EMPTY(lobby_code_lockouts) +/// Ckeys with a join route in flight (sign on, commission, requisition, load, move in). Keyed by ckey so a reconnect cannot double it. +GLOBAL_LIST_EMPTY(lobby_join_locks) + +/datum/lobby_terminal + /// The client this terminal belongs to. + var/client/owner + /// Unpooled tgui window bound to the lobby_browser control. + var/datum/tgui_window/window + /// The open tgui, if any. + var/datum/tgui/lobby_terminal/ui + /// TRUE once the browser has reported itself visible since the last show(). + var/visible = FALSE + /// Current notice: list("text", "kind" = "ok"/"warn"/"bad", "at" = world.time), or null. + var/list/notice + /// Cached poll lamp: 0 none or disabled, 1 open polls, 2 new polls. Filled async at show(). + var/polls_state = 0 + /// Tab the shell asks the client to switch to, and a serial so the client can tell a repeat request apart. + var/tab_request + var/tab_request_serial = 0 + /// Load timeout timer id. + var/load_timer + /// The skin.dmf BROWSER control the terminal renders into. + var/control_id = "lobby_browser" + +/datum/lobby_terminal/New(client/owner) + . = ..() + src.owner = owner + window = new /datum/tgui_window(owner, control_id) + RegisterSignal(window, COMSIG_TGUI_WINDOW_VISIBLE, PROC_REF(on_window_visible)) + RegisterSignal(owner, COMSIG_CLIENT_MOB_LOGIN, PROC_REF(on_mob_login)) + +/datum/lobby_terminal/Destroy() + if(owner) + UnregisterSignal(owner, COMSIG_CLIENT_MOB_LOGIN) + if(owner.lobby_terminal == src) + owner.lobby_terminal = null + if(window) + UnregisterSignal(window, COMSIG_TGUI_WINDOW_VISIBLE) + if(ui) + ui.close(can_be_suspended = FALSE) + ui = null + window = null + owner = null + return ..() + +/// TRUE when the terminal is up and drawing over the map for this client. +/datum/lobby_terminal/proc/is_active() + return ui && !QDELETED(ui) && visible + +/// Opens the terminal over the map. Safe to call again; it reuses an open UI. +/datum/lobby_terminal/proc/show(mob/dead/new_player/user) + if(!owner || !istype(user) || user.client != owner) + return + if(!ui || QDELETED(ui)) + visible = FALSE + refresh_polls(user) + ui_interact(user) + if(load_timer) + deltimer(load_timer) + load_timer = addtimer(CALLBACK(src, PROC_REF(on_load_timeout)), LOBBY_TERMINAL_LOAD_TIMEOUT, TIMER_STOPPABLE) + +/// show() one tick later, after the client constructor has finished (as the chat panel does). +/datum/lobby_terminal/proc/show_deferred(mob/dead/new_player/user) + set waitfor = FALSE + sleep(1 TICKS) + if(QDELETED(src) || QDELETED(user) || !owner || user.client != owner) + return + show(user) + +/// Takes the terminal off the map and closes its UI. The classic HUD (or the game) shows again. +/datum/lobby_terminal/proc/hide() + visible = FALSE + if(load_timer) + deltimer(load_timer) + load_timer = null + if(ui && !QDELETED(ui)) + ui.close(can_be_suspended = FALSE) // ui_close() below does the skin work + else + hide_control() + +/// Skin side of hiding: control off, status bar back. +/datum/lobby_terminal/proc/hide_control() + if(!owner) + return + winset(owner, control_id, "is-visible=false") + winset(owner, "mapwindow.status_bar", "is-visible=true") + +/datum/lobby_terminal/proc/on_window_visible(datum/source, client/client) + SIGNAL_HANDLER + if(!ui || QDELETED(ui) || !istype(owner?.mob, /mob/dead/new_player)) + hide_control() + return + visible = TRUE + if(load_timer) + deltimer(load_timer) + load_timer = null + winset(owner, null, list( + "[control_id].is-visible" = "true", + "mapwindow.status_bar.is-visible" = "false", + )) + +/// Any mob change. A new_player Login shows the terminal itself (lobby_bezel.dm); anything else hides it. +/datum/lobby_terminal/proc/on_mob_login(datum/source, mob/new_mob) + SIGNAL_HANDLER + if(istype(new_mob, /mob/dead/new_player)) + return + hide() + +/datum/lobby_terminal/proc/on_load_timeout() + load_timer = null + if(visible || !owner || !istype(owner.mob, /mob/dead/new_player)) + return + log_tgui(owner, "Lobby terminal did not report visible in time", window = window) + to_chat(owner, span_warning("The dock terminal didn't load. Try again.")) + +/datum/lobby_terminal/Topic(href, list/href_list) + . = ..() + if(!href_list["lobby_reload"] || !owner || usr?.client != owner) + return + var/mob/dead/new_player/player = owner.mob + if(!istype(player)) + return + if(ui && !QDELETED(ui)) + ui.close(can_be_suspended = FALSE) + show(player) + +/// Sets the notice line. kind is "ok" (the CONFIRMED box), "warn" (amber line) or "bad" (red line). Null text clears it. +/datum/lobby_terminal/proc/set_notice(text, kind = "ok") + if(!text) + notice = null + else + notice = list("text" = "[text]", "kind" = kind, "at" = world.time) + if(ui && !QDELETED(ui)) + SStgui.update_uis(src) + +/// Asks the client to switch tabs ("crews", "shipyard", "housing"). +/datum/lobby_terminal/proc/goto_tab(tab) + tab_request = tab + tab_request_serial++ + if(ui && !QDELETED(ui)) + SStgui.update_uis(src) + +/// Runs the new-poll query once, off the UI tick, and caches the lamp state. +/datum/lobby_terminal/proc/refresh_polls(mob/dead/new_player/user) + set waitfor = FALSE + polls_state = 0 + if(!user || is_guest_key(user.key) || !SSdbcore.Connect()) + return + var/player_ckey = user.ckey + var/datum/db_query/query_get_new_polls = SSdbcore.NewQuery({" + SELECT id FROM [format_table_name("poll_question")] + WHERE (adminonly = 0 OR :isadmin = 1) + AND Now() BETWEEN starttime AND endtime + AND deleted = 0 + AND id NOT IN ( + SELECT pollid FROM [format_table_name("poll_vote")] + WHERE ckey = :ckey + AND deleted = 0 + ) + AND id NOT IN ( + SELECT pollid FROM [format_table_name("poll_textreply")] + WHERE ckey = :ckey + AND deleted = 0 + ) + "}, list("isadmin" = user.client?.holder ? TRUE : FALSE, "ckey" = player_ckey)) + if(!query_get_new_polls.Execute()) + qdel(query_get_new_polls) + return + var/has_new = query_get_new_polls.NextRow() + qdel(query_get_new_polls) + if(QDELETED(src)) + return + polls_state = has_new ? 2 : 1 + +// --------------------------------------------------------------------------- +// Shared helpers for every section +// --------------------------------------------------------------------------- + +/** + * Name of the crew the player would leave by founding a new one (commission, requisition, + * saved ship), or null when nothing would change. Never prompts. + */ +/datum/lobby_terminal/proc/crew_change_name(mob/user) + var/datum/voidcrew_crew_record/record = user?.mind?.recover_reputation_crew() + if(!record || record.leader == user.mind) + return null + for(var/datum/team/voidcrew/team in record.teams) + return team.name + return "your crew" + +/// Seconds left on a code lockout for this ckey and lock key (a ship or outpost ref), 0 when none. +/datum/lobby_terminal/proc/code_lock_wait(ckey, key) + var/list/entry = GLOB.lobby_code_failures["[ckey]|[key]"] + if(!entry) + return 0 + var/left = entry[2] - world.time + return left > 0 ? CEILING(left / 10, 1) : 0 + +/// Records a wrong code. Returns TRUE when this failure started a lockout. +/datum/lobby_terminal/proc/note_code_failure(ckey, key) + var/entry_key = "[ckey]|[key]" + var/list/entry = GLOB.lobby_code_failures[entry_key] + if(!entry) + entry = list(0, 0) + GLOB.lobby_code_failures[entry_key] = entry + entry[1]++ + log_game("[key_name(owner)] entered a wrong lobby code for [key] ([entry[1]] in a row)") + if(entry[1] < LOBBY_CODE_MAX_FAILURES) + return FALSE + entry[1] = 0 + entry[2] = world.time + LOBBY_CODE_LOCKOUT + var/lockouts = (GLOB.lobby_code_lockouts[ckey] || 0) + 1 + GLOB.lobby_code_lockouts[ckey] = lockouts + log_game("[key_name(owner)] was locked out of lobby codes for [key] (lockout [lockouts] this round)") + if(lockouts >= LOBBY_CODE_ADMIN_NOTICE_LOCKOUTS) + message_admins("[key_name_admin(owner)] has been locked out of lobby join codes [lockouts] times this round.") + return TRUE + +/// Clears the failure count after a right code, or for every ckey when a lock's code changes (ckey null). +/datum/lobby_terminal/proc/clear_code_failures(ckey, key) + if(ckey) + GLOB.lobby_code_failures -= "[ckey]|[key]" + return + var/suffix = "|[key]" + for(var/entry_key in GLOB.lobby_code_failures.Copy()) + if(copytext(entry_key, -length(suffix)) == suffix) + GLOB.lobby_code_failures -= entry_key + +/** + * Takes the per-ckey join lock shared by every join route. FALSE when the player is already + * spawning or another route holds the lock. Every caller must release_join_lock() on every path. + */ +/datum/lobby_terminal/proc/try_join_lock(mob/dead/new_player/user) + if(!istype(user) || !user.ckey) + return FALSE + if(user.spawning || user.spawning_ship || GLOB.lobby_join_locks[user.ckey]) + return FALSE + GLOB.lobby_join_locks[user.ckey] = world.time + return TRUE + +/datum/lobby_terminal/proc/release_join_lock(ckey) + GLOB.lobby_join_locks -= ckey + +// --------------------------------------------------------------------------- +// Section seams. Empty here; each owner overrides in its own file. +// Data procs return an assoc list merged into ui_data (or null). Act procs get the full +// action name and return TRUE when the UI should update. +// --------------------------------------------------------------------------- + +/// CREWS (lobby_crews.dm): data.crews. +/datum/lobby_terminal/proc/crews_data(mob/user) + return null + +/// CREWS actions: crew_sign_on, crew_code, crew_apply, crew_ping. +/datum/lobby_terminal/proc/crews_act(action, list/params, mob/user) + return FALSE + +/// SHIPYARD (lobby_yard.dm): data.account, owned, free_ships, can_requisition, crew_change. +/datum/lobby_terminal/proc/yard_data(mob/user) + return null + +/// SHIPYARD static data: static.catalog, only after yard_open. +/datum/lobby_terminal/proc/yard_static(mob/user) + return null + +/// SHIPYARD actions: yard_open, yard_preview, yard_commission, yard_requisition, yard_load. +/datum/lobby_terminal/proc/yard_act(action, list/params, mob/user) + return FALSE + +/// HOUSING (lobby_housing.dm): data.outposts. +/datum/lobby_terminal/proc/housing_data(mob/user) + return null + +/// HOUSING actions: home_move_in. +/datum/lobby_terminal/proc/housing_act(action, list/params, mob/user) + return FALSE + +/// Saved ships hook (feature/ship-saving overrides it): list of save cards (null entries for empty slots), or null to hide the box. +/datum/lobby_terminal/proc/saved_ships_data() + return null + +/// Saved ships hook: loads the save in that slot. form_new is the crew-leave answer. +/datum/lobby_terminal/proc/load_saved_ship(slot, form_new) + set_notice("Shipyard records are offline.", "warn") + return FALSE + +#undef LOBBY_TERMINAL_LOAD_TIMEOUT +#undef LOBBY_CODE_MAX_FAILURES +#undef LOBBY_CODE_LOCKOUT +#undef LOBBY_CODE_ADMIN_NOTICE_LOCKOUTS diff --git a/voidcrew/modules/lobby_terminal/lobby_assets.dm b/voidcrew/modules/lobby_terminal/lobby_assets.dm new file mode 100644 index 00000000000..fc30d5acdfe --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_assets.dm @@ -0,0 +1,35 @@ +/** + * Terminal art and font. Runtime string paths, not 'compiled' resources, so the art stays + * out of the compiled RSC and the external RSC zip and arrives with the page. + * + * Only files that exist are registered: the bezel art (html/, drawn separately) can be + * missing, and then the client draws its placeholder bezel. + */ +/datum/asset/simple/namespaced/lobby_terminal + +/datum/asset/simple/namespaced/lobby_terminal/register() + var/art_dir = "voidcrew/modules/lobby_terminal/html/" + assets = list() + if(fexists("interface/fonts/VCR_OSD_Mono.ttf")) + assets["VCR_OSD_Mono.ttf"] = "interface/fonts/VCR_OSD_Mono.ttf" + var/list/art = list("frame_9slice.png", "strip_top.png", "deck.png", "grille.png", "tray.png", "lamp_ready.png", "lamp_notready.png", "lamp_off.png") + for(var/key in list("join", "ready", "notready", "observe", "character")) + for(var/key_state in list("idle", "hover", "pressed", "disabled")) + art += "key_[key]_[key_state].png" + for(var/name in art) + if(fexists("[art_dir][name]")) + assets[name] = "[art_dir][name]" + parents = list() + if(fexists("[art_dir]lobby_terminal.css")) + parents["lobby_terminal.css"] = "[art_dir]lobby_terminal.css" + return ..() + +/// The bezel's small buttons, from the real lobby sprites so a sprite change reaches the terminal too. +/datum/asset/spritesheet_batched/lobby_terminal_buttons + name = "lobbyterminal" + +/datum/asset/spritesheet_batched/lobby_terminal_buttons/create_spritesheets() + for(var/button in list("wiki", "poll", "crew_manifest", "settings", "changelog")) + for(var/suffix in list("", "_highlighted", "_disabled")) + insert_icon("lt-[button][suffix]", uni_icon('icons/hud/lobby/bottom_buttons.dmi', "[button][suffix]")) + insert_icon("lt-new_poll", uni_icon('icons/hud/lobby/poll_overlay.dmi', "new_poll")) diff --git a/voidcrew/modules/lobby_terminal/lobby_bezel.dm b/voidcrew/modules/lobby_terminal/lobby_bezel.dm new file mode 100644 index 00000000000..94c323d8cce --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_bezel.dm @@ -0,0 +1,178 @@ +/** + * Terminal shell: the header data, the bezel keys and buttons, and the hooks that put the + * terminal up on a new_player Login. + * + * Every bezel action does what the matching classic HUD button does + * (code/_onclick/hud/new_player.dm). The HUD stays underneath as the fallback. + */ + +/// Hide the control before anything else on connect. Whether Dream Seeker keeps skin +/// visibility across a reconnect is not known, so never trust it. +/client/New(TopicData) + winset(src, "lobby_browser", "is-visible=false") + return ..() + +/mob/dead/new_player/Login() + . = ..() + if(!client || client.interviewee) + return + if(!client.lobby_terminal) + client.lobby_terminal = new /datum/lobby_terminal(client) + client.lobby_terminal.show_deferred(src) + +/// The terminal is the join menu: when it is up, the old join paths land on CREWS instead. +/mob/dead/new_player/select_ship() + var/datum/lobby_terminal/terminal = client?.lobby_terminal + if(terminal?.is_active()) + terminal.goto_tab("crews") + return + return ..() + +/// Art spec from the bezel art's bezel.json, or null for the placeholder bezel. +/proc/lobby_terminal_bezel_spec() + var/static/list/spec + var/static/checked = FALSE + if(checked) + return spec + checked = TRUE + var/path = "voidcrew/modules/lobby_terminal/html/bezel.json" + if(!fexists(path)) + return null + var/list/decoded = json_decode(file2text(path)) + spec = islist(decoded) ? decoded : null + return spec + +/datum/lobby_terminal/proc/shell_data(mob/dead/new_player/user, list/data) + var/phase + var/clock = 0 + if(SSticker.IsPostgame()) + phase = "ended" + else + switch(SSticker.current_state) + if(GAME_STATE_STARTUP) + phase = "loading" + if(GAME_STATE_PREGAME) + phase = "pregame" + if(GAME_STATE_SETTING_UP) + phase = "setup" + if(GAME_STATE_PLAYING) + phase = "playing" + else + phase = "ended" + if(phase == "playing") + clock = STATION_TIME_PASSED() + else if(phase == "loading" || phase == "pregame") + var/time_left = SSticker.GetTimeLeft() + if(time_left == -10) + clock = -1 + else + clock = max(0, round(time_left / 10)) + data["phase"] = phase + data["clock"] = clock + data["online"] = LAZYLEN(GLOB.clients) + data["ready"] = user.ready == PLAYER_READY_TO_PLAY + data["prefs_ready"] = SSearly_assets.initialized == INITIALIZATION_INNEW_REGULAR || SSatoms.initialized == INITIALIZATION_INNEW_REGULAR + data["can_observe"] = SSticker.current_state > GAME_STATE_STARTUP + data["polls"] = polls_state + data["queue"] = SSticker.queued_players.Find(user) + data["admin_ready"] = (user.client?.holder && phase != "playing") ? list(SSticker.totalPlayersReady, SSticker.total_admins_ready, length(GLOB.admins)) : null + data["notice"] = notice + data["tab_request"] = tab_request ? list("tab" = tab_request, "serial" = tab_request_serial) : null + +/datum/lobby_terminal/proc/shell_act(action, list/params, mob/dead/new_player/user) + switch(action) + if("join") + return act_join(user) + if("ready") + return act_ready(user) + if("observe") + if(SSticker.current_state <= GAME_STATE_STARTUP) + return FALSE + INVOKE_ASYNC(user, TYPE_PROC_REF(/mob/dead/new_player, make_me_an_observer)) + return FALSE + if("character", "settings") + var/datum/preferences/preferences = user.client?.prefs + if(!preferences) + return FALSE + if(!(SSearly_assets.initialized == INITIALIZATION_INNEW_REGULAR || SSatoms.initialized == INITIALIZATION_INNEW_REGULAR)) + return FALSE + preferences.current_window = action == "character" ? PREFERENCE_TAB_CHARACTER_PREFERENCES : PREFERENCE_TAB_GAME_PREFERENCES + preferences.update_static_data(user) + preferences.ui_interact(user) + return FALSE + if("wiki") + if(CONFIG_GET(string/wikiurl)) + user.client?.wiki() + return FALSE + if("polls") + if(!polls_state) + return FALSE + user.handle_player_polling() + return FALSE + if("manifest") + user.ViewManifest() + return FALSE + if("changelog") + user.client?.changelog() + return FALSE + if("start_now") + if(!user.client?.is_localhost() || !check_rights_for(user.client, R_SERVER)) + return FALSE + SSticker.start_immediately = TRUE + if(SSticker.current_state == GAME_STATE_STARTUP) + set_notice("Starting as soon as the port opens.", "warn") + return TRUE + if("dismiss") + notice = null + return TRUE + if("metrics") + // Display metrics from the client (Windows scaling, pane size), logged once per load for tuning. + log_tgui(user, "Lobby terminal metrics: dpr [text2num(params["dpr"])], pane [text2num(params["w"])]x[text2num(params["h"])], scale [text2num(params["s"])], grid [text2num(params["cols"])]x[text2num(params["rows"])]", window = window) + return FALSE + return FALSE + +/// JOIN: the popcap queue of the classic Join button, then CREWS. The terminal is the join menu. +/datum/lobby_terminal/proc/act_join(mob/dead/new_player/user) + if(!SSticker?.IsRoundInProgress()) + set_notice("The port is not open yet.", "warn") + return TRUE + if(SSlag_switch.measures[DISABLE_NON_OBSJOBS]) + set_notice("No berths are being assigned right now.", "warn") + return TRUE + var/relevant_cap + var/hard_popcap = CONFIG_GET(number/hard_popcap) + var/extreme_popcap = CONFIG_GET(number/extreme_popcap) + if(hard_popcap && extreme_popcap) + relevant_cap = min(hard_popcap, extreme_popcap) + else + relevant_cap = max(hard_popcap, extreme_popcap) + if(SSticker.queued_players.len || (relevant_cap && living_player_count() >= relevant_cap && !(ckey(user.key) in GLOB.admin_datums))) + var/queue_position = SSticker.queued_players.Find(user) + if(!queue_position) + SSticker.queued_players += user + queue_position = SSticker.queued_players.len + to_chat(user, span_danger("[CONFIG_GET(string/hard_popcap_message)]")) + set_notice("Port is full. You are number [queue_position] in line.", "warn") + return TRUE + user.auto_deadmin_on_ready_or_latejoin() + goto_tab("crews") + return TRUE + +/// READY: same as the classic Ready button, and keeps that button's own state in step. +/datum/lobby_terminal/proc/act_ready(mob/dead/new_player/user) + if(SSticker.current_state > GAME_STATE_PREGAME) + return FALSE + var/now_ready = user.ready != PLAYER_READY_TO_PLAY + if(now_ready) + user.auto_deadmin_on_ready_or_latejoin() + user.ready = PLAYER_READY_TO_PLAY + else + user.ready = PLAYER_NOT_READY + var/datum/hud/hud = user.hud_used + if(hud) + for(var/atom/movable/screen/lobby/button/ready/ready_button in hud.static_inventory) + ready_button.ready = now_ready + ready_button.base_icon_state = now_ready ? "ready" : "not_ready" + ready_button.update_appearance(UPDATE_ICON) + SEND_SIGNAL(hud, COMSIG_HUD_PLAYER_READY_TOGGLE) + return TRUE diff --git a/voidcrew/modules/lobby_terminal/lobby_crews.dm b/voidcrew/modules/lobby_terminal/lobby_crews.dm new file mode 100644 index 00000000000..c128e26e3a4 --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_crews.dm @@ -0,0 +1,2 @@ +// CREWS section (package P2). Override the section procs declared in _lobby_terminal.dm here: crews_data(), crews_act(). +// Placeholder from the shell package; the section owner replaces this file. diff --git a/voidcrew/modules/lobby_terminal/lobby_housing.dm b/voidcrew/modules/lobby_terminal/lobby_housing.dm new file mode 100644 index 00000000000..90945f0846d --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_housing.dm @@ -0,0 +1,2 @@ +// HOUSING section (package P5). Override the section procs declared in _lobby_terminal.dm here: housing_data(), housing_act(). +// Placeholder from the shell package; the section owner replaces this file. diff --git a/voidcrew/modules/lobby_terminal/lobby_tgui.dm b/voidcrew/modules/lobby_terminal/lobby_tgui.dm new file mode 100644 index 00000000000..b735b8be203 --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_tgui.dm @@ -0,0 +1,119 @@ +/** + * The terminal's tgui. Same as /datum/tgui except open(): instead of a pooled popup it uses + * the terminal's own unpooled window bound to the lobby_browser control. + * + * The body below copies /datum/tgui/open() (code/modules/tgui/tgui.dm). If upstream changes + * that proc, change this one to match. Unpooled windows never suspend + * (/datum/tgui_window/can_be_suspended() needs `pooled`), which matters here: a suspend + * would winset the control to 1x1 off-screen and wreck the map pane. + */ +/datum/tgui/lobby_terminal + +/datum/tgui/lobby_terminal/open() + if(!user.client) + return FALSE + if(window) + return FALSE + var/datum/lobby_terminal/terminal = src_object + if(!istype(terminal) || !terminal.window || terminal.owner != user.client) + return FALSE + process_status() + if(status < UI_UPDATE) + return FALSE + window = terminal.window + opened_at = world.time + window.acquire_lock(src) + if(!window.is_ready()) + window.initialize( + strict_mode = TRUE, + fancy = FALSE, + assets = list( + get_asset_datum(/datum/asset/simple/tgui), + )) + else + window.send_message("ping") + send_assets() + window.send_message("update", get_payload( + with_data = TRUE, + with_static_data = TRUE)) + SStgui.on_open(src) + return TRUE + +/** + * Only the owner's new_player body may use the terminal. Anything else closes it, which is + * what stops SStgui.on_transfer() from carrying the lobby onto a freshly spawned body. + */ +/datum/ui_state/lobby_terminal + +GLOBAL_DATUM_INIT(lobby_terminal_state, /datum/ui_state/lobby_terminal, new) + +/datum/ui_state/lobby_terminal/can_use_topic(src_object, mob/user) + var/datum/lobby_terminal/terminal = src_object + if(!istype(terminal) || !istype(user, /mob/dead/new_player) || !user.client || user.client != terminal.owner) + return UI_CLOSE + return UI_INTERACTIVE + +/datum/lobby_terminal/ui_state(mob/user) + return GLOB.lobby_terminal_state + +/datum/lobby_terminal/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new /datum/tgui/lobby_terminal(user, src, "LobbyTerminal") + if(!ui.open()) + qdel(ui) + return + src.ui = ui + +/datum/lobby_terminal/ui_close(mob/user) + . = ..() + ui = null + visible = FALSE + hide_control() + +/datum/lobby_terminal/ui_assets(mob/user) + return list( + get_asset_datum(/datum/asset/simple/namespaced/lobby_terminal), + get_asset_datum(/datum/asset/spritesheet_batched/lobby_terminal_buttons), + ) + +/datum/lobby_terminal/ui_static_data(mob/user) + var/list/data = list() + data["wiki"] = !!CONFIG_GET(string/wikiurl) + data["localhost_admin"] = !!(user.client?.is_localhost() && check_rights_for(user.client, R_SERVER)) + data["port"] = null + data["bezel"] = lobby_terminal_bezel_spec() + merge_section(data, yard_static(user)) + return data + +/datum/lobby_terminal/ui_data(mob/user) + var/list/data = list() + shell_data(user, data) + data["saves"] = saved_ships_data() + merge_section(data, crews_data(user)) + merge_section(data, yard_data(user)) + merge_section(data, housing_data(user)) + return data + +/// Copies a section's keys into the payload. Sections may return null. +/datum/lobby_terminal/proc/merge_section(list/data, list/section) + if(!islist(section)) + return + for(var/key in section) + data[key] = section[key] + +/datum/lobby_terminal/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + var/mob/user = ui.user + if(!istype(user, /mob/dead/new_player) || user.client != owner) + return FALSE + var/prefix = copytext(action, 1, 6) + if(prefix == "crew_") + return crews_act(action, params, user) + if(prefix == "yard_") + return yard_act(action, params, user) + if(prefix == "home_") + return housing_act(action, params, user) + return shell_act(action, params, user) diff --git a/voidcrew/modules/lobby_terminal/lobby_yard.dm b/voidcrew/modules/lobby_terminal/lobby_yard.dm new file mode 100644 index 00000000000..502b37382cc --- /dev/null +++ b/voidcrew/modules/lobby_terminal/lobby_yard.dm @@ -0,0 +1,2 @@ +// SHIPYARD section (package P3). Override the section procs declared in _lobby_terminal.dm here: yard_data(), yard_static(), yard_act(). +// Placeholder from the shell package; the section owner replaces this file. diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index cb517499a34..163f7f98824 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -10,6 +10,7 @@ #include "glass_floor_baseturfs.dm" #include "hyperspace_jetpack.dm" #include "late_initialization_reentry.dm" +#include "lobby_terminal_bill.dm" #include "map_parser_attributes.dm" #include "mod_night_vision.dm" #include "operating_table_movement.dm" diff --git a/voidcrew/modules/unit_tests/lobby_terminal_bill.dm b/voidcrew/modules/unit_tests/lobby_terminal_bill.dm new file mode 100644 index 00000000000..66b68877287 --- /dev/null +++ b/voidcrew/modules/unit_tests/lobby_terminal_bill.dm @@ -0,0 +1 @@ +// Lobby terminal bill test (package P3). Placeholder from the shell package; the owner replaces this file. From 0d327850cfc29c5bab25b61db28a54db5af394cc Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:22:13 -0800 Subject: [PATCH 03/26] Add the dock terminal HOUSING tab and lobby wiki updates Lists player outposts with their admission mode and residents, moves the player in through join_outpost (which now takes an entered code instead of prompting), rate-limits wrong outpost codes through the shared lobby code limiter, and lets players ask INVITE outposts to take them in. The wiki pages for new players, the shipyard and ship parts describe the terminal. --- .../interfaces/LobbyTerminal/HousingTab.tsx | 274 ++++++++++++++++-- .../interfaces/LobbyTerminal/types.ts | 6 + .../modules/lobby_terminal/lobby_housing.dm | 179 +++++++++++- .../player_outposts/outpost_residents.dm | 6 +- wiki/content/new-player-guide.md | 8 +- wiki/content/ship-parts.md | 2 +- wiki/content/ship-upgrades.md | 21 +- 7 files changed, 464 insertions(+), 32 deletions(-) diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx index 2c393ebbf79..da61db13552 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx @@ -1,28 +1,268 @@ /** - * HOUSING tab. STUB from the shell package: package P5 owns and replaces this file. - * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2. - * data.outposts: see types.ts. + * HOUSING tab: player outposts taking residents (DM: lobby_housing.dm). + * Draws only with the term.tsx primitives, plus one input for the CODE field. */ +import { Fragment, type ReactNode, useState } from 'react'; import { useBackend } from 'tgui/backend'; -import { Frame, Put, TOP, useGrid } from './term'; -import type { LobbyData } from './types'; +import { Blocks, Btn, type Cls, Frame, Put, Rule, TOP, useGrid } from './term'; +import type { LobbyData, Outpost, OutpostMode } from './types'; + +const MODES: Record = { + open: ['OPEN', ''], + password: ['CODE', 'amb'], + approved: ['INVITE', 'amb'], + closed: ['CLOSED', 'red'], +}; + +/** Cuts a name to n cells, ending a cut in '.'. */ +function cutName(name: string, n: number): string { + return name.length <= n ? name : `${name.slice(0, n - 1).trimEnd()}.`; +} + +/** Width of the code field between its brackets. */ +const FIELD = 10; +const MOVE_IN = '[ MOVE IN ]'; +/** Widest key group at the right of a row: CODE [field] [ MOVE IN ]. */ +const KEYS_W = 5 + FIELD + 3 + MOVE_IN.length; + +/** A text field on the grid, n cells wide, drawn in the terminal font. */ +function CodeField(props: { + x: number; + y: number; + n: number; + value: string; + disabled: boolean; + onChange: (value: string) => void; + onEnter: () => void; +}) { + const { x, y, n, value, disabled, onChange, onEnter } = props; + const { cw, ch } = useGrid(); + return ( + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onEnter(); + } + }} + style={{ + position: 'absolute', + left: x * cw, + top: y * ch, + width: n * cw, + height: ch, + padding: 0, + margin: 0, + border: 'none', + outline: 'none', + background: 'transparent', + color: disabled ? 'var(--lt-faint)' : 'var(--lt-amb)', + caretColor: 'var(--lt-amb)', + font: 'inherit', + lineHeight: 'inherit', + letterSpacing: 0, + zIndex: 6, + }} + /> + ); +} export function HousingTab() { - const { data } = useBackend(); - const { cols, rows } = useGrid(); + const { act, data } = useBackend(); + const { cols, rows, narrow } = useGrid(); + const [codes, setCodes] = useState>({}); + const [page, setPage] = useState(0); const outposts = data.outposts || []; + + const h = rows - 1 - TOP; + const bottom = TOP + h - 1; + const hy = TOP + 1; + const right = cols - 4; + // The mockup's columns on a wide screen; tighter on a narrow one, with the keys on a second line. + const colA = narrow ? Math.min(22, Math.floor(cols * 0.42)) : 30; + const colR = narrow ? colA + 9 : 44; + const nameW = colA - 5; + const step = narrow ? 4 : 3; + const perPage = Math.max(1, Math.floor((bottom - 1 - (hy + 2)) / step)); + const pages = Math.max(1, Math.ceil(outposts.length / perPage)); + const shownPage = Math.min(page, pages - 1); + const shown = outposts.slice( + shownPage * perPage, + shownPage * perPage + perPage, + ); + const blockEnd = narrow ? cols - 3 : right - KEYS_W - 2; + const blockRoom = Math.max(0, Math.floor((blockEnd - (colR + 5)) / 2)); + + const moveIn = (o: Outpost) => { + act('home_move_in', { outpost: o.ref, code: codes[o.ref] || '' }); + setCodes({ ...codes, [o.ref]: '' }); + }; + + const codeKeys = (o: Outpost, y: number) => { + const locked = (o.code_wait || 0) > 0; + const code = codes[o.ref] || ''; + const fx = right - MOVE_IN.length - 2 - FIELD; + const cls = locked ? 'faint' : 'amb'; + return ( + <> + + CODE + + + {`[${' '.repeat(FIELD)}]`} + + setCodes({ ...codes, [o.ref]: value })} + onEnter={() => moveIn(o)} + /> + 0} + disabled={locked} + onClick={() => moveIn(o)} + /> + + ); + }; + + const keys = (o: Outpost, y: number) => { + if (o.mode === 'password' && !o.cleared && !o.denial) { + return codeKeys(o, y); + } + const ax = right - MOVE_IN.length - 2; + let ask: ReactNode = null; + if (o.can_ask) { + ask = o.asked ? ( + + ) : ( + act('home_request', { outpost: o.ref })} + /> + ); + } + return ( + <> + {ask} + {o.denial ? ( + // Faint, but a press still answers with the reason as a notice. + act('home_move_in', { outpost: o.ref })} + > + {MOVE_IN} + + ) : ( + moveIn(o)} + /> + )} + + ); + }; + return ( - - - NO ROOMS TO LET. - + + {outposts.length === 0 ? ( + + NO ROOMS TO LET. + + ) : ( + <> + + OUTPOST + + + ADMITS + + + {narrow ? 'RES' : 'RESIDENTS'} + + + )} + {shown.map((o, i) => { + const y = hy + 2 + i * step; + const [mode, modeCls] = MODES[o.mode] || MODES.closed; + const blocks = Math.max(0, Math.min(o.residents, blockRoom)); + return ( + + + {cutName(o.name.toUpperCase(), nameW)} + + + {mode} + + + {String(o.residents).padStart(3, ' ')} + + {Array.from({ length: blocks }, (_, k) => ( + + ))} + {keys(o, narrow ? y + 1 : y)} + {i < shown.length - 1 && ( + + )} + + ); + })} + {pages > 1 && ( + <> + setPage(shownPage - 1)} + /> + + {`${shownPage + 1}/${pages}`} + + = pages - 1} + onClick={() => setPage(shownPage + 1)} + /> + + )} ); } diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts index c7c785fe236..5e058274e40 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts @@ -210,7 +210,12 @@ export type Outpost = { cleared: BooleanLike; /** Short in-character reason MOVE IN is refused, or null. */ denial: string | null; + /** Seconds left on a wrong-code lockout. */ code_wait?: number; + /** INVITE outposts: the player may ask to move in. */ + can_ask?: BooleanLike; + /** A move-in request from this player is still fresh. */ + asked?: BooleanLike; }; export type HousingData = { @@ -266,4 +271,5 @@ export type LobbyActions = { yard_requisition: { leave_crew: 0 | 1 }; yard_load: { slot: number; leave_crew: 0 | 1 }; home_move_in: { outpost: string; code?: string }; + home_request: { outpost: string }; }; diff --git a/voidcrew/modules/lobby_terminal/lobby_housing.dm b/voidcrew/modules/lobby_terminal/lobby_housing.dm index 90945f0846d..638e3f57bba 100644 --- a/voidcrew/modules/lobby_terminal/lobby_housing.dm +++ b/voidcrew/modules/lobby_terminal/lobby_housing.dm @@ -1,2 +1,177 @@ -// HOUSING section (package P5). Override the section procs declared in _lobby_terminal.dm here: housing_data(), housing_act(). -// Placeholder from the shell package; the section owner replaces this file. +// HOUSING section of the dock terminal (lobby_terminal). Lists player outposts and moves the player in +// through /mob/dead/new_player/join_outpost() (voidcrew/modules/player_outposts/outpost_residents.dm), +// which stays the authority on admission. Codes go through the shared limiter in _lobby_terminal.dm. + +/// How long a ckey waits before asking the same outpost again. +#define LOBBY_HOUSING_REQUEST_COOLDOWN (3 MINUTES) +/// How long a ckey waits between asking any two outposts. +#define LOBBY_HOUSING_REQUEST_GAP (10 SECONDS) + +/// Move-in requests: "[ckey]|[outpost ref]" = world.time asked, and "[ckey]" = world.time of the last request anywhere. +GLOBAL_LIST_EMPTY(lobby_housing_requests) + +/// Outposts a lobby player can see: loaded and owned. +/datum/lobby_terminal/proc/housing_outposts() + . = list() + for(var/obj/structure/overmap/dynamic/player_outpost/home as anything in GLOB.player_outposts) + if(QDELETED(home) || !home.loaded || !home.founder_ckey) + continue + . += home + +/// Resolves a client ref only inside the live outpost list. +/datum/lobby_terminal/proc/housing_find(ref) + if(!istext(ref)) + return null + var/obj/structure/overmap/dynamic/player_outpost/home = locate(ref) in GLOB.player_outposts + if(QDELETED(home) || !home.loaded || !home.founder_ckey) + return null + return home + +/// Limiter key for an outpost's code. The access revision is part of it, so a new code clears old failures. +/datum/lobby_terminal/proc/housing_code_key(obj/structure/overmap/dynamic/player_outpost/home) + return "outpost:[REF(home)]:[home.resident_access_revision]" + +/** + * Short in-character reason this player can't move in, or null. Mirrors the order of + * resident_admission_error(). A password outpost the player isn't cleared for reads as + * null here: the code field is the gate, and join_outpost() checks the code itself. + */ +/datum/lobby_terminal/proc/housing_denial(obj/structure/overmap/dynamic/player_outpost/home, player_key) + if(!SSticker.IsRoundInProgress()) + return "Opens when the shift starts." + if(home.resident_mode == "closed") + return "Not taking residents." + if(player_key in home.blocked_residents) + return "You're barred from here." + var/cleared = home.has_resident_clearance(player_key) + if(home.dock_mode == OUTPOST_DOCK_MODE_LOCKDOWN && !cleared) + return "Locked down. Residents only." + if(home.resident_mode == "approved" && !cleared) + return "Residents by invitation." + if(home.resident_mode == "password" && !cleared && !length(home.resident_password)) + return "Not taking residents." + var/wait = cryo_rejoin_wait(player_key, home) + if(wait) + return "Still in cryo for [DisplayTimeText(wait)]." + if(!home.available_resident_pod()) + return "No free cryopods." + return null + +/// TRUE while this ckey's request to this outpost is still fresh. +/datum/lobby_terminal/proc/housing_asked(obj/structure/overmap/dynamic/player_outpost/home, player_key) + var/asked_at = GLOB.lobby_housing_requests["[player_key]|[REF(home)]"] + return asked_at && world.time < asked_at + LOBBY_HOUSING_REQUEST_COOLDOWN + +/datum/lobby_terminal/housing_data(mob/user) + var/player_key = user?.ckey + var/list/outposts = list() + for(var/obj/structure/overmap/dynamic/player_outpost/home as anything in housing_outposts()) + outposts += list(list( + "ref" = REF(home), + "name" = home.name, + "mode" = home.resident_mode, + "residents" = home.active_resident_count(), + "cleared" = !!home.has_resident_clearance(player_key), + "denial" = housing_denial(home, player_key), + "code_wait" = code_lock_wait(player_key, housing_code_key(home)), + "can_ask" = !!(home.resident_mode == "approved" && SSticker.IsRoundInProgress() && !home.has_resident_clearance(player_key) && !(player_key in home.blocked_residents)), + "asked" = !!housing_asked(home, player_key), + )) + return list("outposts" = outposts) + +/datum/lobby_terminal/housing_act(action, list/params, mob/user) + var/mob/dead/new_player/player = user + if(!istype(player) || !player.ckey) + return FALSE + var/obj/structure/overmap/dynamic/player_outpost/home = housing_find(params["outpost"]) + if(!home) + set_notice("That outpost is gone.", "warn") + return TRUE + switch(action) + if("home_move_in") + housing_move_in(player, home, params["code"]) + return TRUE + if("home_request") + housing_request(player, home) + return TRUE + return FALSE + +/// Checks the code against the limiter, then hands off to join_outpost() off the UI tick. +/datum/lobby_terminal/proc/housing_move_in(mob/dead/new_player/player, obj/structure/overmap/dynamic/player_outpost/home, code) + var/player_key = player.ckey + if(SSlag_switch.measures[DISABLE_NON_OBSJOBS]) + set_notice("No berths are being assigned right now.", "warn") + return + var/denial = housing_denial(home, player_key) + if(denial) + set_notice(denial, "warn") + return + var/attempt = "" + if(home.resident_mode == "password" && !home.has_resident_clearance(player_key)) + var/lock_key = housing_code_key(home) + var/wait = code_lock_wait(player_key, lock_key) + if(wait) + set_notice("Keypad locked. Try again in [wait] seconds.", "bad") + return + attempt = istext(code) ? trim(copytext(code, 1, 65)) : "" + if(!length(attempt)) + set_notice("Enter the code.", "warn") + return + // Exact match, as resident_admission_error() compares it. The code is never echoed. + if(attempt != home.resident_password) + if(note_code_failure(player_key, lock_key)) + set_notice("Keypad locked.", "bad") + else + set_notice("Wrong code.", "bad") + return + clear_code_failures(player_key, lock_key) + if(!try_join_lock(player)) + set_notice("Already on your way.", "warn") + return + set_notice("Moving in at [home.name].", "ok") + INVOKE_ASYNC(src, PROC_REF(housing_finish_move_in), player, home, attempt, player_key) + +/** + * join_outpost() sleeps (eligibility, appearance). The join lock spans all of it. + * attempt is never null here, so join_outpost() never opens its own password prompt. + */ +/datum/lobby_terminal/proc/housing_finish_move_in(mob/dead/new_player/player, obj/structure/overmap/dynamic/player_outpost/home, attempt, player_key) + var/joined = FALSE + if(!QDELETED(player) && !QDELETED(home)) + joined = player.join_outpost(home, attempt) + release_join_lock(player_key) + if(joined || QDELETED(src)) + return + var/denial = (!QDELETED(home) && player_key) ? housing_denial(home, player_key) : null + set_notice(denial || "Couldn't move in. Try again.", "warn") + +/// INVITE outposts: tells the residents on shift that this player wants to move in. +/datum/lobby_terminal/proc/housing_request(mob/dead/new_player/player, obj/structure/overmap/dynamic/player_outpost/home) + var/player_key = player.ckey + if(home.resident_mode != "approved" || !SSticker.IsRoundInProgress() || home.has_resident_clearance(player_key) || (player_key in home.blocked_residents)) + return + if(housing_asked(home, player_key)) + return + var/last = GLOB.lobby_housing_requests[player_key] + if(last && world.time < last + LOBBY_HOUSING_REQUEST_GAP) + set_notice("Wait a moment.", "warn") + return + var/list/recipients = list() + for(var/datum/mind/member as anything in home.residents) + if(QDELETED(member) || !member.current?.client || member.current.stat == DEAD) + continue + recipients |= member.current + if(!length(recipients)) + set_notice("Nobody's home at [home.name].", "warn") + return + GLOB.lobby_housing_requests[player_key] = world.time + GLOB.lobby_housing_requests["[player_key]|[REF(home)]"] = world.time + var/safe_name = html_encode(home.name) + var/safe_key = html_encode(player.key) + for(var/mob/resident as anything in recipients) + to_chat(resident, span_notice("[safe_name] housing office: [safe_key] is asking to move in.")) + log_game("[key_name(player)] asked to move in at outpost [home.name] ([length(recipients)] residents told)") + set_notice("Request sent to [home.name].", "ok") + +#undef LOBBY_HOUSING_REQUEST_COOLDOWN +#undef LOBBY_HOUSING_REQUEST_GAP diff --git a/voidcrew/modules/player_outposts/outpost_residents.dm b/voidcrew/modules/player_outposts/outpost_residents.dm index 045855736a1..a349b7b49f9 100644 --- a/voidcrew/modules/player_outposts/outpost_residents.dm +++ b/voidcrew/modules/player_outposts/outpost_residents.dm @@ -79,12 +79,12 @@ /// Through the existing lobby only: respawn's delay/configuration still controls /// reaching this mob, and character reuse/job/population gates are checked again. -/mob/dead/new_player/proc/join_outpost(obj/structure/overmap/dynamic/player_outpost/home) +/// attempt: the resident password, already entered (the dock terminal's code field). Null prompts for it. +/mob/dead/new_player/proc/join_outpost(obj/structure/overmap/dynamic/player_outpost/home, attempt = null) if(spawning || spawning_ship || !client || QDELETED(home)) return FALSE - var/attempt var/revision = home.resident_access_revision - if(home.resident_mode == "password" && !home.has_resident_clearance(ckey)) + if(isnull(attempt) && home.resident_mode == "password" && !home.has_resident_clearance(ckey)) attempt = tgui_input_text(src, "Enter the resident password for [home.name].", "Resident Arrival", max_length = 64, encode = FALSE, timeout = 60 SECONDS) if(QDELETED(home) || !client || spawning || spawning_ship) return FALSE diff --git a/wiki/content/new-player-guide.md b/wiki/content/new-player-guide.md index 64e5e8177cd..0d6b5561e3d 100644 --- a/wiki/content/new-player-guide.md +++ b/wiki/content/new-player-guide.md @@ -17,7 +17,13 @@ Each hull is rolled at random: a hull class, one of that class's themes, and a r Your job comes from the ship category preferences in your character setup, Command, Security, Engineering, Medical, Science, Cargo, Service and Assistant, each set to high, medium or low. Each hull picks its captain first, contested between the players who set Command highest, with ties broken at random; if nobody wants Command, someone is drafted and told so. Everyone else is matched to an open job in a category they asked for, working down from high to low, then to any open job. Job titles vary by hull and theme, so the captain might be called Captain, Team Leader or something stranger. -If you join after the round has started, you get a ship menu instead. You can join any existing crew that has an open slot and has left joining enabled, or launch a ship of your own from the shipyard. The two Pill-class hulls are free; everything else costs ship parts you have banked from previous rounds. +If you join after the round has started, press **JOIN** on the dock terminal. The terminal has three tabs: + +- **CREWS** lists every crew that is taking people. Pick one to see its ship, its memo and its open jobs, then press **SIGN ON** next to the job you want. A crew with a code needs the code typed into its **CODE** field first; if you don't have it, **APPLY** with a short note and the captain decides. A full crew can be pinged so its captain knows someone is waiting. +- **SHIPYARD** builds you a ship of your own. See [Buying & Upgrading Ships](ship-upgrades.md). The two Pill-class hulls are free; everything else costs ship parts you have banked from previous rounds. +- **HOUSING** lists [player outposts](player-outposts.md) taking residents. Press **MOVE IN** to wake up in one of the outpost's cryopods. An outpost marked **CODE** needs its code typed in first. One marked **INVITE** takes only people its management has invited; press **REQUEST** to let the residents on shift know you want in. + +The keys along the bottom of the terminal open your character setup, the game settings, the crew manifest, polls, the changelog and this wiki. **OBSERVE** lets you watch as a ghost instead. ## What is already on your ship diff --git a/wiki/content/ship-parts.md b/wiki/content/ship-parts.md index 39f7a8c9270..5ddd41d3505 100644 --- a/wiki/content/ship-parts.md +++ b/wiki/content/ship-parts.md @@ -77,6 +77,6 @@ Give the item to the recipient so they can put it in their case and extract it. ## Spending banked parts -Open the **Shipyard** from the join menu to see your balance and the prices of hulls, themes and modules. Purchases use your banked parts; you do not carry physical parts into the shipyard. Free options need no parts. +Open the **SHIPYARD** tab on the lobby's dock terminal to see your balance and the prices of hulls, themes and modules. The bill for a ship adds up everything on it you don't own yet, and **COMMISSION** pays it from your banked parts in one go. You do not carry physical parts into the shipyard. Free options need no parts. Unlocks are permanent. Buying a theme or module lets you select it for future launches of that hull without paying again, and losing a ship does not erase your purchases. See [Buying & Upgrading Ships](ship-upgrades.md) for choosing a loadout and [Ship Catalog](ship-catalog.md) for the available hulls. diff --git a/wiki/content/ship-upgrades.md b/wiki/content/ship-upgrades.md index 429f3643447..7a8b7fbb9b1 100644 --- a/wiki/content/ship-upgrades.md +++ b/wiki/content/ship-upgrades.md @@ -2,7 +2,7 @@ title: Buying & Upgrading Ships category: The Fleet order: 2 -blurb: Buying permanent unlocks in the shipyard and choosing a hull, theme and modules. +blurb: Building a ship in the shipyard: choosing a hull, theme and modules, and paying for permanent unlocks. --- The shipyard lets you choose a hull, theme and modules before launching. Purchases use [ship parts](ship-parts.md) banked to your account, and everything you buy stays unlocked for future rounds. For the hulls themselves, see the [Ship Catalog](ship-catalog.md). @@ -17,22 +17,27 @@ Use **Request Extraction Case** under the **IC** tab, put up to five parts insid ## The shipyard -From the lobby, the join menu has a **Start Your Own Ship** section with an **Open Shipyard** button. Everything about buying a ship happens in that one window, because it is the only place that shows you what you are buying. +Open the **SHIPYARD** tab on the dock terminal in the lobby. Everything about buying a ship happens there, because it is the only place that shows you what you are buying. -It has three tabs beside the map preview. **Hull** is the shelf: every ship with its part cost, crew capacity, slot count and theme count. **Theme** lists that hull's whole-ship variants, with the free default pre-selected. **Upgrades** gives one row per slot and the modules that fit it, with one free default in each. +1. **Pick a hull.** The shelf lists every ship with its part cost and crew. Selecting one shows its floor plan and description. +2. **Fit it out.** Choose a finish (the hull's theme) and a module for each slot. The floor plan redraws as you change them, so you can see what each room will look like. A hull's description tells you less than its floor plan does. +3. **Check the bill.** The bill lists everything on the ship you don't own yet, with the price of each and the total. Things you already own, and the free defaults, cost nothing. +4. **Commission.** One press pays the whole bill and launches the ship. If you are short, the bill shows which parts you are missing and the key stays dark. -The preview panel draws the actual ship map, and it is live: change the theme and the whole hull redraws, pick a different module and that room changes in place. Use it. A hull's description tells you less than its floor plan does. +Everything on the bill is unlocked for good once it is paid. The next time you build that hull, everything you bought is free to pick again. If the shipyard is too busy to launch your ship right after you paid, your unlocks are kept, so commissioning it again costs nothing. -Buying anything asks for confirmation and tells you the cost. Purchases are one-time and permanent: a theme or module you unlock is yours on every future ship of that class. Selecting is separate from buying, so you can own four modules for a slot and still fly the default. Once the hull is paid for, **LAUNCH SHIP** appears next to Cancel. +**REQUISITION** gives you a free hull when no crew has a seat open for you. + +If you are in someone else's crew, building a ship means leaving it to start your own. The bill says which crew you would leave, and **COMMISSION** asks you to confirm before anything is spent. !!! tip "Look before you spend" - Switching hulls in the shipyard costs nothing and the preview works on hulls you do not own. Read every floor plan first. + Browsing hulls and fittings costs nothing, and the floor plan works on hulls you do not own. Read every floor plan first. ## What happens when you launch -Your ship is built to order (hull, theme and the module in each slot) and placed in the safe inner ring of the overmap. You spawn aboard as its captain, so you set its course and manage its crew. It then appears in every other player's join menu with its job list, so people can pick a role and spawn straight in. If you want a crew, launch early and put something useful in the ship's memo. Then read [Piloting](piloting.md), [Ship Systems](ship-systems.md) and [Ship Combat](ship-combat.md). +Your ship is built to order (hull, theme and the module in each slot) and placed in the safe inner ring of the overmap. You spawn aboard as its captain, so you set its course and manage its crew. It then appears in every other player's CREWS tab with its job list, so people can pick a role and spawn straight in. If you want a crew, launch early and put something useful in the ship's memo. Then read [Piloting](piloting.md), [Ship Systems](ship-systems.md) and [Ship Combat](ship-combat.md). -A crew can run several ships, but only its leader can add one, by buying, requisitioning, commissioning or claiming it. The leader is whoever founded the crew. Anyone else who does it is asked whether they want to leave and start their own crew with that ship; saying no cancels it at no cost. A captain can still rebuild their own crew's ship from a checkpoint. If the leader leaves the crew, goes to cryo or respawns, the captain of the crew's oldest surviving ship takes over, or the earliest remaining member if it has no eligible captain. [Reputation](zone-reputation.md) stays with the crew across hull losses and is shared by its ships; accounts, research and mission slots remain per ship. +A crew can run several ships, but only its leader can add one, by buying, requisitioning, commissioning or claiming it. The leader is whoever founded the crew. Anyone else who does it is asked first, then leaves their crew and starts a new one with that ship. Saying no costs nothing. A captain can still rebuild their own crew's ship from a checkpoint. If the leader leaves the crew, goes to cryo or respawns, the captain of the crew's oldest surviving ship takes over, or the earliest remaining member if it has no eligible captain. [Reputation](zone-reputation.md) stays with the crew across hull losses and is shared by its ships; accounts, research and mission slots remain per ship. ## Modular hulls: slots and modules From 6eaed8f626c3dfff474b5c143c7cd793610d58ee Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:22:29 -0800 Subject: [PATCH 04/26] Add the CREWS tab to the dock terminal lobby One line per crew with the record beside it: memo, berths with SIGN ON, CODE and NOTE + APPLY for locked crews, PING CREW when every berth is taken. SIGN ON is a direct join with no popups. It checks the popcap queue and stops (the old path printed 'Server is full' and joined anyway), job bans and playtime off the UI tick, the cryo rejoin wait, admin-disabled late join and the shared join lock. Wrong codes go through the shared lockout, and a new code on the ship resets it. --- .../interfaces/LobbyTerminal/CrewRecord.tsx | 355 ++++++++++++++++++ .../interfaces/LobbyTerminal/CrewsTab.tsx | 220 ++++++++++- .../modules/lobby_terminal/lobby_crews.dm | 290 +++++++++++++- 3 files changed, 846 insertions(+), 19 deletions(-) create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx new file mode 100644 index 00000000000..162d9ffc510 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx @@ -0,0 +1,355 @@ +/** + * The record pane of one crew: memo, berths with SIGN ON, CODE / NOTE + APPLY for locked + * crews, PING CREW when every berth is taken. Package P2. + */ +import { type ReactNode, useState } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { + Blocks, + Btn, + Frame, + Put, + pad, + RPut, + Rule, + TOP, + useGrid, + wrap, +} from './term'; +import type { Crew, LobbyData } from './types'; + +/** SHIP_JOIN_PASSWORD_MAX_LEN and SHIP_APPLICATION_MESSAGE_MAX_LEN (voidcrew/_DEFINES/overmap.dm). */ +const CODE_MAX = 24; +const NOTE_MAX = 200; + +/** Seconds as m:ss. */ +export function clock(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s < 10 ? '0' : ''}${s}`; +} + +/** Cuts to n characters without padding; a cut ends in '.'. */ +export function cut(str: string, n: number): string { + return str.length > n ? `${str.slice(0, Math.max(0, n - 1))}.` : str; +} + +/** Locked and not yet cleared to board. */ +export const isLocked = (c: Crew) => !!c.locked && !c.cleared; + +/** A one-line text field on the grid, drawn in amber inside [ ]. Enter submits. */ +function Field(props: { + x: number; + y: number; + w: number; + value: string; + max: number; + disabled?: boolean; + onChange: (value: string) => void; + onEnter: () => void; +}) { + const { x, y, w, value, max, disabled, onChange, onEnter } = props; + const { cw, ch } = useGrid(); + return ( + <> + + {`[${' '.repeat(w)}]`} + + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onEnter(); + } + }} + style={{ + position: 'absolute', + left: Math.round((x + 1) * cw), + top: Math.round(y * ch), + width: Math.round(w * cw), + height: ch, + padding: 0, + margin: 0, + border: 'none', + outline: 'none', + background: 'transparent', + color: 'var(--lt-amb)', + caretColor: 'var(--lt-amb)', + font: 'inherit', + lineHeight: `${ch}px`, + textShadow: 'inherit', + userSelect: 'text', + zIndex: 6, + }} + /> + + ); +} + +type Props = { + crew: Crew; + x: number; + w: number; + h: number; + /** Narrow layout: the record has the whole screen and closes with BACK. */ + narrow: boolean; + onClose: () => void; +}; + +export function CrewRecord(props: Props) { + const { crew: c, x, w, h, narrow, onClose } = props; + const { act } = useBackend(); + const [code, setCode] = useState(''); + const [note, setNote] = useState(''); + const bottom = TOP + h - 1; + const out: ReactNode[] = []; + let y = TOP + 2; + + const closeLabel = narrow ? ' BACK ' : ' X '; + out.push( + + {closeLabel} + , + ); + + out.push( + + {pad(c.cls.toUpperCase(), Math.max(1, w - 18))} + , + + {`${c.crew} ABOARD`} + , + ); + y += 1; + if (c.doors) { + out.push( + + DOORS + , + + CREW ONLY + , + ); + } + y += 2; + + const locked = isLocked(c) && !c.applied; + // Rows the lower block needs: the code and note rows for a locked crew, one row otherwise. + const tail = locked ? 7 : c.applied ? 3 : 1; + + if (c.memo) { + const room = Math.max(1, bottom - y - tail - 6); + const lines = wrap(`"${c.memo}"`, w - 6).slice(0, Math.min(5, room)); + for (const line of lines) { + out.push( + + {line} + , + ); + y++; + } + y += 1; + } + out.push(); + y += 1; + + if (!c.jobs.length) { + out.push( + + NO BERTHS OPEN + , + ); + y += 3; + if (c.ping_wait > 0) { + out.push( + + [ PING CREW ] + , + + {clock(c.ping_wait)} + , + ); + } else { + out.push( + act('crew_ping', { ship: c.ref })} + />, + ); + } + return <>{out}; + } + + if (c.rejoin_wait > 0 && !locked) { + y += 1; + out.push( + + {`BACK ABOARD IN ${clock(c.rejoin_wait)}`} + , + ); + y += 2; + } + + const grey = locked || !!c.applied; + const sign = '[ SIGN ON ]'; + const maxBlocks = Math.max(1, Math.floor((w - 20 - sign.length - 4) / 2)); + // Two rows per berth as in the mockup, one when that would not fit. + const step = y + c.jobs.length * 2 + tail <= bottom ? 2 : 1; + const jobRoom = Math.max(1, Math.floor((bottom - y - tail) / step)); + for (const job of c.jobs.slice(0, jobRoom)) { + const usable = !grey && !!job.ok && c.rejoin_wait <= 0; + out.push( + + {pad(job.title.toUpperCase(), 14)} + , + ); + const shown = Math.min(job.open, maxBlocks); + for (let i = 0; i < shown; i++) { + out.push( + , + ); + } + out.push( + act('crew_sign_on', { ship: c.ref, job: job.ref })} + />, + ); + y += step; + } + + if (locked) { + out.push(); + y += 2; + const waiting = c.code_wait > 0; + const sendCode = () => { + if (!code.trim() || waiting) { + return; + } + act('crew_code', { ship: c.ref, code }); + setCode(''); + }; + out.push( + + CODE + , + , + , + ); + if (waiting) { + out.push( + + {clock(c.code_wait)} + , + ); + } + y += 2; + const sendNote = () => { + act('crew_apply', { ship: c.ref, note }); + setNote(''); + }; + out.push( + + NOTE + , + , + ); + y += 2; + out.push( + , + ); + } else if (c.applied) { + out.push(); + y += 2; + out.push( + + [ APPLIED ] + , + ); + } + + return <>{out}; +} + +/** The record pane with its frame. */ +export function CrewRecordPane(props: Props) { + const { crew, x, w, h } = props; + return ( + + + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx index 81e6a2beacf..8f22e7be011 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewsTab.tsx @@ -1,28 +1,214 @@ /** - * CREWS tab. STUB from the shell package: package P2 owns and replaces this file. - * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2 - * (rows - 1 is the shell's notice line). data.crews: see types.ts. + * CREWS tab: one line per crew, and the record of the selected one beside it (or in its + * place on a narrow screen). Package P2. Draws only with the term.tsx primitives; content + * lives between rows TOP and rows - 2 (rows - 1 is the shell's notice line). */ +import { type ReactNode, useState } from 'react'; import { useBackend } from 'tgui/backend'; -import { Frame, Put, TOP, useGrid } from './term'; -import type { LobbyData } from './types'; +import { CrewRecordPane, isLocked } from './CrewRecord'; +import { Frame, Hit, lpad, Put, pad, TOP, useGrid } from './term'; +import type { Crew, LobbyData } from './types'; + +function openSlots(c: Crew): number { + return c.jobs.reduce((sum, job) => sum + job.open, 0); +} + +/** Status word and its colour, as in the mockup. Open crews show nothing. */ +function status(c: Crew): [string, string] | null { + if (!c.jobs.length) { + return ['FULL', 'red']; + } + if (c.applied) { + return ['APPLIED', 'amb']; + } + if (isLocked(c)) { + return ['LOCKED', 'amb']; + } + return null; +} export function CrewsTab() { const { data } = useBackend(); - const { cols, rows } = useGrid(); + const { cols, rows, narrow } = useGrid(); const crews = data.crews || []; + const [selRef, setSelRef] = useState(null); + const [page, setPage] = useState(1); + + const selected = crews.find((c) => c.ref === selRef) || null; + const h = rows - 1 - TOP; + + if (selected && narrow) { + return ( + setSelRef(null)} + /> + ); + } + + const split = !!selected; + // The mockup splits 64 | 46 at 110 columns. + const lw = split ? Math.round((cols * 64) / 110) : cols; + const showMemo = !split && !narrow; + // Fixed-width columns are placed from the right; the class column takes a share of the rest. + // With the memo column it starts at the mockup's share of the width (column 70 of 126). + const memo = Math.round((cols * 70) / 126); + const st = showMemo ? memo - 10 : lw - 11; + const open = st - (showMemo ? 7 : 6); + const crewCol = open - (showMemo ? 7 : 6); + const name = 3; + const cls = name + Math.min(26, Math.round((crewCol - name) * 0.58)); + + const hy = TOP + 1; + const fit = Math.max(1, h - 3); + const pages = Math.max(1, Math.ceil(crews.length / fit)); + const pg = Math.min(pages, Math.max(1, page)); + const shown = crews.slice((pg - 1) * fit, pg * fit); + + const out: ReactNode[] = []; + out.push( + + SHIP + , + + CLASS + , + + CREW + , + + OPEN + , + ); + if (showMemo) { + out.push( + + MEMO + , + ); + } + + if (!crews.length) { + out.push( + + {data.phase === 'playing' + ? 'NO CREWS ARE SIGNING ON.' + : 'CREWS SIGN ON ONCE THE PORT OPENS.'} + , + ); + } + + shown.forEach((c, i) => { + const y = hy + 1 + i; + const isSel = c.ref === selRef; + const k = isSel ? 'inv' : ''; + const slots = openSlots(c); + const stat = status(c); + if (isSel) { + out.push( + + {' '.repeat(Math.max(0, lw - 3))} + , + ); + } + out.push( + + {isSel ? '>' : ' '} + , + + {pad(c.name.toUpperCase(), cls - name - 1)} + , + + {pad(c.cls, crewCol - cls - 1)} + , + + {lpad(c.crew, 3)} + , + + {lpad(slots || '-', 3)} + , + ); + if (stat) { + out.push( + + {stat[0]} + , + ); + } + if (showMemo) { + out.push( + + {pad(c.memo, cols - memo - 2)} + , + ); + } + out.push( + setSelRef(isSel ? null : c.ref)} + />, + ); + }); + + if (pages > 1) { + const py = TOP + h - 1; + const label = ` ${pg}/${pages} `; + out.push( + 1 ? 'inv' : 'faint'} + bg={pg <= 1} + onClick={pg > 1 ? () => setPage(pg - 1) : undefined} + > + {' < '} + , + + {label} + , + = pages} + onClick={pg < pages ? () => setPage(pg + 1) : undefined} + > + {' > '} + , + ); + } + return ( - - - NO CREWS ARE SIGNING ON. - - + <> + + {out} + + {!!selected && ( + setSelRef(null)} + /> + )} + ); } diff --git a/voidcrew/modules/lobby_terminal/lobby_crews.dm b/voidcrew/modules/lobby_terminal/lobby_crews.dm index c128e26e3a4..bc1eca90b99 100644 --- a/voidcrew/modules/lobby_terminal/lobby_crews.dm +++ b/voidcrew/modules/lobby_terminal/lobby_crews.dm @@ -1,2 +1,288 @@ -// CREWS section (package P2). Override the section procs declared in _lobby_terminal.dm here: crews_data(), crews_act(). -// Placeholder from the shell package; the section owner replaces this file. +/** + * CREWS section of the dock terminal (package P2). + * + * Lists every joinable crew and runs the direct join path: SIGN ON, CODE, APPLY and PING. + * This replaces select_job_on_ship()'s popups while the terminal is up. The classic + * ShipJoinMenu stays as the fallback and is not touched. + * + * Every action re-validates on the server. Refs only resolve against get_joinable_ships() + * and that ship's job_slots, never a bare locate(). + */ + +/// Ping cooldown, the same as ship_join_menu.dm's SHIP_JOIN_PING_COOLDOWN (undefined at the end of that file). +#define LOBBY_CREW_PING_COOLDOWN (2 MINUTES) +/// One application per ckey per this long, across every ship. +#define LOBBY_CREW_APPLY_COOLDOWN (10 SECONDS) +/// How long a cached job eligibility answer (ban, playtime) is trusted. +#define LOBBY_CREW_JOB_CHECK_TTL (5 MINUTES) + +/datum/lobby_terminal + /// REF(job) = list(ok, world.time checked). Eligibility can query the ban DB and sleep, so it never runs inside ui_data. + var/list/crew_job_ok = list() + /// TRUE while an eligibility refresh is running. + var/crew_jobs_checking = FALSE + /// Ckey = world.time of the next allowed application. Static, so a reconnect does not reset it. + var/static/list/crew_apply_cooldowns = list() + /// REF(ship) = md5 of the join code last seen, so a code change wipes everyone's wrong-code count. + var/static/list/crew_code_seen = list() + /// Shared instance of the old join menu, only to reach its static ping cooldown list. + var/static/datum/ship_join_menu/crew_ping_menu + +/datum/lobby_terminal/crews_data(mob/user) + var/list/crews = list() + var/ckey = user?.ckey + var/list/to_check + for(var/obj/structure/overmap/ship/ship as anything in get_joinable_ships()) + var/ship_ref = REF(ship) + var/list/jobs = list() + for(var/datum/job/job as anything in ship.job_slots) + var/open = ship.job_slots[job] + if(open <= 0) + continue + var/job_ref = REF(job) + var/list/cached = crew_job_ok[job_ref] + if(!cached || world.time - cached[2] > LOBBY_CREW_JOB_CHECK_TTL) + LAZYOR(to_check, job) + jobs += list(list( + "ref" = job_ref, + "title" = job.title, + "open" = open, + "ok" = cached ? cached[1] : TRUE, + )) + crews += list(list( + "ref" = ship_ref, + "name" = ship.name, + "cls" = ship.source_template?.short_name || "Unknown", + "crew" = length(ship.manifest), + "memo" = ship.memo || "", + "locked" = !!ship.join_password, + "cleared" = ship.is_password_cleared(ckey), + "applied" = !isnull(ship.find_crew_application(ckey)), + "doors" = !!ship.crew_only_airlocks, + "ping_wait" = CEILING(crew_ping_wait(ckey, ship) / (1 SECONDS), 1), + "rejoin_wait" = CEILING(cryo_rejoin_wait(ckey, ship) / (1 SECONDS), 1), + "code_wait" = code_lock_wait(ckey, ship_ref), + "jobs" = jobs, + )) + if(to_check && !crew_jobs_checking && istype(user, /mob/dead/new_player)) + crew_jobs_checking = TRUE + INVOKE_ASYNC(src, PROC_REF(refresh_crew_jobs), user, to_check) + return list("crews" = crews) + +/// Runs the job eligibility checks (ban DB, playtime, account age) off the UI tick and caches the answers. +/datum/lobby_terminal/proc/refresh_crew_jobs(mob/dead/new_player/user, list/jobs) + for(var/datum/job/job as anything in jobs) + if(QDELETED(src) || QDELETED(user) || !user.client) + break + if(QDELETED(job)) + continue + var/ok = SSjob.check_job_eligibility(user, job, "Lobby terminal") == JOB_AVAILABLE + if(ok && user.client) + ok = job.special_check_latejoin(user.client) + crew_job_ok[REF(job)] = list(!!ok, world.time) + crew_jobs_checking = FALSE + +/// Deciseconds until this ckey may ping this ship. Both the sender and the ship must be off cooldown. +/datum/lobby_terminal/proc/crew_ping_wait(ckey, obj/structure/overmap/ship/ship) + var/list/cooldowns = crew_ping_cooldowns() + return max(0, (cooldowns[ckey] || 0) - world.time, COOLDOWN_TIMELEFT(ship, join_ping_cooldown)) + +/// The old join menu's static ping cooldown list, shared so the two menus can't be used to double a ping. +/datum/lobby_terminal/proc/crew_ping_cooldowns() + if(!crew_ping_menu) + crew_ping_menu = new /datum/ship_join_menu(null) + return crew_ping_menu.ping_cooldowns + +/// Finds a joinable ship by ref. Never a bare locate(). +/datum/lobby_terminal/proc/crew_find_ship(ship_ref) + if(!istext(ship_ref)) + return null + var/obj/structure/overmap/ship/ship = locate(ship_ref) + if(!istype(ship) || QDELETED(ship) || !(ship in get_joinable_ships())) + return null + return ship + +/// Finds the job datum for a ref among this ship's berths. +/datum/lobby_terminal/proc/crew_find_job(obj/structure/overmap/ship/ship, job_ref) + if(!istext(job_ref)) + return null + for(var/datum/job/job as anything in ship.job_slots) + if(REF(job) == job_ref) + return job + return null + +/datum/lobby_terminal/crews_act(action, list/params, mob/user) + var/mob/dead/new_player/player = user + if(!istype(player) || !player.ckey) + return FALSE + var/obj/structure/overmap/ship/ship = crew_find_ship(params["ship"]) + if(!ship) + set_notice("That ship has left.", "warn") + return TRUE + switch(action) + if("crew_sign_on") + return crew_sign_on(player, ship, params["job"]) + if("crew_code") + return crew_enter_code(player, ship, params["code"]) + if("crew_apply") + return crew_apply(player, ship, params["note"]) + if("crew_ping") + return crew_ping(player, ship) + return FALSE + +/** + * Port is full: the classic Join button's rule plus the old late join's "first in line" + * rule, and unlike select_job_on_ship() it actually stops the join. Admins skip it. + * Puts the player in line and returns TRUE when they have to wait. + */ +/datum/lobby_terminal/proc/crew_port_full(mob/dead/new_player/player) + if(ckey(player.key) in GLOB.admin_datums) + return FALSE + var/relevant_cap + var/hard_popcap = CONFIG_GET(number/hard_popcap) + var/extreme_popcap = CONFIG_GET(number/extreme_popcap) + if(hard_popcap && extreme_popcap) + relevant_cap = min(hard_popcap, extreme_popcap) + else + relevant_cap = max(hard_popcap, extreme_popcap) + var/over_cap = relevant_cap && living_player_count() >= relevant_cap + var/behind = length(SSticker.queued_players) && SSticker.queued_players[1] != player + if(!over_cap && !behind) + return FALSE + var/queue_position = SSticker.queued_players.Find(player) + if(!queue_position) + SSticker.queued_players += player + queue_position = length(SSticker.queued_players) + set_notice("Port is full. You are number [queue_position] in line.", "warn") + return TRUE + +/// The gates SIGN ON checks before and after its sleep. Returns a short denial line, or null. +/datum/lobby_terminal/proc/crew_sign_on_denial(mob/dead/new_player/player, obj/structure/overmap/ship/ship, datum/job/job) + if(!SSticker?.IsRoundInProgress()) + return "The port is not open yet." + if(SSlag_switch.measures[DISABLE_NON_OBSJOBS]) + return "No berths are being assigned right now." + if(QDELETED(ship) || !(ship in get_joinable_ships())) + return "That ship has left." + if(!job || !(job in ship.job_slots) || ship.job_slots[job] <= 0) + return "That berth was just taken." + if(!ship.is_password_cleared(player.ckey)) + return "Their doors are locked." + if(cryo_rejoin_wait(player.ckey, ship)) + return "You left that crew too recently." + return null + +/datum/lobby_terminal/proc/crew_sign_on(mob/dead/new_player/player, obj/structure/overmap/ship/ship, job_ref) + var/datum/job/job = crew_find_job(ship, job_ref) + var/denial = crew_sign_on_denial(player, ship, job) + if(denial) + set_notice(denial, "warn") + return TRUE + if(crew_port_full(player)) + return TRUE + var/list/cached = crew_job_ok[REF(job)] + if(cached && !cached[1]) + set_notice("You aren't cleared for that job.", "warn") + return TRUE + if(!try_join_lock(player)) + return FALSE + INVOKE_ASYNC(src, PROC_REF(crew_finish_sign_on), player, ship, job) + return TRUE + +/// The sleeping half of SIGN ON: eligibility (may hit the ban DB), every gate again, then the spawn. Holds the join lock throughout. +/datum/lobby_terminal/proc/crew_finish_sign_on(mob/dead/new_player/player, obj/structure/overmap/ship/ship, datum/job/job) + var/player_ckey = player.ckey + var/eligible = SSjob.check_job_eligibility(player, job, "Lobby terminal sign on") == JOB_AVAILABLE + if(QDELETED(player) || !player.client || QDELETED(src)) + release_join_lock(player_ckey) + return + if(eligible) + eligible = job.special_check_latejoin(player.client) + if(!QDELETED(job)) + crew_job_ok[REF(job)] = list(!!eligible, world.time) + if(!eligible) + release_join_lock(player_ckey) + set_notice("You aren't cleared for that job.", "warn") + return + var/denial = crew_sign_on_denial(player, ship, job) + if(denial) + release_join_lock(player_ckey) + set_notice(denial, "warn") + return + if(crew_port_full(player)) + release_join_lock(player_ckey) + return + player.auto_deadmin_on_ready_or_latejoin() + set_notice("Waking aboard [ship.name] as [job.title].", "ok") + var/joined = player.AttemptSpawnOnShip(job, ship) + release_join_lock(player_ckey) + if(QDELETED(src)) + return + if(joined) + // Don't greet them with this if they come back to the lobby later. + notice = null + else if(!QDELETED(player) && player.client) + set_notice("Could not sign on. Try another berth.", "bad") + +/datum/lobby_terminal/proc/crew_enter_code(mob/dead/new_player/player, obj/structure/overmap/ship/ship, code) + if(!ship.join_password || ship.is_password_cleared(player.ckey)) + return TRUE + var/ship_ref = REF(ship) + // A new code on the ship wipes everyone's wrong-code count for it. + var/code_hash = md5(lowertext(ship.join_password)) + if(crew_code_seen[ship_ref] != code_hash) + crew_code_seen[ship_ref] = code_hash + clear_code_failures(null, ship_ref) + if(code_lock_wait(player.ckey, ship_ref)) + set_notice("Too many wrong codes. Wait a minute.", "bad") + return TRUE + // Raw on purpose: captains set the code through raw TGUI params, so the attempt stays raw + // too (as select_job_on_ship() does). It is only compared, never shown or logged. + var/attempt = trim("[code || ""]", SHIP_JOIN_PASSWORD_MAX_LEN + 1) + if(!length(attempt)) + return FALSE + if(!ship.check_join_password(attempt)) + if(note_code_failure(player.ckey, ship_ref)) + set_notice("Too many wrong codes. Wait a minute.", "bad") + else + set_notice("Wrong code.", "bad") + return TRUE + ship.password_cleared_ckeys[player.ckey] = TRUE + clear_code_failures(player.ckey, ship_ref) + log_game("[key_name(player)] entered the join code of ship [ship.name] at the lobby terminal") + set_notice(null) + return TRUE + +/datum/lobby_terminal/proc/crew_apply(mob/dead/new_player/player, obj/structure/overmap/ship/ship, note) + if(!ship.join_password || ship.is_password_cleared(player.ckey) || ship.find_crew_application(player.ckey)) + return TRUE + if(world.time < (crew_apply_cooldowns[player.ckey] || 0)) + set_notice("Wait a moment before applying again.", "warn") + return TRUE + // Raw on purpose, as the old menu does (encode = FALSE): the captain's TGUI escapes it, + // and it is html_encode()d at the one place it reaches chat. + note = trim("[note || ""]", SHIP_APPLICATION_MESSAGE_MAX_LEN + 1) + if(!length(note)) + set_notice("Write a note for the captain.", "warn") + return TRUE + crew_apply_cooldowns[player.ckey] = world.time + LOBBY_CREW_APPLY_COOLDOWN + ship.file_crew_application(player, note) + return TRUE + +/// PING CREW: the old join menu's ping, with the same shared cooldowns. +/datum/lobby_terminal/proc/crew_ping(mob/dead/new_player/player, obj/structure/overmap/ship/ship) + if(ship_has_open_slots(ship)) + return TRUE + if(crew_ping_wait(player.ckey, ship) > 0) + return TRUE + var/list/cooldowns = crew_ping_cooldowns() + cooldowns[player.ckey] = world.time + LOBBY_CREW_PING_COOLDOWN + COOLDOWN_START(ship, join_ping_cooldown, LOBBY_CREW_PING_COOLDOWN) + ship.ship_notify("Someone wants to join [html_encode(ship.name)]. Please open a job slot at the cryogenic oversight console.", "CREW REQUEST", SHIP_NOTIFY_NOTICE, 'voidcrew/sound/notify.ogg', 25) + to_chat(player, span_notice("Ping sent to [html_encode(ship.name)]'s crew.")) + log_game("[key_name(player)] pinged ship [ship.name] to request an open job slot.") + return TRUE + +#undef LOBBY_CREW_PING_COOLDOWN +#undef LOBBY_CREW_APPLY_COOLDOWN +#undef LOBBY_CREW_JOB_CHECK_TTL From 7366ab69e2fe87cd60e4ff698b97340547183ac0 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:22:46 -0800 Subject: [PATCH 05/26] Add the dock terminal shipyard backend with a single commission bill The shipyard section sends the hull catalog after the player opens it, sends each hull's preview images on demand, and caches parts and ownership off the UI tick. COMMISSION rebuilds the bill on the server, spends it once and records every unlock, then launches with the crew answer the terminal collected. spend_parts now refunds the classes it already took when a later class fails. --- voidcrew/edits/mobs/new_player.dm | 12 +- voidcrew/modules/lobby_terminal/lobby_yard.dm | 612 +++++++++++++++++- .../ship_purchase/ship_economy_database.dm | 47 +- .../modules/unit_tests/lobby_terminal_bill.dm | 201 +++++- 4 files changed, 851 insertions(+), 21 deletions(-) diff --git a/voidcrew/edits/mobs/new_player.dm b/voidcrew/edits/mobs/new_player.dm index 06a93b2919d..deb96839ade 100644 --- a/voidcrew/edits/mobs/new_player.dm +++ b/voidcrew/edits/mobs/new_player.dm @@ -118,6 +118,8 @@ /// Flag to prevent double-clicking ship spawn /mob/dead/new_player/var/spawning_ship = FALSE +/// Crew-leave answer the dock terminal already collected for requisition_free_hull() (TRUE founds a new crew). Null asks with a popup. +/mob/dead/new_player/var/lobby_form_new = null /** * Callback when player confirms their hull, theme and upgrade selections. @@ -130,12 +132,13 @@ spawn_ship_with_upgrades(template, upgrade_selections, selected_theme) /** - * Actually spawn the ship with the given upgrade selections and theme + * Actually spawn the ship with the given upgrade selections and theme. + * preset_form_new: the crew-leave answer the dock terminal already collected (lobby_terminal/lobby_yard.dm); null asks with a popup. */ -/mob/dead/new_player/proc/spawn_ship_with_upgrades(datum/map_template/shuttle/voidcrew/template, list/upgrade_selections, datum/ship_theme/selected_theme) +/mob/dead/new_player/proc/spawn_ship_with_upgrades(datum/map_template/shuttle/voidcrew/template, list/upgrade_selections, datum/ship_theme/selected_theme, preset_form_new = null) if(!template) return select_ship() - var/form_new = choose_new_reputation_crew(src) + var/form_new = isnull(preset_form_new) ? choose_new_reputation_crew(src) : preset_form_new if(isnull(form_new)) return var/datum/voidcrew_crew_record/registration_crew = mind?.recover_reputation_crew() @@ -193,7 +196,8 @@ if(!can_requisition_hull(src)) to_chat(src, span_warning("A berth has opened aboard the fleet.")) return select_ship() - var/form_new = choose_new_reputation_crew(src) + var/form_new = isnull(lobby_form_new) ? choose_new_reputation_crew(src) : lobby_form_new + lobby_form_new = null if(isnull(form_new)) return var/datum/voidcrew_crew_record/registration_crew = mind?.recover_reputation_crew() diff --git a/voidcrew/modules/lobby_terminal/lobby_yard.dm b/voidcrew/modules/lobby_terminal/lobby_yard.dm index 502b37382cc..5a2c7989305 100644 --- a/voidcrew/modules/lobby_terminal/lobby_yard.dm +++ b/voidcrew/modules/lobby_terminal/lobby_yard.dm @@ -1,2 +1,610 @@ -// SHIPYARD section (package P3). Override the section procs declared in _lobby_terminal.dm here: yard_data(), yard_static(), yard_act(). -// Placeholder from the shell package; the section owner replaces this file. +/** + * # Dock terminal: SHIPYARD (backend) + * + * Overrides the shipyard section procs declared in _lobby_terminal.dm. + * + * - yard_static(): the hull catalog, sent only after the player opens SHIPYARD. + * - yard_data(): parts, ownership and requisition state. DB-backed values are cached on the + * terminal and refreshed off the UI tick (yard_refresh()), never inside ui_data(). + * - yard_act(): yard_open, yard_preview (per-hull lazy preview images), yard_commission + * (one bill for the hull, finish and modules not owned yet, then launch), yard_requisition + * and yard_load (the ship-saves hook). + * + * The client sends a configuration, never a price. Every bill is rebuilt here from the shelf. + */ + +/// Cached DB values older than this are fetched again on the next shipyard action. +#define LOBBY_YARD_REFRESH_INTERVAL (60 SECONDS) +/// A join lock older than this, with no spawn in flight, is left over from a runtime and is dropped. +#define LOBBY_YARD_STALE_LOCK (3 MINUTES) + +/// The shelf as catalog entries for the terminal. Same for everyone; rebuilt when FREE_SHIPS changes. +GLOBAL_LIST(lobby_yard_catalog) +/// FREE_SHIPS value the catalog was built with. +GLOBAL_VAR(lobby_yard_catalog_free) +/// Hull type string -> preview image names for that hull, filled with the catalog. +GLOBAL_LIST_EMPTY(lobby_yard_preview_files) + +/datum/lobby_terminal + /// TRUE once this player opened SHIPYARD; the catalog is only sent after that. + var/yard_opened = FALSE + /// Cached parts account: class -> count, or null when there is no ledger (no DB, guest). + var/list/yard_account + /// Cached ownership: list("hulls" = list(), "finishes" = list(hull = ids), "modules" = list(hull = ids)). + var/list/yard_owned + /// world.time of the last finished refresh. + var/yard_refreshed_at = 0 + /// TRUE while a refresh is waiting on the DB. + var/yard_refreshing = FALSE + /// Hull type strings whose preview images were already sent to this client. + var/list/yard_previews_sent = list() + /// TRUE once the preview URL mappings went to the browser. + var/yard_mappings_sent = FALSE + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- + +/datum/lobby_terminal/yard_static(mob/user) + if(!yard_opened) + return null + return list("catalog" = lobby_yard_catalog()) + +/datum/lobby_terminal/yard_data(mob/user) + if(!yard_opened) + return null + return list( + "account" = yard_account, + "owned" = yard_owned, + "free_ships" = !!CONFIG_GET(flag/free_ships), + "can_requisition" = !!(SSticker?.IsRoundInProgress() && can_requisition_hull(user)), + "crew_change" = crew_change_name(user), + ) + +/// Fetches parts and ownership from the DB (sleeps) and caches them on the terminal. +/datum/lobby_terminal/proc/yard_refresh() + set waitfor = FALSE + if(yard_refreshing || !owner?.ckey) + return + yard_refreshing = TRUE + var/player_ckey = owner.ckey + var/datum/ship_economy_db/economy = GLOB.ship_economy_db + var/list/parts = economy?.get_parts(player_ckey) + var/list/hulls = get_effective_unlocked_ships(player_ckey) + var/list/theme_rows = economy?.get_unlocked_themes(player_ckey) || list() + var/list/module_rows = economy?.get_unlocked_upgrades(player_ckey) || list() + if(QDELETED(src)) + return + yard_refreshing = FALSE + yard_refreshed_at = world.time + yard_account = parts + var/list/owned_modules = lobby_yard_split_unlocks(module_rows) + // Builder Bay was merged into Engineering Bay; honour old purchases as the selector does. + var/list/pill_modules = owned_modules["[/datum/map_template/shuttle/voidcrew/pill]"] + if(pill_modules && ("pill_extra_builder" in pill_modules)) + pill_modules |= "pill_extra_engineering" + yard_owned = list( + "hulls" = hulls, + "finishes" = lobby_yard_split_unlocks(theme_rows), + "modules" = owned_modules, + ) + if(ui && !QDELETED(ui)) + SStgui.update_uis(src) + +/// "type|id" unlock rows -> list(type = list(ids)). +/proc/lobby_yard_split_unlocks(list/rows) + var/list/by_hull = list() + for(var/row in rows) + var/list/row_parts = splittext(row, "|") + if(length(row_parts) < 2) + continue + var/list/ids = by_hull[row_parts[1]] + if(!ids) + ids = list() + by_hull[row_parts[1]] = ids + ids |= row_parts[2] + return by_hull + +// --------------------------------------------------------------------------- +// Catalog +// --------------------------------------------------------------------------- + +/// The shelf as sent to the terminal (static data). Built once and cached globally. +/proc/lobby_yard_catalog() + var/free = !!CONFIG_GET(flag/free_ships) + if(GLOB.lobby_yard_catalog && GLOB.lobby_yard_catalog_free == free) + return GLOB.lobby_yard_catalog + ensure_ship_upgrades_initialized() + var/list/catalog = list() + for(var/datum/map_template/shuttle/voidcrew/hull as anything in get_purchasable_ship_templates()) + catalog += list(lobby_yard_hull_entry(hull)) + GLOB.lobby_yard_catalog = catalog + GLOB.lobby_yard_catalog_free = free + return catalog + +/// Part cost list with only the classes that cost something. +/proc/lobby_yard_cost(list/cost) + var/list/out = list() + for(var/part_class in cost) + var/amount = cost[part_class] + if(isnum(amount) && amount > 0) + out[part_class] = amount + return out + +/// Job definitions (theme/module job_slots format) -> list(list(title, slots)). +/proc/lobby_yard_crew_lines(list/definitions) + var/list/lines = list() + for(var/list/definition in definitions) + if(!definition["name"]) + continue + lines += list(list("[definition["name"]]", definition["slots"] || 0)) + return lines + +/// A slot key as players read it: the hull prefix dropped, words spaced, upper case. +/proc/lobby_yard_slot_label(datum/map_template/shuttle/voidcrew/hull, slot_key) + var/label = lowertext("[slot_key]") + var/list/path_parts = splittext("[hull.type]", "/") + var/leaf = path_parts[length(path_parts)] + var/underscore = findtext(leaf, "_") + var/prefix = "[underscore ? copytext(leaf, 1, underscore) : leaf]_" + if(findtext(label, prefix) == 1) + label = copytext(label, length(prefix) + 1) + label = replacetext(label, "_s_", "'s ") + label = replacetext(label, "_", " ") + return uppertext(label) + +/proc/lobby_yard_hull_entry(datum/map_template/shuttle/voidcrew/hull) + var/hull_id = "[hull.type]" + var/list/themes = get_themes_for_ship(hull.type) + var/list/modules = get_modules_for_ship(hull.type) + + var/list/slot_keys = list() + for(var/slot_key in hull.upgrade_slot_ids) + slot_keys |= slot_key + + var/list/finishes = list() + for(var/theme_id in themes) + var/datum/ship_theme/theme = themes[theme_id] + var/list/theme_slots = get_upgrade_slot_ids_for_theme(hull, theme) + for(var/slot_key in theme_slots) + slot_keys |= slot_key + finishes += list(list( + "id" = theme.id, + "name" = theme.name, + "desc" = theme.desc, + "cost" = lobby_yard_cost(theme.part_cost), + "def" = !!theme.is_default, + "slots" = theme_slots.Copy(), + "jobs" = lobby_yard_crew_lines(theme.job_slots), + )) + + var/list/slots = list() + for(var/slot_key in slot_keys) + slots += list(list("key" = slot_key, "label" = lobby_yard_slot_label(hull, slot_key))) + + var/list/module_entries = list() + for(var/module_id in modules) + var/datum/ship_upgrade_module/module = modules[module_id] + var/list/module_themes = null + if(islist(module.for_theme)) + var/list/theme_list = module.for_theme + module_themes = theme_list.Copy() + else if(module.for_theme) + module_themes = list("[module.for_theme]") + var/list/jobs = list() + jobs[""] = lobby_yard_crew_lines(module.job_slots_add) + for(var/theme_key in module.job_slots_add_by_theme) + jobs["[theme_key]"] = lobby_yard_crew_lines(module.job_slots_add_by_theme[theme_key]) + module_entries += list(list( + "id" = module.id, + "name" = module.name, + "desc" = module.desc, + "slot" = module.slot, + "cost" = lobby_yard_cost(module.part_cost), + "def" = !!module.is_default, + "themes" = module_themes, + "jobs" = jobs, + )) + + var/list/preview = build_ship_preview_data(hull) + GLOB.lobby_yard_preview_files[hull_id] = lobby_yard_preview_names(preview) + + var/list/entry = list( + "id" = hull_id, + "name" = hull.name, + "short" = hull.short_name || hull.name, + "desc" = hull.catalog_desc || generate_ship_description(hull), + "cost" = lobby_yard_cost(hull.part_requirements), + "free" = !!is_ship_free(hull), + "finishes" = finishes, + "slots" = slots, + "modules" = module_entries, + "preview" = preview, + ) + // Optional key (not in the shared types): a hull without finishes takes its crew from the template. + if(!length(themes)) + entry["jobs"] = lobby_yard_crew_lines(hull.job_slots) + return entry + +/// Every image name a hull's preview block refers to. +/proc/lobby_yard_preview_names(list/preview) + var/list/names = list() + if(!islist(preview)) + return names + var/list/hulls = preview["hulls"] + for(var/theme_id in hulls) + var/list/hull_entry = hulls[theme_id] + if(islist(hull_entry) && hull_entry["png"]) + names |= hull_entry["png"] + var/list/modules = preview["modules"] + for(var/module_id in modules) + var/list/module_entry = modules[module_id] + if(!islist(module_entry)) + continue + if(module_entry["png"]) + names |= module_entry["png"] + var/list/variants = module_entry["themes"] + for(var/theme_id in variants) + var/list/variant = variants[theme_id] + if(islist(variant) && variant["png"]) + names |= variant["png"] + return names + +/// A hull type string from the client, resolved only against the shelf. +/proc/lobby_yard_find_hull(hull_id) + if(!istext(hull_id) || !hull_id) + return null + for(var/datum/map_template/shuttle/voidcrew/hull as anything in get_purchasable_ship_templates()) + if("[hull.type]" == hull_id) + return hull + return null + +/** + * Turns a client fit into a theme and slot selections, the way the selector validates + * them. Unnamed slots take their default module. Returns list(theme, selections), or a + * string saying why the fit is refused. + */ +/proc/lobby_yard_resolve_fit(datum/map_template/shuttle/voidcrew/hull, finish_id, list/module_ids) + var/list/themes = get_themes_for_ship(hull.type) + var/datum/ship_theme/theme = null + if(length(themes)) + if(finish_id) + theme = themes["[finish_id]"] + if(!theme) + return "That finish isn't offered on this hull." + else + theme = get_default_theme_for_ship(hull.type) + var/list/modules = get_modules_for_ship(hull.type) + var/list/selections = list() + for(var/slot_key in get_upgrade_slot_ids_for_theme(hull, theme)) + var/module_id = islist(module_ids) ? module_ids[slot_key] : null + if(module_id) + var/datum/ship_upgrade_module/module = modules["[module_id]"] + if(!module || module.slot != slot_key || !is_module_available_for_theme(module, theme?.id)) + return "That fit isn't offered on this hull." + selections[slot_key] = module + continue + var/datum/ship_upgrade_module/default_module = get_default_module_for_ship_slot(hull.type, slot_key) + if(default_module && is_module_available_for_theme(default_module, theme?.id)) + selections[slot_key] = default_module + return list(theme, selections) + +// --------------------------------------------------------------------------- +// The bill +// --------------------------------------------------------------------------- + +/** + * Lines of a commission bill: every item in the fit the player doesn't own yet and that + * costs parts. Each line is list("kind" = "hull"/"finish"/"module", "id", "name", "cost"). + * Pure: the caller supplies ownership, so the math can be tested without a database. + */ +/proc/lobby_yard_bill_lines(datum/map_template/shuttle/voidcrew/hull, datum/ship_theme/theme, list/selections, hull_owned, list/owned_theme_ids, list/owned_module_ids) + var/list/lines = list() + if(!hull_owned && !is_ship_free(hull)) + var/list/hull_cost = lobby_yard_cost(hull.part_requirements) + if(length(hull_cost)) + lines += list(list("kind" = "hull", "id" = "[hull.type]", "name" = hull.name, "cost" = hull_cost)) + if(theme && !theme.is_default && !(theme.id in owned_theme_ids)) + var/list/theme_cost = lobby_yard_cost(theme.part_cost) + if(length(theme_cost)) + lines += list(list("kind" = "finish", "id" = theme.id, "name" = theme.name, "cost" = theme_cost)) + for(var/slot_key in selections) + var/datum/ship_upgrade_module/module = selections[slot_key] + if(!istype(module) || module.is_default || (module.id in owned_module_ids)) + continue + var/list/module_cost = lobby_yard_cost(module.part_cost) + if(length(module_cost)) + lines += list(list("kind" = "module", "id" = module.id, "name" = module.name, "cost" = module_cost)) + return lines + +/// Sum of a bill's lines: class -> parts. +/proc/lobby_yard_bill_total(list/lines) + var/list/total = list() + for(var/list/line in lines) + var/list/cost = line["cost"] + for(var/part_class in cost) + total[part_class] = (total[part_class] || 0) + cost[part_class] + return total + +/** + * Pays a commission bill: reads ownership and parts (sleeps on the DB), spends the total + * once, then records every unlock. Returns TRUE when paid (or nothing was owed), or a + * short line saying why not. An unlock write that fails after paying is logged and + * reported to admins; the commission still goes ahead, since the player has paid. + */ +/datum/lobby_terminal/proc/yard_settle(mob/user, player_ckey, datum/map_template/shuttle/voidcrew/hull, datum/ship_theme/theme, list/selections, datum/ship_economy_db/economy = GLOB.ship_economy_db) + if(CONFIG_GET(flag/free_ships)) + return TRUE + var/hull_id = "[hull.type]" + var/hull_owned = is_ship_free(hull) + if(!hull_owned) + if(!economy) + return "The shipyard ledger is offline." + hull_owned = economy.is_ship_unlocked(player_ckey, hull_id) + var/list/owned_themes = economy?.get_unlocked_themes_for_ship(player_ckey, hull_id) || list() + var/list/owned_modules = economy?.get_unlocked_upgrades_for_ship(player_ckey, hull_id) || list() + if(hull.type == /datum/map_template/shuttle/voidcrew/pill && ("pill_extra_builder" in owned_modules)) + owned_modules |= "pill_extra_engineering" + + var/list/lines = lobby_yard_bill_lines(hull, theme, selections, hull_owned, owned_themes, owned_modules) + if(!length(lines)) + return TRUE + if(!economy) + return "The shipyard ledger is offline." + var/list/total = lobby_yard_bill_total(lines) + var/list/parts = economy.get_parts(player_ckey) + if(!parts) + return "The shipyard ledger is offline." + for(var/part_class in total) + if((parts[part_class] || 0) < total[part_class]) + return "Not enough parts for this bill." + if(!economy.spend_parts(player_ckey, total)) + return "The payment didn't go through. Nothing was charged." + + var/list/total_text = list() + for(var/part_class in total) + total_text += "[total[part_class]] [part_class]" + log_game("SHIP_CATALOG: [player_ckey] paid a lobby bill of [total_text.Join(", ")] for [hull_id] ([length(lines)] item\s)") + + for(var/list/line in lines) + var/written = FALSE + switch(line["kind"]) + if("hull") + written = economy.unlock_ship(player_ckey, hull_id) + if(written) + log_game("SHIP_CATALOG: [player_ckey] unlocked ship [hull_id] ([hull.name])") + ship_metric_hull_unlocked(user, hull) + if("finish") + written = economy.unlock_theme(player_ckey, hull_id, line["id"]) + if(written) + log_game("SHIP_UPGRADE: [player_ckey] purchased theme '[line["id"]]' for [hull_id]") + ship_metric_unlocked("ship_theme_unlocked", user, hull.type, line["id"], line["cost"], FALSE) + if("module") + written = economy.unlock_upgrade(player_ckey, hull_id, line["id"]) + if(written) + log_game("SHIP_UPGRADE: [player_ckey] purchased upgrade '[line["id"]]' for [hull_id]") + ship_metric_unlocked("ship_module_unlocked", user, hull.type, line["id"], line["cost"], FALSE) + if(!written) + log_game("SHIP_CATALOG ERROR: [player_ckey] paid for [line["kind"]] [line["id"]] on [hull_id] but the unlock was not recorded") + message_admins("Ship unlock failed for [player_ckey]: paid for [line["kind"]] [line["id"]] on [hull_id], but it was not recorded. Check the game log.") + return TRUE + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +/datum/lobby_terminal/yard_act(action, list/params, mob/user) + var/mob/dead/new_player/player = user + if(!istype(player)) + return FALSE + switch(action) + if("yard_open") + if(!yard_opened) + yard_opened = TRUE + if(ui && !QDELETED(ui)) + update_static_data(player, ui) + yard_refresh() + return TRUE + if("yard_preview") + yard_send_preview(params["hull"]) + return FALSE + if("yard_commission") + return yard_commission(player, params) + if("yard_requisition") + return yard_requisition(player, params) + if("yard_load") + return yard_load(player, params) + return FALSE + +/// Sends one hull's preview images to this client, once. +/datum/lobby_terminal/proc/yard_send_preview(hull_id) + if(!yard_opened || !owner || !istext(hull_id) || (hull_id in yard_previews_sent)) + return + lobby_yard_catalog() // fills the preview file index + var/list/names = GLOB.lobby_yard_preview_files[hull_id] + if(isnull(names)) // not on the shelf + return + yard_previews_sent += hull_id + if(!length(names)) + return + var/datum/asset/previews = get_asset_datum(/datum/asset/simple/ship_previews) + SSassets.transport.send_assets(owner, names) + if(!yard_mappings_sent && window) + yard_mappings_sent = TRUE + window.send_raw_message(previews.get_serialized_url_mappings()) + +/** + * Why a join route can't start right now, or null. Same gates as the Join key: round + * running, admin late-join switch, popcap queue. + */ +/datum/lobby_terminal/proc/yard_route_denial(mob/dead/new_player/player) + if(!SSticker?.IsRoundInProgress()) + return "The port is not open yet." + if(SSlag_switch.measures[DISABLE_NON_OBSJOBS]) + return "No berths are being assigned right now." + if(ckey(player.key) in GLOB.admin_datums) + return null + var/relevant_cap + var/hard_popcap = CONFIG_GET(number/hard_popcap) + var/extreme_popcap = CONFIG_GET(number/extreme_popcap) + if(hard_popcap && extreme_popcap) + relevant_cap = min(hard_popcap, extreme_popcap) + else + relevant_cap = max(hard_popcap, extreme_popcap) + var/full = relevant_cap && living_player_count() >= relevant_cap + if(SSticker.queued_players.len) + if(full || player != SSticker.queued_players[1]) + return "Port is full. Wait for your turn in line." + else if(full) + return "Port is full. Press JOIN to get in line." + return null + +/** + * Takes the shared join lock, dropping one left over from a runtime first. FALSE when + * another route is running for this player. + */ +/datum/lobby_terminal/proc/yard_take_lock(mob/dead/new_player/player) + var/locked_at = GLOB.lobby_join_locks[player.ckey] + if(locked_at && world.time - locked_at > LOBBY_YARD_STALE_LOCK && !player.spawning && !player.spawning_ship) + release_join_lock(player.ckey) + return try_join_lock(player) + +/** + * The crew-leave answer for a founding route. Returns TRUE (found a new crew and leave the + * old one), FALSE (nothing to leave), or null after asking the client to confirm. + */ +/datum/lobby_terminal/proc/yard_form_new(mob/dead/new_player/player, list/params) + var/crew_name = crew_change_name(player) + if(!crew_name) + return FALSE + if(text2num("[params["leave_crew"]]") == 1) + return TRUE + set_notice("This leaves [crew_name].", "warn") + return null + +/datum/lobby_terminal/proc/yard_commission(mob/dead/new_player/player, list/params) + var/denial = yard_route_denial(player) + if(denial) + set_notice(denial, "warn") + return TRUE + var/datum/map_template/shuttle/voidcrew/hull = lobby_yard_find_hull(params["hull"]) + if(!hull) + set_notice("That hull isn't on the shelf.", "bad") + return TRUE + var/fit = lobby_yard_resolve_fit(hull, params["finish"], params["modules"]) + if(!islist(fit)) + set_notice(fit, "bad") + return TRUE + var/list/fit_list = fit + var/form_new = yard_form_new(player, params) + if(isnull(form_new)) + return TRUE + if(!yard_take_lock(player)) + set_notice("Your ship is already being prepared.", "warn") + return TRUE + set_notice("Preparing your [hull.name].", "ok") + INVOKE_ASYNC(src, PROC_REF(run_commission), player, player.ckey, hull, fit_list[1], fit_list[2], form_new) + return TRUE + +/** + * Pays the bill and launches. The caller holds the join lock; this releases it on every + * path. Sleeps on the DB and on the ship load. + */ +/datum/lobby_terminal/proc/run_commission(mob/dead/new_player/player, player_ckey, datum/map_template/shuttle/voidcrew/hull, datum/ship_theme/theme, list/selections, form_new, datum/ship_economy_db/economy = GLOB.ship_economy_db) + var/paid = yard_settle(player, player_ckey, hull, theme, selections, economy) + if(QDELETED(src)) + release_join_lock(player_ckey) + return + yard_refresh_after_purchase() + if(paid != TRUE) + release_join_lock(player_ckey) + set_notice(paid, "bad") + return + // The player may have left or spawned while the DB answered. Their unlocks are kept. + if(QDELETED(player) || !player.client || player.client != owner) + release_join_lock(player_ckey) + return + // The crew they agreed to leave may have changed while the DB answered. + var/crew_name = crew_change_name(player) + if(!crew_name) + form_new = FALSE + else if(!form_new) + release_join_lock(player_ckey) + set_notice("This leaves [crew_name].", "warn") + return + log_game("[key_name(player)] commissioned [hull.name] ([hull.type]) from the dock terminal[theme ? " in [theme.id]" : ""]") + player.spawn_ship_with_upgrades(hull, selections, theme, form_new) + release_join_lock(player_ckey) + yard_report_failed_launch(player) + +/// Re-reads ownership after something may have been bought. +/datum/lobby_terminal/proc/yard_refresh_after_purchase() + if(yard_opened) + yard_refresh() + +/// After a launch call returns: if the player is still in the lobby, say why. +/datum/lobby_terminal/proc/yard_report_failed_launch(mob/dead/new_player/player) + if(QDELETED(src) || QDELETED(player) || !player.client || !istype(player.client.mob, /mob/dead/new_player)) + return + goto_tab("shipyard") + if(player.spawning_ship) + set_notice("Your ship is ready, but no berth is free aboard.", "bad") + else if(SSmapping.at_z_level_ceiling()) + set_notice("The shipyard is congested. What you paid for stays yours; try again shortly.", "bad") + else + set_notice("The shipyard could not prepare your ship. What you paid for stays yours.", "bad") + +/datum/lobby_terminal/proc/yard_requisition(mob/dead/new_player/player, list/params) + var/denial = yard_route_denial(player) + if(denial) + set_notice(denial, "warn") + return TRUE + if(!can_requisition_hull(player)) + set_notice("A berth has opened aboard the fleet.", "warn") + return TRUE + var/form_new = yard_form_new(player, params) + if(isnull(form_new)) + return TRUE + if(!yard_take_lock(player)) + set_notice("Your ship is already being prepared.", "warn") + return TRUE + set_notice("Requisitioning a hull.", "ok") + INVOKE_ASYNC(src, PROC_REF(run_requisition), player, player.ckey, form_new) + return TRUE + +/// requisition_free_hull() with the terminal's crew answer. Releases the join lock. +/datum/lobby_terminal/proc/run_requisition(mob/dead/new_player/player, player_ckey, form_new) + if(QDELETED(player)) + release_join_lock(player_ckey) + return + player.lobby_form_new = form_new + player.requisition_free_hull() + if(!QDELETED(player)) + player.lobby_form_new = null + release_join_lock(player_ckey) + yard_report_failed_launch(player) + +/datum/lobby_terminal/proc/yard_load(mob/dead/new_player/player, list/params) + var/denial = yard_route_denial(player) + if(denial) + set_notice(denial, "warn") + return TRUE + var/slot = text2num("[params["slot"]]") + if(!isnum(slot) || slot < 1 || slot != round(slot)) + return FALSE + var/form_new = yard_form_new(player, params) + if(isnull(form_new)) + return TRUE + if(!yard_take_lock(player)) + set_notice("Your ship is already being prepared.", "warn") + return TRUE + INVOKE_ASYNC(src, PROC_REF(run_load), player.ckey, slot, form_new) + return TRUE + +/// The ship-saves hook under the join lock. Releases it. +/datum/lobby_terminal/proc/run_load(player_ckey, slot, form_new) + load_saved_ship(slot, form_new) + release_join_lock(player_ckey) + +#undef LOBBY_YARD_REFRESH_INTERVAL +#undef LOBBY_YARD_STALE_LOCK diff --git a/voidcrew/modules/ship_purchase/ship_economy_database.dm b/voidcrew/modules/ship_purchase/ship_economy_database.dm index 8db629a1524..7bf896d6ce9 100644 --- a/voidcrew/modules/ship_purchase/ship_economy_database.dm +++ b/voidcrew/modules/ship_purchase/ship_economy_database.dm @@ -242,7 +242,7 @@ GLOBAL_DATUM_INIT(ship_economy_db, /datum/ship_economy_db, new) ckey = ckey(ckey) // Normalize ckey - if(!SSdbcore.IsConnected()) + if(!is_available()) return FALSE // First, check if player has sufficient parts @@ -258,23 +258,18 @@ GLOBAL_DATUM_INIT(ship_economy_db, /datum/ship_economy_db, new) log_game("SHIP_ECONOMY: [ckey] insufficient [part_class] parts (have: [have], need: [needed])") return FALSE - // Deduct each class + // Deduct each class. Each class is its own row, so this is not one transaction: when a + // later class fails, the classes already taken are put back (a single lobby bill + // often spans several classes). + var/list/deducted = list() for(var/part_class in requirements) var/amount = requirements[part_class] - - var/datum/db_query/query = SSdbcore.NewQuery( - "UPDATE [format_table_name("player_ship_parts")] \ - SET quantity = quantity - :amount \ - WHERE ckey = :ckey AND part_class = :part_class AND quantity >= :amount", - list("ckey" = ckey, "part_class" = part_class, "amount" = amount) - ) - - if(!query.Execute() || query.affected < 1) - qdel(query) + if(!deduct_part_class(ckey, part_class, amount)) log_game("SHIP_ECONOMY: [ckey] failed to deduct [amount] [part_class] parts (transaction failed)") + refund_parts(ckey, deducted) + invalidate_cache(ckey, "parts") return FALSE - - qdel(query) + deducted[part_class] = amount // Invalidate cache invalidate_cache(ckey, "parts") @@ -287,6 +282,30 @@ GLOBAL_DATUM_INIT(ship_economy_db, /datum/ship_economy_db, new) return TRUE +/// Whether the database can take writes. A proc so tests can stand in for it. +/datum/ship_economy_db/proc/is_available() + return SSdbcore.IsConnected() + +/// Takes amount parts of one class, only if the player has them. TRUE on success. +/datum/ship_economy_db/proc/deduct_part_class(ckey, part_class, amount) + var/datum/db_query/query = SSdbcore.NewQuery( + "UPDATE [format_table_name("player_ship_parts")] SET quantity = quantity - :amount WHERE ckey = :ckey AND part_class = :part_class AND quantity >= :amount", + list("ckey" = ckey, "part_class" = part_class, "amount" = amount) + ) + var/success = query.Execute() && query.affected >= 1 + qdel(query) + return success + +/// Puts back parts a failed spend_parts() already took: class -> amount. +/datum/ship_economy_db/proc/refund_parts(ckey, list/taken) + for(var/part_class in taken) + var/amount = taken[part_class] + if(add_part(ckey, part_class, amount, "refund")) + log_game("SHIP_ECONOMY: [ckey] refunded [amount] [part_class] parts after a failed spend") + continue + log_game("SHIP_ECONOMY ERROR: [ckey] lost [amount] [part_class] parts: the refund after a failed spend did not go through") + message_admins("Ship parts refund failed for [ckey]: [amount] [part_class] parts were taken but not returned. Check the game log.") + /** * Check if a ship template is unlocked for a player * diff --git a/voidcrew/modules/unit_tests/lobby_terminal_bill.dm b/voidcrew/modules/unit_tests/lobby_terminal_bill.dm index 66b68877287..0059d9b1a89 100644 --- a/voidcrew/modules/unit_tests/lobby_terminal_bill.dm +++ b/voidcrew/modules/unit_tests/lobby_terminal_bill.dm @@ -1 +1,200 @@ -// Lobby terminal bill test (package P3). Placeholder from the shell package; the owner replaces this file. +/** + * Dock terminal shipyard bill (voidcrew/modules/lobby_terminal/lobby_yard.dm). + * + * Covers the single commission bill: which lines it charges, the total, the refund when a + * multi-class spend fails halfway, the join lock being released on every path, and + * FREE_SHIPS charging nothing. A stand-in ledger replaces the database. + */ + +/// Ledger stand-in: balances in memory, one class can be made to fail. +/datum/ship_economy_db/lobby_bill_test + var/list/balance = list() + var/fail_class + var/list/refunded = list() + var/list/spent_calls = list() + var/list/unlocked = list() + +/datum/ship_economy_db/lobby_bill_test/is_available() + return TRUE + +/datum/ship_economy_db/lobby_bill_test/get_parts(ckey) + var/list/parts = list() + for(var/part_class in GLOB.ship_part_classes) + parts[part_class] = balance[part_class] || 0 + return parts + +/datum/ship_economy_db/lobby_bill_test/deduct_part_class(ckey, part_class, amount) + if(part_class == fail_class || (balance[part_class] || 0) < amount) + return FALSE + balance[part_class] -= amount + return TRUE + +/datum/ship_economy_db/lobby_bill_test/add_part(ckey, part_class, quantity = 1, method = "unspecified") + balance[part_class] = (balance[part_class] || 0) + quantity + if(method == "refund") + refunded[part_class] = (refunded[part_class] || 0) + quantity + return TRUE + +/datum/ship_economy_db/lobby_bill_test/spend_parts(ckey, list/requirements) + spent_calls += list(requirements.Copy()) + return ..() + +/datum/ship_economy_db/lobby_bill_test/is_ship_unlocked(ckey, ship_template) + return ("hull|[ship_template]" in unlocked) + +/datum/ship_economy_db/lobby_bill_test/unlock_ship(ckey, ship_template) + unlocked += "hull|[ship_template]" + return TRUE + +/datum/ship_economy_db/lobby_bill_test/get_unlocked_themes_for_ship(ckey, ship_template) + var/list/ids = list() + for(var/entry in unlocked) + var/list/entry_parts = splittext(entry, "|") + if(entry_parts[1] == "finish" && entry_parts[2] == "[ship_template]") + ids += entry_parts[3] + return ids + +/datum/ship_economy_db/lobby_bill_test/unlock_theme(ckey, ship_template, theme_id) + unlocked += "finish|[ship_template]|[theme_id]" + return TRUE + +/datum/ship_economy_db/lobby_bill_test/get_unlocked_upgrades_for_ship(ckey, ship_template) + var/list/ids = list() + for(var/entry in unlocked) + var/list/entry_parts = splittext(entry, "|") + if(entry_parts[1] == "module" && entry_parts[2] == "[ship_template]") + ids += entry_parts[3] + return ids + +/datum/ship_economy_db/lobby_bill_test/unlock_upgrade(ckey, ship_template, upgrade_id) + unlocked += "module|[ship_template]|[upgrade_id]" + return TRUE + +/// A terminal with no client or browser window, for calling the bill procs directly. +/datum/lobby_terminal/bill_test + +/datum/lobby_terminal/bill_test/New() + return // No client: skip the window and signal setup. + +/datum/unit_test/lobby_terminal_bill + var/datum/map_template/shuttle/voidcrew/hull + var/datum/ship_theme/finish + var/datum/ship_upgrade_module/paid_module + var/datum/ship_upgrade_module/stock_module + var/datum/ship_upgrade_module/owned_module + var/list/selections + /// FREE_SHIPS before the test, restored in Destroy(). + var/was_free_ships + +/datum/unit_test/lobby_terminal_bill/Destroy() + CONFIG_SET(flag/free_ships, was_free_ships) + return ..() + +/datum/unit_test/lobby_terminal_bill/Run() + was_free_ships = CONFIG_GET(flag/free_ships) + CONFIG_SET(flag/free_ships, FALSE) + for(var/datum/map_template/shuttle/voidcrew/candidate as anything in get_purchasable_ship_templates()) + if(length(lobby_yard_cost(candidate.part_requirements))) + hull = candidate + break + TEST_ASSERT(hull, "No hull on the shelf costs parts") + + finish = new + finish.id = "lobby_bill_finish" + finish.name = "Test Finish" + finish.part_cost = list("science" = 2) + + paid_module = new + paid_module.id = "lobby_bill_paid" + paid_module.slot = "slot_a" + paid_module.part_cost = list("combat" = 3, "science" = 1) + + stock_module = new + stock_module.id = "lobby_bill_stock" + stock_module.slot = "slot_b" + stock_module.is_default = TRUE + stock_module.part_cost = list("combat" = 50) + + owned_module = new + owned_module.id = "lobby_bill_owned" + owned_module.slot = "slot_c" + owned_module.part_cost = list("trade" = 4) + + selections = list("slot_a" = paid_module, "slot_b" = stock_module, "slot_c" = owned_module) + + check_bill_lines() + check_partial_spend_refund() + check_commission_paths() + check_free_ships() + +/datum/unit_test/lobby_terminal_bill/proc/check_bill_lines() + var/list/lines = lobby_yard_bill_lines(hull, finish, selections, FALSE, list(), list("lobby_bill_owned")) + TEST_ASSERT_EQUAL(length(lines), 3, "An unowned hull, finish and paid module should make three lines; stock and owned items are free") + var/list/expected = lobby_yard_cost(hull.part_requirements) + expected["science"] = (expected["science"] || 0) + 3 + expected["combat"] = (expected["combat"] || 0) + 3 + var/list/total = lobby_yard_bill_total(lines) + for(var/part_class in GLOB.ship_part_classes) + TEST_ASSERT_EQUAL(total[part_class] || 0, expected[part_class] || 0, "Bill total for [part_class] is wrong") + + lines = lobby_yard_bill_lines(hull, finish, selections, TRUE, list("lobby_bill_finish"), list("lobby_bill_owned")) + TEST_ASSERT_EQUAL(length(lines), 1, "Owned hull and finish still billed") + TEST_ASSERT_EQUAL(lines[1]["id"], "lobby_bill_paid", "The remaining line should be the paid module") + + finish.is_default = TRUE + lines = lobby_yard_bill_lines(hull, finish, list(), TRUE, list(), list()) + TEST_ASSERT_EQUAL(length(lines), 0, "A stock finish was billed") + finish.is_default = FALSE + +/datum/unit_test/lobby_terminal_bill/proc/check_partial_spend_refund() + var/datum/ship_economy_db/lobby_bill_test/ledger = new + ledger.balance = list("combat" = 10, "science" = 10) + ledger.fail_class = "science" + TEST_ASSERT(!ledger.spend_parts("lobbybilltest", list("combat" = 3, "science" = 2)), "A spend with a failing class went through") + TEST_ASSERT_EQUAL(ledger.refunded["combat"], 3, "The class taken before the failure was not refunded") + TEST_ASSERT_EQUAL(ledger.balance["combat"], 10, "Combat parts were lost on a failed spend") + TEST_ASSERT_EQUAL(ledger.balance["science"], 10, "Science parts changed on a failed spend") + +/datum/unit_test/lobby_terminal_bill/proc/check_commission_paths() + var/test_ckey = "lobbybilltest" + var/datum/lobby_terminal/bill_test/terminal = new + var/datum/ship_economy_db/lobby_bill_test/ledger = new + var/list/total = lobby_yard_bill_total(lobby_yard_bill_lines(hull, finish, selections, FALSE, list(), list())) + + // Short on parts: nothing spent, lock released, a notice. + GLOB.lobby_join_locks[test_ckey] = world.time + terminal.run_commission(null, test_ckey, hull, finish, selections, FALSE, ledger) + TEST_ASSERT(!GLOB.lobby_join_locks[test_ckey], "A refused bill kept the join lock") + TEST_ASSERT_EQUAL(length(ledger.spent_calls), 0, "A bill the player can't afford was charged") + TEST_ASSERT(terminal.notice, "A refused bill gave no notice") + + // Enough parts: one spend for the whole bill, every line unlocked, lock released. + for(var/part_class in total) + ledger.balance[part_class] = total[part_class] + 1 + GLOB.lobby_join_locks[test_ckey] = world.time + terminal.run_commission(null, test_ckey, hull, finish, selections, FALSE, ledger) + TEST_ASSERT(!GLOB.lobby_join_locks[test_ckey], "A paid bill kept the join lock") + TEST_ASSERT_EQUAL(length(ledger.spent_calls), 1, "The bill was not charged once") + for(var/part_class in total) + TEST_ASSERT_EQUAL(ledger.balance[part_class], 1, "Wrong [part_class] charge") + TEST_ASSERT(("hull|[hull.type]" in ledger.unlocked), "The hull was not unlocked") + TEST_ASSERT(("finish|[hull.type]|lobby_bill_finish" in ledger.unlocked), "The finish was not unlocked") + TEST_ASSERT(("module|[hull.type]|lobby_bill_paid" in ledger.unlocked), "The module was not unlocked") + TEST_ASSERT(!("module|[hull.type]|lobby_bill_stock" in ledger.unlocked), "A stock module was recorded as bought") + + // Everything owned now: a second commission costs nothing. + GLOB.lobby_join_locks[test_ckey] = world.time + terminal.run_commission(null, test_ckey, hull, finish, selections, FALSE, ledger) + TEST_ASSERT(!GLOB.lobby_join_locks[test_ckey], "A free relaunch kept the join lock") + TEST_ASSERT_EQUAL(length(ledger.spent_calls), 1, "Owned items were charged again") + qdel(terminal) + +/datum/unit_test/lobby_terminal_bill/proc/check_free_ships() + var/datum/lobby_terminal/bill_test/terminal = new + var/datum/ship_economy_db/lobby_bill_test/ledger = new + CONFIG_SET(flag/free_ships, TRUE) + var/result = terminal.yard_settle(null, "lobbybilltest", hull, finish, selections, ledger) + CONFIG_SET(flag/free_ships, FALSE) + TEST_ASSERT_EQUAL(result, TRUE, "FREE_SHIPS refused a commission") + TEST_ASSERT_EQUAL(length(ledger.spent_calls), 0, "FREE_SHIPS charged parts") + qdel(terminal) From 32d2b87b3bb0287f9dc6a79762a365c5664f3953 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:24:28 -0800 Subject: [PATCH 06/26] Tell a requisitioning player when a berth opened instead of a load failure --- voidcrew/modules/lobby_terminal/lobby_yard.dm | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/voidcrew/modules/lobby_terminal/lobby_yard.dm b/voidcrew/modules/lobby_terminal/lobby_yard.dm index 5a2c7989305..c3865e3d3af 100644 --- a/voidcrew/modules/lobby_terminal/lobby_yard.dm +++ b/voidcrew/modules/lobby_terminal/lobby_yard.dm @@ -582,6 +582,11 @@ GLOBAL_LIST_EMPTY(lobby_yard_preview_files) if(!QDELETED(player)) player.lobby_form_new = null release_join_lock(player_ckey) + // The fleet opened a berth while the request ran: requisition_free_hull() refused it. + if(!QDELETED(src) && !QDELETED(player) && player.client && istype(player.client.mob, /mob/dead/new_player) && !player.spawning_ship && !can_requisition_hull(player)) + goto_tab("crews") + set_notice("A berth has opened aboard the fleet.", "warn") + return yard_report_failed_launch(player) /datum/lobby_terminal/proc/yard_load(mob/dead/new_player/player, list/params) From acc8c6ef76445e438bed398fa2cbc7f865b15100 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:31:13 -0800 Subject: [PATCH 07/26] Draw the lobby terminal's small buttons from lobby sprite PNGs The batched spritesheet failed for the whole round when the first player reached the lobby before the asset subsystem had run. The five buttons now go out as PNGs cut from bottom_buttons.dmi in the terminal's own asset. --- .../interfaces/LobbyTerminal/Bezel.tsx | 21 +++++++++++++++---- .../modules/lobby_terminal/lobby_assets.dm | 19 +++++++---------- voidcrew/modules/lobby_terminal/lobby_tgui.dm | 5 +---- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx index 76795c08df6..e531afb55ce 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bezel.tsx @@ -8,6 +8,7 @@ * Without the art (static.bezel null) the same tree is drawn by PLACEHOLDER_CSS. */ import { type ReactNode, useState } from 'react'; +import { resolveAsset } from 'tgui/assets'; import { useBackend } from 'tgui/backend'; import type { LobbyData } from './types'; @@ -65,10 +66,22 @@ function SmallButton(props: { onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} > - - {!!mark && ( - - )} + + {mark ? ( + + ) : null} ); } diff --git a/voidcrew/modules/lobby_terminal/lobby_assets.dm b/voidcrew/modules/lobby_terminal/lobby_assets.dm index fc30d5acdfe..64fb6338b0a 100644 --- a/voidcrew/modules/lobby_terminal/lobby_assets.dm +++ b/voidcrew/modules/lobby_terminal/lobby_assets.dm @@ -2,8 +2,8 @@ * Terminal art and font. Runtime string paths, not 'compiled' resources, so the art stays * out of the compiled RSC and the external RSC zip and arrives with the page. * - * Only files that exist are registered: the bezel art (html/, drawn separately) can be - * missing, and then the client draws its placeholder bezel. + * Only art files that exist are registered: the bezel art (html/) can be missing, and then + * the client draws its placeholder bezel. */ /datum/asset/simple/namespaced/lobby_terminal @@ -19,17 +19,12 @@ for(var/name in art) if(fexists("[art_dir][name]")) assets[name] = "[art_dir][name]" + // The bezel's small buttons, cut from the real lobby sprites so a sprite change reaches the terminal too. + for(var/button in list("wiki", "poll", "crew_manifest", "settings", "changelog")) + for(var/suffix in list("", "_highlighted", "_disabled")) + assets["lt_[button][suffix].png"] = icon('icons/hud/lobby/bottom_buttons.dmi', "[button][suffix]", frame = 1) + assets["lt_new_poll.png"] = icon('icons/hud/lobby/poll_overlay.dmi', "new_poll") parents = list() if(fexists("[art_dir]lobby_terminal.css")) parents["lobby_terminal.css"] = "[art_dir]lobby_terminal.css" return ..() - -/// The bezel's small buttons, from the real lobby sprites so a sprite change reaches the terminal too. -/datum/asset/spritesheet_batched/lobby_terminal_buttons - name = "lobbyterminal" - -/datum/asset/spritesheet_batched/lobby_terminal_buttons/create_spritesheets() - for(var/button in list("wiki", "poll", "crew_manifest", "settings", "changelog")) - for(var/suffix in list("", "_highlighted", "_disabled")) - insert_icon("lt-[button][suffix]", uni_icon('icons/hud/lobby/bottom_buttons.dmi', "[button][suffix]")) - insert_icon("lt-new_poll", uni_icon('icons/hud/lobby/poll_overlay.dmi', "new_poll")) diff --git a/voidcrew/modules/lobby_terminal/lobby_tgui.dm b/voidcrew/modules/lobby_terminal/lobby_tgui.dm index b735b8be203..f41718c57c9 100644 --- a/voidcrew/modules/lobby_terminal/lobby_tgui.dm +++ b/voidcrew/modules/lobby_terminal/lobby_tgui.dm @@ -72,10 +72,7 @@ GLOBAL_DATUM_INIT(lobby_terminal_state, /datum/ui_state/lobby_terminal, new) hide_control() /datum/lobby_terminal/ui_assets(mob/user) - return list( - get_asset_datum(/datum/asset/simple/namespaced/lobby_terminal), - get_asset_datum(/datum/asset/spritesheet_batched/lobby_terminal_buttons), - ) + return list(get_asset_datum(/datum/asset/simple/namespaced/lobby_terminal)) /datum/lobby_terminal/ui_static_data(mob/user) var/list/data = list() From ba5e2a26daee640e677489c092b895bdb7d3d03d Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:31:43 -0800 Subject: [PATCH 08/26] Build the dock terminal shipyard screens SHIPYARD home with new ship, requisition and saved ship cards drawn from their floor plans; the hull registry and record; FIT with the live composited yard preview on a viewscreen and the slot being edited outlined; and the single BILL with commission. Leaving a crew shows a LEAVES line and turns the key into CONFIRM. Narrow windows get one panel at a time. Hull.jobs carries crew for hulls without finishes. --- .../interfaces/LobbyTerminal/Bill.tsx | 227 +++++++ .../interfaces/LobbyTerminal/Fit.tsx | 400 +++++++++++ .../interfaces/LobbyTerminal/HullRecord.tsx | 127 ++++ .../interfaces/LobbyTerminal/Hulls.tsx | 150 +++++ .../interfaces/LobbyTerminal/PlanPic.tsx | 153 +++++ .../interfaces/LobbyTerminal/SaveSlots.tsx | 337 ++++++++++ .../interfaces/LobbyTerminal/ShipPic.tsx | 152 +++++ .../interfaces/LobbyTerminal/YardTab.tsx | 633 +++++++++++++++++- .../interfaces/LobbyTerminal/types.ts | 2 + 9 files changed, 2169 insertions(+), 12 deletions(-) create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx create mode 100644 tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx new file mode 100644 index 00000000000..1bbfd0ef365 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Bill.tsx @@ -0,0 +1,227 @@ +/** + * BILL: the fitted ship and its crew on the left, the one bill on the right (each line, + * total, parts on account, any shortfall) and [ COMMISSION ]. When commissioning leaves a + * crew, one amber LEAVES line shows and the key reads CONFIRM after the first press. + * Narrow: the bill alone. + * + * The client sends the configuration, never prices; the server works out the bill again. + */ +import type { ReactNode } from 'react'; + +import { ShipPic } from './ShipPic'; +import { Frame, lpad, Put, pad, RPut, Rule, TOP, useGrid } from './term'; +import type { Hull, LobbyActions } from './types'; +import { + billOf, + costOf, + crewOf, + fitOf, + Key, + PART_ORDER, + partsOf, + usePreview, + type YardCtx, +} from './YardTab'; + +export function Bill(props: { + ctx: YardCtx; + hull: Hull; + onCommission: (params: LobbyActions['yard_commission']) => void; +}) { + const { ctx, hull, onCommission } = props; + const { view, set, own } = ctx; + const { cols, rows, narrow } = useGrid(); + usePreview(hull.id); + const h = rows - 1 - TOP; + const fit = fitOf(hull, view.finish, view.picks); + const bill = billOf(own, fit, ctx.account); + const crew = crewOf(fit); + const crewN = crew.reduce((a, l) => a + l[1], 0); + const out: ReactNode[] = []; + + const lw = narrow ? 0 : Math.floor(cols * 0.5); + if (!narrow) { + out.push( + , + ); + const ph = Math.floor(h * 0.55); + out.push( + , + ); + let y = TOP + ph + 1; + out.push(); + y++; + out.push( + + CREW + , + + {String(crewN)} + , + ); + y += 2; + const colW = Math.floor((lw - 6) / 2); + const per = Math.max(1, Math.ceil(crew.length / 2)); + crew.forEach(([job, n], i) => { + const cx = 3 + Math.floor(i / per) * colW; + const cy = y + (i % per); + if (cy >= TOP + h - 1) { + return; + } + out.push( + + {pad(job, colW - 5)} + , + + {lpad(n, 2)} + , + ); + }); + } + + const x = narrow ? 0 : lw + 1; + const w = cols - x; + out.push(); + let y = TOP + 2; + const by = TOP + h - 3; + const labelW = Math.min(14, Math.max(8, Math.floor(w * 0.25))); + const nameX = x + 3 + labelW + 1; + // Leave the bottom of the box to the total, account, warnings and keys. + const lineEnd = by - 7; + bill.lines.forEach(([label, name, cost], i) => { + if (y >= lineEnd) { + return; + } + const costText = cost ? costOf(cost) || '-' : '-'; + const nameRoom = x + w - 3 - nameX; + // A long price drops to its own row under the name. + const own = nameRoom - (costText.length + 2) < 10; + out.push( + + {pad(label, labelW)} + , + + {pad( + name, + own ? nameRoom : nameRoom - Math.max(9, costText.length + 2), + )} + , + ); + if (own) { + y++; + } + out.push( + + {costText.slice(-(w - 6))} + , + ); + y++; + }); + y++; + out.push(); + y += 2; + const short = Object.keys(bill.short).length > 0; + const due = partsOf(bill.total); + out.push( + + TOTAL + , + + {costOf(bill.total) || '-'} + , + ); + y++; + const accountText = PART_ORDER.map( + (k) => + `${ctx.account?.[k] || 0} ${narrow ? k.slice(0, 3).toUpperCase() : k.toUpperCase()}`, + ).join(' '); + out.push( + + {narrow ? 'HELD' : 'ON ACCOUNT'} + , + + {accountText} + , + ); + + const confirming = view.confirm === 'commission'; + const blocked = short || !ctx.open; + if (short) { + out.push( + + SHORT + , + + {costOf(bill.short)} + , + ); + } else if (!ctx.open) { + out.push( + + THE YARD OPENS AT DEPARTURE + , + ); + } else if (ctx.crewChange) { + out.push( + + {pad(`LEAVES ${ctx.crewChange.toUpperCase()}`, w - 6)} + , + ); + } + const commission = () => { + if (ctx.crewChange && !confirming) { + set({ confirm: 'commission' }); + return; + } + const modules: Record = {}; + for (const s of fit.slots) { + if (s.pick) { + modules[s.key] = s.pick.id; + } + } + onCommission({ + hull: hull.id, + finish: fit.finish?.id || '', + modules, + leave_crew: ctx.crewChange ? 1 : 0, + }); + }; + out.push( + , + set({ screen: 'fit' })} + />, + ); + + return <>{out}; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx new file mode 100644 index 00000000000..9ab6f781c14 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Fit.tsx @@ -0,0 +1,400 @@ +/** + * FIT: the finish, then each module slot. Wide: the step list with the running total and + * crew on the left, the viewscreen and this step's options on the right. Narrow: the + * viewscreen above the options, PREV/NEXT between steps. + */ +import type { ReactNode } from 'react'; + +import { ShipPic } from './ShipPic'; +import { + Frame, + Hit, + lpad, + Put, + pad, + RPut, + Rule, + TOP, + useGrid, + wrap, +} from './term'; +import type { CrewLine, Finish, Hull, Module } from './types'; +import { + billOf, + costOf, + crewOf, + finishOwned, + fitOf, + Key, + moduleOwned, + partsOf, + type ShipFit, + usePreview, + type YardCtx, +} from './YardTab'; + +type Step = { + label: string; + /** Finish step, or the slot key. */ + slot: string | null; + opts: (Finish | Module)[]; + pick: number; +}; + +function jobsText(jobs: CrewLine[]): string { + return jobs.map(([t, n]) => (n > 1 ? `${t} x${n}` : t)).join(', '); +} + +function stepsOf(fit: ShipFit): Step[] { + const steps: Step[] = []; + const { hull, finish } = fit; + if (hull.finishes.length) { + steps.push({ + label: 'FINISH', + slot: null, + opts: hull.finishes, + pick: Math.max( + 0, + hull.finishes.findIndex((f) => f.id === finish?.id), + ), + }); + } + for (const s of fit.slots) { + steps.push({ + label: s.label, + slot: s.key, + opts: s.options, + pick: Math.max( + 0, + s.options.findIndex((m) => m.id === s.pick?.id), + ), + }); + } + return steps; +} + +export function Fit(props: { ctx: YardCtx; hull: Hull }) { + const { ctx, hull } = props; + const { view, set, own } = ctx; + const { cols, rows, narrow } = useGrid(); + usePreview(hull.id); + const h = rows - 1 - TOP; + const fit = fitOf(hull, view.finish, view.picks); + const steps = stepsOf(fit); + if (!steps.length) { + return null; + } + const st = Math.min(steps.length - 1, Math.max(0, view.step)); + const S = steps[st]; + const bill = billOf(own, fit, ctx.account); + const crew = crewOf(fit); + const crewN = crew.reduce((a, l) => a + l[1], 0); + const out: ReactNode[] = []; + + const ownedOpt = (o: Finish | Module) => + S.slot === null + ? finishOwned(own, hull, o as Finish) + : moduleOwned(own, hull, o as Module); + const choose = (j: number) => { + const o = S.opts[j]; + if (S.slot === null) { + set({ finish: o.id }); + } else { + set({ picks: { ...view.picks, [S.slot]: o.id } }); + } + }; + const last = st === steps.length - 1; + const next = () => (last ? set({ screen: 'bill' }) : set({ step: st + 1 })); + + const x = narrow + ? 0 + : Math.min(48, Math.max(34, Math.floor(cols * 0.36))) + 1; + const w = cols - x; + const lw = x - 1; + + // The viewscreen takes what the busiest step's options leave, so it doesn't jump. + const descRows = (o: Finish | Module) => + Math.min(2, wrap(o.desc, w - 6).length); + const need = + Math.max( + ...steps.map((s) => { + let n = s.opts.length + Math.max(0, ...s.opts.map(descRows)); + if (s.slot === null) { + n += Math.max( + 0, + ...s.opts.map( + (o) => + Math.min(2, wrap(jobsText((o as Finish).jobs), w - 12).length) + + 1, + ), + ); + } + return n; + }), + ) + (narrow ? 7 : 6); + const ph = Math.max(narrow ? 6 : 9, Math.min(Math.floor(h * 0.62), h - need)); + + if (!narrow) { + // Step list, total and crew. + out.push( + , + ); + // Two rows a step when there is room, one when not. + const pitch = TOP + 2 + steps.length * 2 + 8 <= TOP + h ? 2 : 1; + let y = TOP + 2; + steps.forEach((s, i) => { + const o = s.opts[s.pick]; + const cur = i === st; + const paid = + !!o && + !(s.slot === null + ? finishOwned(own, hull, o as Finish) + : moduleOwned(own, hull, o as Module)); + if (cur) { + out.push( + + {' '.repeat(lw - 3)} + , + ); + } + out.push( + + {cur ? '>' : ' '} + , + + {pad(s.label, 14)} + , + + {pad(o ? o.name : '-', lw - 21)} + , + set({ step: i })} + />, + ); + y += pitch; + }); + if (pitch === 1) { + y++; + } + out.push(); + const tot = costOf(bill.total) || 'NOTHING DUE'; + const totLines = wrap(tot, lw - 13); + const short = Object.keys(bill.short).length > 0; + out.push( + + TOTAL + , + ); + totLines.forEach((l, i) => { + out.push( + + {l} + , + ); + }); + let cy = y + 3 + Math.max(0, totLines.length - 1); + const stop = TOP + h - 4; + if (cy + 2 < stop) { + out.push( + + CREW + , + + {String(crewN)} + , + ); + cy += 2; + if (cy + crew.length <= stop) { + crew.forEach(([job, n], i) => { + out.push( + + {pad(job, lw - 10)} + , + + {lpad(n, 2)} + , + ); + }); + } + } + out.push( + set({ screen: 'bill' })} + />, + ); + } + + // Viewscreen. + out.push( + , + , + ); + + // This step's options. + const oy = TOP + ph; + const oh = h - ph; + const title = narrow ? `${S.label} ${st + 1}/${steps.length}` : S.label; + out.push(); + let y = oy + 2; + const tagW = 18; + S.opts.forEach((o, j) => { + const sel = j === S.pick; + const owned = ownedOpt(o); + if (sel) { + out.push( + + {' '.repeat(w - 3)} + , + ); + } + const tag = o.def ? 'STOCK' : owned ? 'OWNED' : costOf(o.cost); + out.push( + + {sel ? '>' : ' '} + , + + {pad(o.name, w - 6 - Math.min(tagW, tag.length + 2))} + , + + {tag} + , + choose(j)} + />, + ); + y++; + }); + y++; + const o = S.opts[S.pick]; + const by = oy + oh - 2; + if (o) { + const jl = + S.slot === null ? wrap(jobsText((o as Finish).jobs), w - 12) : []; + const rowsFree = + by - (narrow ? 2 : 1) - y - (jl.length ? jl.length + 1 : 0); + wrap(o.desc, w - 6) + .slice(0, Math.max(1, rowsFree)) + .forEach((l, i) => { + out.push( + + {l} + , + ); + }); + if (jl.length) { + y++; + out.push( + + CREW + , + ); + jl.slice(0, Math.max(1, by - (narrow ? 2 : 1) - y)).forEach((l, i) => { + out.push( + + {l} + , + ); + }); + } + } + if (narrow) { + const tot = costOf(bill.total); + const short = Object.keys(bill.short).length > 0; + out.push( + + {`CREW ${crewN}`} + , + ); + if (tot) { + out.push( + + {tot.slice(0, w - 15)} + , + ); + } + if (!last) { + out.push( + set({ screen: 'bill' })} + />, + ); + } + } + if (st > 0) { + out.push( + set({ step: st - 1 })} + />, + ); + } + out.push( + , + ); + + return <>{out}; +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx new file mode 100644 index 00000000000..a75d3236651 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HullRecord.tsx @@ -0,0 +1,127 @@ +/** + * One hull's record: the stock fit on the viewscreen, model name, description, crew, + * rooms, finishes, price and [ FIT OUT ]. + */ +import type { ReactNode } from 'react'; + +import { ShipPic } from './ShipPic'; +import { Frame, Put, TOP, useGrid, wrap } from './term'; +import type { Hull } from './types'; +import { + costOf, + crewCount, + hullOwned, + Key, + stockFit, + usePreview, + type YardCtx, +} from './YardTab'; + +export function HullRecord(props: { + ctx: YardCtx; + hull: Hull; + x: number; + w: number; +}) { + const { ctx, hull, x, w } = props; + const { rows, narrow } = useGrid(); + usePreview(hull.id); + const h = rows - 1 - TOP; + const fit = stockFit(hull); + const picH = Math.max(6, Math.floor(h * 0.42)); + const out: ReactNode[] = []; + + out.push( + , + ); + let y = TOP + 2 + picH; + out.push( + + {hull.name.toUpperCase().slice(0, w - 6)} + , + ); + y++; + const by = TOP + h - 3; + const owned = hullOwned(ctx.own, hull); + // Leave room for the stats line and the price above the key. + const descRows = Math.max(1, by - (owned ? 1 : 3) - 2 - y); + wrap(hull.desc, w - 6) + .slice(0, descRows) + .forEach((l, i) => { + out.push( + + {l} + , + ); + }); + y++; + if (y < by - (owned ? 0 : 2)) { + out.push( + + CREW + , + + {String(crewCount(fit))} + , + + ROOMS + , + + {String(hull.slots.length)} + , + ); + if (hull.finishes.length && w >= 44) { + out.push( + + FINISHES + , + + {String(hull.finishes.length)} + , + ); + } + } + if (!owned) { + out.push( + + PRICE + , + + {costOf(hull.cost)} + , + ); + } + out.push( + + ctx.set({ screen: 'fit', finish: null, picks: {}, step: 0 }) + } + />, + ); + if (!narrow) { + out.push( + ctx.set({ hull: null })} + > + {' X '} + , + ); + } + + return ( + + {out} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx new file mode 100644 index 00000000000..2edb1089ccf --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/Hulls.tsx @@ -0,0 +1,150 @@ +/** + * HULLS registry. Wide: the list, and the selected hull's record beside it. Narrow: the + * list, or the record on its own screen ([ BACK ] returns to the list). + */ +import { type ReactNode, useMemo } from 'react'; + +import { HullRecord } from './HullRecord'; +import { Frame, Hit, lpad, Put, pad, TOP, useGrid } from './term'; +import { + crewCount, + hullOwned, + partsOf, + stockFit, + type YardCtx, +} from './YardTab'; + +export function Hulls(props: { ctx: YardCtx }) { + const { ctx } = props; + const { catalog, hull, own } = ctx; + const { cols, rows, narrow } = useGrid(); + const h = rows - 1 - TOP; + const crews = useMemo( + () => catalog.map((hl) => crewCount(stockFit(hl))), + [catalog], + ); + + if (narrow && hull) { + return ; + } + + const split = !!hull && !narrow; + const lw = split ? Math.max(40, Math.floor(cols * 0.41)) : cols; + // Columns, right to left from the price. + const price = lw - 14; + const rooms = price - 8; + const crew = rooms - 6; + const fin = split ? 0 : price - 9; + const showModel = !split && crew - 28 >= 20; + const showFin = !split && !narrow; + const roomsAt = showFin ? fin - 8 : rooms; + const crewAt = showFin ? roomsAt - 7 : crew; + const nameEnd = showModel ? 27 : crewAt - 1; + + const out: ReactNode[] = []; + const hy = TOP + 1; + out.push( + + HULL + , + + CREW + , + + ROOMS + , + + PRICE + , + ); + if (showModel) { + out.push( + + MODEL + , + ); + } + if (showFin) { + out.push( + + FINISH + , + ); + } + const maxRows = Math.max(0, TOP + h - 2 - hy); + catalog.slice(0, maxRows).forEach((hl, i) => { + const y = hy + 1 + i; + const sel = hull?.id === hl.id; + const k = sel ? 'inv' : ''; + const owned = hullOwned(own, hl); + const cost = partsOf(hl.cost); + const priceText = owned + ? cost && !hl.free && !own.free + ? 'OWNED' + : 'FREE' + : `${cost} PARTS`; + if (sel) { + out.push( + + {' '.repeat(lw - 3)} + , + ); + } + out.push( + + {sel ? '>' : ' '} + , + + {pad(hl.short.toUpperCase(), nameEnd - 3)} + , + + {lpad(crews[i], 3)} + , + + {lpad(hl.slots.length, 3)} + , + + {priceText} + , + ); + if (showModel) { + out.push( + + {pad(hl.name, crewAt - 29)} + , + ); + } + if (showFin) { + out.push( + + {lpad(hl.finishes.length || '-', 3)} + , + ); + } + out.push( + ctx.set({ hull: sel ? null : hl.id })} + />, + ); + }); + + return ( + <> + + {out} + {split && !!hull && ( + + )} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx new file mode 100644 index 00000000000..6906b480afe --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/PlanPic.tsx @@ -0,0 +1,153 @@ +/** + * A saved ship as a line drawing from its survey plan string: rows split by '/', each a + * run-length list of cells (`12o` = twelve floor). '#' wall, 'g' window, 'o' floor, + * 'e' engine, 'w' weapon, 's' sensor, 't' cargo, '.' nothing. Walls are joined centre to + * centre. A tall hull lies on its side when the space is wide. + */ +import { useMemo } from 'react'; + +import { useGrid } from './term'; + +function parsePlan(plan: string): string[][] { + return plan.split('/').map((row) => { + const out: string[] = []; + for (const m of row.matchAll(/(\d*)(\D)/g)) { + const n = m[1] ? Number(m[1]) : 1; + for (let i = 0; i < n; i++) { + out.push(m[2]); + } + } + return out; + }); +} + +export function PlanPic(props: { + x: number; + y: number; + w: number; + h: number; + plan: string; + /** Drawn dark (the ship can't be loaded). */ + faded?: boolean; +}) { + const { x, y, w, h, plan, faded } = props; + const { cw, ch } = useGrid(); + const aw = w * cw; + const ah = h * ch; + + const drawing = useMemo(() => { + let rows = parsePlan(plan); + let W = Math.max(0, ...rows.map((r) => r.length)); + let H = rows.length; + if (!W || !H) { + return null; + } + if (H > W * 1.15 && aw > ah) { + const rot: string[][] = []; + for (let cx = 0; cx < W; cx++) { + rot.push([]); + for (let cy = H - 1; cy >= 0; cy--) { + rot[cx].push(rows[cy][cx] || '.'); + } + } + rows = rot; + [W, H] = [H, W]; + } + const k = Math.min(aw / W, ah / H); + const at = (cx: number, cy: number) => rows[cy]?.[cx] || '.'; + const wall = (c: string) => c === '#' || c === 'g'; + let floor = ''; + let lines = ''; + let glass = ''; + let eng = ''; + const dots: [number, number, string][] = []; + for (let cy = 0; cy < H; cy++) { + for (let cx = 0; cx < W; cx++) { + const c = at(cx, cy); + if (c === '.') { + continue; + } + const px = (cx + 0.5) * k; + const py = (cy + 0.5) * k; + floor += `M${cx * k} ${cy * k}h${k}v${k}h-${k}z`; + if (wall(c)) { + let lone = true; + for (const [dx, dy] of [ + [1, 0], + [0, 1], + ]) { + const n = at(cx + dx, cy + dy); + if (wall(n)) { + const seg = `M${px} ${py}L${px + dx * k} ${py + dy * k}`; + if (c === 'g' || n === 'g') { + glass += seg; + } else { + lines += seg; + } + lone = false; + } + } + if (wall(at(cx - 1, cy)) || wall(at(cx, cy - 1))) { + lone = false; + } + if (lone) { + lines += `M${px - k * 0.3} ${py}h${k * 0.6}`; + } + } else if (c === 'e') { + eng += `M${cx * k + k * 0.15} ${cy * k + k * 0.15}h${k * 0.7}v${k * 0.7}h-${k * 0.7}z`; + } else if (c === 'w' || c === 's' || c === 't') { + dots.push([px, py, c]); + } + } + } + return { W, H, k, floor, lines, glass, eng, dots }; + }, [plan, aw, ah]); + + if (!drawing) { + return null; + } + const { W, H, k, floor, lines, glass, eng, dots } = drawing; + const ph = faded ? '#2b6450' : '#7effbf'; + const dim = faded ? '#1d4636' : '#3f9a76'; + const amb = faded ? '#5a4a15' : '#ffc41f'; + const red = faded ? '#5a2a30' : '#ff5f6f'; + const sw = Math.max(1.2, k * 0.18); + return ( +
+ + + + + + {dots.map(([cx, cy, c], i) => ( + + ))} + +
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx new file mode 100644 index 00000000000..80246c0e3b8 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx @@ -0,0 +1,337 @@ +/** + * SAVED SHIPS box on the shipyard home: one card per slot with the ship's floor plan, + * name, class, what it carries and LOAD, a denial line, or UNDER WAY. Too narrow for three + * cards, it becomes a one-line-per-slot list. + */ +import type { ReactNode } from 'react'; +import { useBackend } from 'tgui/backend'; + +import { PlanPic } from './PlanPic'; +import { Blocks, Frame, Put, pad, RPut, wrap } from './term'; +import type { LobbyData, SaveCard } from './types'; +import { Key, thousands, type YardCtx } from './YardTab'; + +const MIN_CARD = 26; + +type Props = { + saves: (SaveCard | null)[]; + x: number; + y: number; + w: number; + h: number; + ctx: YardCtx; +}; + +/** LOAD, or CONFIRM after the first press when loading leaves a crew. */ +function useLoad(ctx: YardCtx) { + const { act } = useBackend(); + return (sv: SaveCard) => { + const key = `load${sv.slot}`; + if (ctx.crewChange && ctx.view.confirm !== key) { + ctx.set({ confirm: key }); + return; + } + ctx.set({ confirm: null }); + act('yard_load', { slot: sv.slot, leave_crew: ctx.crewChange ? 1 : 0 }); + }; +} + +export function SaveSlots(props: Props) { + const { saves, x, y, w, h, ctx } = props; + const gap = 2; + const n = Math.max(1, saves.length); + const cardW = Math.floor((w - 4 - gap * (n - 1)) / n); + if (cardW < MIN_CARD || h < 14) { + return ; + } + return ( + + {saves.map((sv, i) => ( + + ))} + + ); +} + +function Card(props: { + sv: SaveCard | null; + n: number; + x: number; + y: number; + w: number; + h: number; + ctx: YardCtx; +}) { + const { sv, n, x, y, w, h, ctx } = props; + const load = useLoad(ctx); + const out: ReactNode[] = []; + if (!sv) { + return ( + + + EMPTY + + + ); + } + const ix = x + 2; + const iw = w - 4; + const denied = !!sv.denial; + const fit = sv.fit || []; + + const lines: [string, number | null, string][] = fit.map((f) => [ + f[0], + f[1], + f[2] || 'red', + ]); + if (sv.lab) { + lines.push(['Lab machines', sv.lab, '']); + } + if (sv.cargo) { + lines.push(['Cargo machines', sv.cargo, 'amb']); + } + if (sv.research) { + lines.push([`Research, ${sv.research} nodes`, null, '']); + } + if (sv.funds) { + lines.push([`Holds ${thousands(sv.funds)} cr`, null, 'amb']); + } + + const nameRows = Math.min(2, wrap(sv.name.toUpperCase(), iw).length); + const hasStats = sv.mass !== undefined || sv.crew !== undefined; + // Rows below the lines: the key, or a two-line denial, or LEAVES + the key. + const foot = denied ? 2 : 1 + (ctx.crewChange ? 1 : 0); + // The plan shrinks (to 3 rows) before the first few fit lines are cut. + // Short cards drop the blank row under the stats. + const statRows = hasStats ? (h < 15 ? 1 : 2) : 1; + const fixed = 4 + nameRows + 1 + statRows + foot; + const prH = Math.max(2, Math.min(11, h - fixed - Math.min(4, lines.length))); + if (sv.plan) { + out.push( + , + ); + } + let yy = y + 1 + prH + 1; + const nameLines = wrap(sv.name.toUpperCase(), iw); + const nm = nameLines.slice(0, 2); + if (nameLines.length > 2) { + nm[1] = `${pad(nm[1], iw - 1)}.`; + } + nm.forEach((l, i) => { + out.push( + + {l} + , + ); + }); + out.push( + + {pad(sv.class, iw)} + , + ); + if (hasStats) { + if (sv.mass !== undefined) { + out.push( + + MASS + , + + {`${thousands(sv.mass)} t`} + , + ); + } + if (sv.crew !== undefined) { + const cx = sv.mass !== undefined ? ix + 16 : ix; + out.push( + + CREW + , + + {String(sv.crew)} + , + ); + } + yy += statRows; + } else { + yy += 1; + } + + const room = Math.max(0, y + h - 2 - foot - yy); + const show = + lines.length > room ? lines.slice(0, Math.max(0, room - 1)) : lines; + show.forEach(([label, count, cls], i) => { + out.push( + , + + {pad(label, iw - 6)} + , + ); + if (count) { + out.push( + + {String(count)} + , + ); + } + yy++; + }); + if (room > 0 && show.length < lines.length) { + out.push( + + {`+${lines.length - show.length} more`} + , + ); + } + + const by = y + h - 2; + if (sv.denial) { + wrap(sv.denial, iw) + .slice(-2) + .reverse() + .forEach((l, i) => { + out.push( + + {l} + , + ); + }); + } else if (sv.locked) { + out.push( + + UNDER WAY + , + ); + } else { + const confirming = ctx.view.confirm === `load${sv.slot}`; + if (ctx.crewChange && confirming) { + out.push( + + {pad(`LEAVES ${ctx.crewChange.toUpperCase()}`, iw)} + , + ); + } + out.push( + load(sv)} + />, + ); + } + + return ( + + {out} + + ); +} + +/** Narrow windows: one line per slot. */ +function SaveList(props: Props) { + const { saves, x, y, w, h, ctx } = props; + const load = useLoad(ctx); + const out: ReactNode[] = []; + let yy = y + 2; + const keyW = 12; + saves.forEach((sv, i) => { + if (yy > y + h - 2) { + return; + } + const n = sv?.slot || i + 1; + out.push( + + {String(n)} + , + ); + if (!sv) { + out.push( + + EMPTY + , + ); + yy += 2; + return; + } + const nameW = Math.max(4, w - 6 - keyW - 4); + out.push( + + {pad(sv.name.toUpperCase(), nameW)} + , + ); + if (sv.denial) { + out.push( + + {pad(sv.denial, w - 9)} + , + ); + } else if (sv.locked) { + out.push( + + UNDER WAY + , + ); + } else { + const confirming = ctx.view.confirm === `load${sv.slot}`; + out.push( + + {pad(sv.class, w - 9)} + , + load(sv)} + />, + ); + } + yy += 3; + }); + if (ctx.crewChange && ctx.view.confirm?.startsWith('load')) { + out.push( + + {pad(`LEAVES ${ctx.crewChange.toUpperCase()}`, w - 6)} + , + ); + } + return ( + + {out} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx new file mode 100644 index 00000000000..20888487436 --- /dev/null +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/ShipPic.tsx @@ -0,0 +1,152 @@ +/** + * The yard's real full-colour preview on a recessed viewscreen: the finish's hull picture + * with each picked module laid on its slot, the same compositing as ShipPreview in + * ShipUpgradeSelector.tsx (module connector tile onto the slot marker tile). + * + * Scale: whole screen pixels per tile. 32 px tiles or more snap to a multiple of 32 and + * draw pixelated; anything smaller is a smooth downscale at a whole number of px per tile, + * so tiles never land between pixels. + */ +import type { CSSProperties, ReactNode } from 'react'; +import { resolveAsset } from 'tgui/assets'; + +import { useGrid } from './term'; +import type { ShipFit } from './YardTab'; + +type Geom = { + png: string; + width: number; + height: number; + connector?: [number, number]; +}; + +type PreviewData = { + tile_px?: number; + hulls: Record }>; + modules: Record }>; +}; + +const PAD = 10; + +export function ShipPic(props: { + /** Cells; fractions allowed. */ + x: number; + y: number; + w: number; + h: number; + fit: ShipFit; + /** Slot key to outline in amber, or null. */ + hiSlot?: string | null; +}) { + const { x, y, w, h, fit, hiSlot } = props; + const { cw, ch } = useGrid(); + const sx = Math.round(x * cw); + const sy = Math.round(y * ch); + const sw = Math.round(w * cw); + const sh = Math.round(h * ch); + const frame: CSSProperties = { + left: sx, + top: sy, + width: sw, + height: sh, + }; + + const pv = fit.hull.preview as PreviewData | null; + const themeKey = fit.finish?.id || ''; + const hull = pv?.hulls?.[themeKey] || pv?.hulls?.['']; + if (!pv || !hull || sw <= PAD * 2 || sh <= PAD * 2) { + return ( +
+
+
+ ); + } + + const tile = pv.tile_px || 32; + const aw = sw - PAD * 2; + const ah = sh - PAD * 2; + let d = Math.floor(Math.min(aw / hull.width, ah / hull.height)); + const crisp = d >= tile; + if (crisp) { + d = Math.floor(d / tile) * tile; + } + d = Math.max(1, d); + const iw = hull.width * d; + const ih = hull.height * d; + const rendering = crisp ? 'pixelated' : 'auto'; + + const layers: ReactNode[] = [ + , + ]; + for (const slot of fit.slots) { + const marker = hull.slots?.[slot.key]; + const mod = slot.pick ? pv.modules?.[slot.pick.id] : undefined; + if (!marker || !mod) { + continue; + } + const g = (themeKey && mod.themes?.[themeKey]) || mod; + const [cx, cy] = g.connector || [1, 1]; + const l = (marker[0] - cx) * d; + const t = (hull.height - (marker[1] - cy) - g.height) * d; + layers.push( + , + ); + if (slot.key === hiSlot) { + layers.push( +
, + ); + } + } + + return ( +
+
+ {layers} +
+
+
+ ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx index b4021a975be..957ed99f3c4 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx @@ -1,25 +1,634 @@ /** - * SHIPYARD tab. STUB from the shell package: package P4 owns and replaces this file. - * Draw only with the term.tsx primitives; content lives between rows TOP and rows - 2. - * The catalog arrives in static data only after `yard_open`. data/static keys: see types.ts. + * SHIPYARD tab: home (new ship, requisition, saved ships), then HULLS -> hull record -> + * FIT -> BILL. Where the player is and what they picked stays in React (a module-level + * store, so it survives a tab switch); only yard_* actions reach DM. + * + * The catalog arrives in static data only after `yard_open`. Keys: see types.ts. */ -import { useEffect } from 'react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { useBackend } from 'tgui/backend'; -import { Frame, Put, TOP, useGrid } from './term'; -import type { LobbyData } from './types'; +import { Bill } from './Bill'; +import { Fit } from './Fit'; +import { Hulls } from './Hulls'; +import { SaveSlots } from './SaveSlots'; +import { Frame, Put, RPut, TOP, useGrid } from './term'; +import type { + CrewLine, + Finish, + Hull, + LobbyData, + Module, + Owned, + PartClass, + Parts, +} from './types'; -export function YardTab() { +// --------------------------------------------------------------------------- +// Catalog helpers (shared by Hulls, HullRecord, Fit and Bill). +// --------------------------------------------------------------------------- + +export const PART_ORDER: PartClass[] = ['combat', 'science', 'trade', 'misc']; + +/** "4 COMBAT 2 TRADE", or '' when nothing is due. */ +export function costOf(cost?: Parts | null): string { + return PART_ORDER.filter((k) => (cost?.[k] || 0) > 0) + .map((k) => `${cost?.[k]} ${k.toUpperCase()}`) + .join(' '); +} + +export function partsOf(cost?: Parts | null): number { + return PART_ORDER.reduce((a, k) => a + (cost?.[k] || 0), 0); +} + +/** 1,850 */ +export function thousands(n: number): string { + return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +export type Ownership = { + owned: Owned | null | undefined; + /** FREE_SHIPS config: everything is free. */ + free: boolean; +}; + +export function hullOwned(o: Ownership, hull: Hull): boolean { + return ( + o.free || + !!hull.free || + !partsOf(hull.cost) || + !!o.owned?.hulls?.includes(hull.id) + ); +} + +export function finishOwned(o: Ownership, hull: Hull, f: Finish): boolean { + return ( + o.free || + !!f.def || + !partsOf(f.cost) || + !!o.owned?.finishes?.[hull.id]?.includes(f.id) + ); +} + +export function moduleOwned(o: Ownership, hull: Hull, m: Module): boolean { + return ( + o.free || + !!m.def || + !partsOf(m.cost) || + !!o.owned?.modules?.[hull.id]?.includes(m.id) + ); +} + +/** Upper-case label for a slot key: from the catalog, else the key without its hull prefix. */ +export function slotLabel(hull: Hull, key: string): string { + const known = hull.slots.find((s) => s.key === key); + let s = (known?.label || key).toLowerCase(); + const pre = `${key.split('_')[0]}_`; + if (!known && s.startsWith(pre)) { + s = s.slice(pre.length); + } + return s.replace(/_s_/g, "'s ").replace(/_/g, ' ').toUpperCase(); +} + +/** The default finish, or the first, or null for a hull without finishes. */ +export function defaultFinish(hull: Hull): Finish | null { + return hull.finishes.find((f) => !!f.def) || hull.finishes[0] || null; +} + +/** Modules for a slot under a finish, the stock one first. */ +export function slotOptions( + hull: Hull, + key: string, + finish: Finish | null, +): Module[] { + const opts = hull.modules.filter( + (m) => + m.slot === key && (!m.themes || !finish || m.themes.includes(finish.id)), + ); + return [...opts.filter((m) => !!m.def), ...opts.filter((m) => !m.def)]; +} + +export type FitSlot = { + key: string; + label: string; + options: Module[]; + pick: Module | null; +}; + +export type ShipFit = { + hull: Hull; + finish: Finish | null; + slots: FitSlot[]; +}; + +/** + * The configuration as it would be built. A pick the finish doesn't offer falls back to + * that finish's stock module, as the server resets picks on a finish change. + */ +export function fitOf( + hull: Hull, + finishId: string | null, + picks: Record, +): ShipFit { + const finish = + hull.finishes.find((f) => f.id === finishId) || defaultFinish(hull); + const keys: string[] = finish ? finish.slots : hull.slots.map((s) => s.key); + const slots = keys.map((key) => { + const options = slotOptions(hull, key, finish); + const pick = options.find((m) => m.id === picks[key]) || options[0] || null; + return { key, label: slotLabel(hull, key), options, pick }; + }); + return { hull, finish, slots }; +} + +/** The stock fit of a hull. */ +export function stockFit(hull: Hull): ShipFit { + return fitOf(hull, null, {}); +} + +/** Crew manifest of a fit, same titles summed. */ +export function crewOf(fit: ShipFit): CrewLine[] { + const out: CrewLine[] = []; + const add = (lines?: CrewLine[]) => { + for (const [title, n] of lines || []) { + const have = out.find((l) => l[0] === title); + if (have) { + have[1] += n; + } else { + out.push([title, n]); + } + } + }; + add(fit.finish ? fit.finish.jobs : fit.hull.jobs); + for (const s of fit.slots) { + if (s.pick) { + const jobs = s.pick.jobs || {}; + add((fit.finish && jobs[fit.finish.id]) || jobs[''] || []); + } + } + return out; +} + +export function crewCount(fit: ShipFit): number { + return crewOf(fit).reduce((a, l) => a + l[1], 0); +} + +/** [label, name, cost or null when owned] */ +export type BillLine = [string, string, Parts | null]; + +export type ShipBill = { + lines: BillLine[]; + total: Parts; + short: Parts; +}; + +export function billOf( + o: Ownership, + fit: ShipFit, + account: Parts | null | undefined, +): ShipBill { + const { hull, finish } = fit; + const lines: BillLine[] = [ + ['HULL', hull.short, hullOwned(o, hull) ? null : hull.cost], + ]; + if (finish) { + lines.push([ + 'FINISH', + finish.name, + finishOwned(o, hull, finish) ? null : finish.cost, + ]); + } + for (const s of fit.slots) { + if (s.pick) { + lines.push([ + s.label, + s.pick.name, + moduleOwned(o, hull, s.pick) ? null : s.pick.cost, + ]); + } + } + const total: Parts = {}; + for (const l of lines) { + for (const k of PART_ORDER) { + const n = l[2]?.[k] || 0; + if (n) { + total[k] = (total[k] || 0) + n; + } + } + } + const short: Parts = {}; + for (const k of PART_ORDER) { + const due = total[k] || 0; + const have = account?.[k] || 0; + if (due > have) { + short[k] = due - have; + } + } + return { lines, total, short }; +} + +// --------------------------------------------------------------------------- +// UI-local state. +// --------------------------------------------------------------------------- + +export type YardScreen = 'home' | 'hulls' | 'fit' | 'bill'; + +export type YardView = { + screen: YardScreen; + /** Selected hull id (HULLS record, FIT, BILL). */ + hull: string | null; + finish: string | null; + /** slot key -> module id */ + picks: Record; + step: number; + /** The key that has been pressed once and now reads CONFIRM. */ + confirm: string | null; +}; + +const HOME: YardView = { + screen: 'home', + hull: null, + finish: null, + picks: {}, + step: 0, + confirm: null, +}; + +/** Survives the tab unmounting (tab switch, the CONFIRMED box). */ +const store: { view: YardView } = { view: HOME }; +/** Hulls whose preview pictures have been asked for this session. */ +const previewed = new Set(); + +export type SetView = (patch: Partial) => void; + +function useYardView(): [YardView, SetView] { + const [view, setView] = useState(store.view); + const set: SetView = (patch) => + setView((prev) => { + const next = { ...prev, confirm: null, ...patch }; + store.view = next; + return next; + }); + return [view, set]; +} + +/** Ask once per hull for its preview pictures. */ +export function usePreview(hullId: string | null) { const { act } = useBackend(); - const { cols, rows } = useGrid(); useEffect(() => { - act('yard_open'); - }, []); + if (hullId && !previewed.has(hullId)) { + previewed.add(hullId); + act('yard_preview', { hull: hullId }); + } + }, [hullId]); +} + +/** Everything the screens need, worked out once per render. */ +export type YardCtx = { + view: YardView; + set: SetView; + catalog: Hull[]; + hull: Hull | null; + own: Ownership; + account: Parts | null | undefined; + /** Commissioning, requisition and loading are open (round running). */ + open: boolean; + crewChange: string | null; +}; + +// --------------------------------------------------------------------------- +// Shared bits of drawing. +// --------------------------------------------------------------------------- + +/** A `[ LABEL ]` key in any colour class; faint and dead when disabled. */ +export function Key(props: { + x: number; + y: number; + label: string; + cls?: string; + right?: boolean; + disabled?: boolean; + bg?: boolean; + onClick?: () => void; +}) { + const { x, y, label, cls = '', right, disabled, bg, onClick } = props; + const text = `[ ${label} ]`; + const at = right ? x - text.length : x; + if (disabled) { + return ( + + {text} + + ); + } + const amber = cls === 'amb' || cls === 'inva'; + return ( + + {text} + + ); +} + +/** Breadcrumb and [ BACK ] on the tab row, right-aligned. */ +function Crumbs(props: { ctx: YardCtx; screen: YardScreen }) { + const { ctx, screen } = props; + const { view, set, hull } = ctx; + const { cols } = useGrid(); + const seg: [string, (() => void) | null][] = [ + ['SHIPYARD', () => set({ screen: 'home', hull: null })], + [ + 'HULLS', + () => + set({ + screen: 'hulls', + hull: screen === 'hulls' ? null : view.hull, + }), + ], + ]; + if (hull && screen !== 'hulls') { + seg.push([hull.short.toUpperCase(), () => set({ screen: 'hulls' })]); + } + if (screen === 'fit' || screen === 'bill') { + seg.push(['FIT', () => set({ screen: 'fit' })]); + } + if (screen === 'bill') { + seg.push(['BILL', null]); + } + // The tab names end near column 41; drop leading crumbs until the rest fits. + const room = cols - 1 - 42 - 12; + const width = () => seg.map((s) => s[0]).join(' / ').length; + while (seg.length > 1 && width() > room) { + seg.shift(); + } + const showCrumbs = width() <= room; + const back: Record> = { + home: {}, + hulls: view.hull ? { hull: null } : { screen: 'home', hull: null }, + fit: { screen: 'hulls' }, + bill: { screen: 'fit' }, + }; + const out: ReactNode[] = []; + let x = cols - 1 - (showCrumbs ? width() : 0); + out.push( + set(back[screen])} + />, + ); + if (showCrumbs) { + seg.forEach(([label, go], i) => { + const last = i === seg.length - 1; + out.push( + + {label} + , + ); + x += label.length; + if (!last) { + out.push( + + {' / '} + , + ); + x += 3; + } + }); + } + return <>{out}; +} + +// --------------------------------------------------------------------------- +// Home: NEW SHIP, REQUISITION, SAVED SHIPS. +// --------------------------------------------------------------------------- + +function Home(props: { ctx: YardCtx }) { + const { ctx } = props; + const { data, act } = useBackend(); + const { cols, rows, narrow } = useGrid(); + const bottom = rows - 1; + const req = !!data.can_requisition; + const account = data.account || {}; + const confirmReq = ctx.view.confirm === 'req'; + const reqLine = ctx.crewChange + ? `LEAVES ${ctx.crewChange.toUpperCase()}` + : 'SURPLUS HULL'; + + const requisition = () => { + if (ctx.crewChange && !confirmReq) { + ctx.set({ confirm: 'req' }); + return; + } + ctx.set({ confirm: null }); + act('yard_requisition', { leave_crew: ctx.crewChange ? 1 : 0 }); + }; + const newShip = () => ctx.set({ screen: 'hulls', hull: null }); + + const out: ReactNode[] = []; + let th = 5; + const y = TOP + 2; + if (narrow) { + // One box: NEW SHIP and the parts on account, the surplus hull below. + th = req ? 6 : 5; + out.push( + , + , + ); + const short = PART_ORDER.map( + (k) => `${k.slice(0, 3).toUpperCase()} ${account[k] || 0}`, + ).join(' '); + if (cols - 20 >= short.length) { + out.push( + + {short} + , + ); + } + if (req) { + out.push( + + {reqLine.slice(0, cols - 24)} + , + , + ); + } + } else { + const lw = req ? Math.floor(cols * 0.62) : cols; + out.push( + , + , + ); + const segs = PART_ORDER.map((k) => [ + k.toUpperCase(), + String(account[k] || 0), + ]); + const partsW = + 7 + + segs.reduce((a, [k, n]) => a + k.length + 1 + n.length, 0) + + 3 * (segs.length - 1); + let x = lw - 3 - partsW; + out.push( + + PARTS + , + ); + x += 7; + for (const [k, n] of segs) { + out.push( + + {k} + , + + {n} + , + ); + x += k.length + 1 + n.length + 3; + } + if (req) { + const rx = lw + 1; + const rw = cols - lw - 1; + out.push( + , + + {reqLine.slice(0, Math.max(0, rw - 23))} + , + , + ); + } + } + + if (data.saves) { + out.push( + , + ); + } + return <>{out}; +} + +// --------------------------------------------------------------------------- + +function Waiting() { + const { cols, rows } = useGrid(); return ( - + - THE YARD IS CLOSED. + CONTACTING THE YARD... ); } + +export function YardTab() { + const { data, act } = useBackend(); + const [view, set] = useYardView(); + useEffect(() => { + act('yard_open'); + }, []); + + const catalog = useMemo(() => data.catalog || [], [data.catalog]); + const hull = useMemo( + () => catalog.find((h) => h.id === view.hull) || null, + [catalog, view.hull], + ); + const ctx: YardCtx = { + view, + set, + catalog, + hull, + own: { owned: data.owned, free: !!data.free_ships }, + account: data.account, + open: data.phase === 'playing', + crewChange: data.crew_change || null, + }; + + let screen = view.screen; + if ((screen === 'fit' || screen === 'bill') && !hull && !!data.catalog) { + screen = 'hulls'; + } + let body: ReactNode; + if (screen === 'home') { + body = ; + } else if (!data.catalog) { + body = ; + } else if (screen === 'hulls') { + body = ; + } else if (screen === 'fit' && hull) { + body = ; + } else if (hull) { + body = ( + { + // The client forgets the fit once it is sent. + set(HOME); + act('yard_commission', params); + }} + /> + ); + } + + return ( + <> + {screen !== 'home' && } + {body} + + ); +} diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts index c7c785fe236..7ec303f21d1 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts @@ -148,6 +148,8 @@ export type Hull = { finishes: Finish[]; slots: HullSlot[]; modules: Module[]; + /** Crew of a hull with no finishes (template job_slots). Hulls with finishes use Finish.jobs. */ + jobs?: CrewLine[]; /** build_ship_preview_data(hull), unchanged. */ preview: unknown; }; From 232509d629121ab4f5b8c4d3a8c27c6a724725aa Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:42:31 -0800 Subject: [PATCH 09/26] Integrate the dock terminal lobby packages Share one text field between CREWS and HOUSING, route the old join menu's outpost and ship password popups through the lobby code limiter, clear spawning_ship when a new hull can't seat its buyer, keep a refused commission's fit, return SHIPYARD to its front page when pressed again, limit the yard's DB refresh and the client metrics log, and tidy the bezel CSS comments. --- .../interfaces/LobbyTerminal/CrewRecord.tsx | 59 +-------------- .../interfaces/LobbyTerminal/HousingTab.tsx | 73 +++++-------------- .../interfaces/LobbyTerminal/SaveSlots.tsx | 2 +- .../interfaces/LobbyTerminal/YardTab.tsx | 32 ++++++-- .../interfaces/LobbyTerminal/index.tsx | 7 +- .../interfaces/LobbyTerminal/term.tsx | 67 +++++++++++++++++ .../interfaces/LobbyTerminal/types.ts | 2 + voidcrew/edits/mobs/new_player.dm | 28 +++++-- voidcrew/edits/mobs/ship_join_menu.dm | 6 +- .../lobby_terminal/html/lobby_terminal.css | 6 +- .../modules/lobby_terminal/lobby_bezel.dm | 7 ++ .../modules/lobby_terminal/lobby_crews.dm | 49 ++++++++----- .../modules/lobby_terminal/lobby_housing.dm | 37 ++++++++++ voidcrew/modules/lobby_terminal/lobby_yard.dm | 28 +++++-- 14 files changed, 246 insertions(+), 157 deletions(-) diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx index 162d9ffc510..595c8584e4b 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/CrewRecord.tsx @@ -8,13 +8,13 @@ import { useBackend } from 'tgui/backend'; import { Blocks, Btn, + Field, Frame, Put, pad, RPut, Rule, TOP, - useGrid, wrap, } from './term'; import type { Crew, LobbyData } from './types'; @@ -38,62 +38,6 @@ export function cut(str: string, n: number): string { /** Locked and not yet cleared to board. */ export const isLocked = (c: Crew) => !!c.locked && !c.cleared; -/** A one-line text field on the grid, drawn in amber inside [ ]. Enter submits. */ -function Field(props: { - x: number; - y: number; - w: number; - value: string; - max: number; - disabled?: boolean; - onChange: (value: string) => void; - onEnter: () => void; -}) { - const { x, y, w, value, max, disabled, onChange, onEnter } = props; - const { cw, ch } = useGrid(); - return ( - <> - - {`[${' '.repeat(w)}]`} - - onChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - onEnter(); - } - }} - style={{ - position: 'absolute', - left: Math.round((x + 1) * cw), - top: Math.round(y * ch), - width: Math.round(w * cw), - height: ch, - padding: 0, - margin: 0, - border: 'none', - outline: 'none', - background: 'transparent', - color: 'var(--lt-amb)', - caretColor: 'var(--lt-amb)', - font: 'inherit', - lineHeight: `${ch}px`, - textShadow: 'inherit', - userSelect: 'text', - zIndex: 6, - }} - /> - - ); -} - type Props = { crew: Crew; x: number; @@ -273,6 +217,7 @@ export function CrewRecord(props: Props) { value={code} max={CODE_MAX} disabled={waiting} + secret onChange={setCode} onEnter={sendCode} />, diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx index da61db13552..105172e10c2 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/HousingTab.tsx @@ -1,11 +1,21 @@ /** * HOUSING tab: player outposts taking residents (DM: lobby_housing.dm). - * Draws only with the term.tsx primitives, plus one input for the CODE field. + * Draws only with the term.tsx primitives. */ import { Fragment, type ReactNode, useState } from 'react'; import { useBackend } from 'tgui/backend'; -import { Blocks, Btn, type Cls, Frame, Put, Rule, TOP, useGrid } from './term'; +import { + Blocks, + Btn, + type Cls, + Field, + Frame, + Put, + Rule, + TOP, + useGrid, +} from './term'; import type { LobbyData, Outpost, OutpostMode } from './types'; const MODES: Record = { @@ -26,54 +36,6 @@ const MOVE_IN = '[ MOVE IN ]'; /** Widest key group at the right of a row: CODE [field] [ MOVE IN ]. */ const KEYS_W = 5 + FIELD + 3 + MOVE_IN.length; -/** A text field on the grid, n cells wide, drawn in the terminal font. */ -function CodeField(props: { - x: number; - y: number; - n: number; - value: string; - disabled: boolean; - onChange: (value: string) => void; - onEnter: () => void; -}) { - const { x, y, n, value, disabled, onChange, onEnter } = props; - const { cw, ch } = useGrid(); - return ( - onChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - onEnter(); - } - }} - style={{ - position: 'absolute', - left: x * cw, - top: y * ch, - width: n * cw, - height: ch, - padding: 0, - margin: 0, - border: 'none', - outline: 'none', - background: 'transparent', - color: disabled ? 'var(--lt-faint)' : 'var(--lt-amb)', - caretColor: 'var(--lt-amb)', - font: 'inherit', - lineHeight: 'inherit', - letterSpacing: 0, - zIndex: 6, - }} - /> - ); -} - export function HousingTab() { const { act, data } = useBackend(); const { cols, rows, narrow } = useGrid(); @@ -115,15 +77,14 @@ export function HousingTab() { CODE - - {`[${' '.repeat(FIELD)}]`} - - setCodes({ ...codes, [o.ref]: value })} onEnter={() => moveIn(o)} /> diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx index 80246c0e3b8..a676ec9959a 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/SaveSlots.tsx @@ -95,7 +95,7 @@ function Card(props: { const lines: [string, number | null, string][] = fit.map((f) => [ f[0], f[1], - f[2] || 'red', + f[2] ?? 'red', ]); if (sv.lab) { lines.push(['Lab machines', sv.lab, '']); diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx index 957ed99f3c4..c0001d8223a 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/YardTab.tsx @@ -255,7 +255,10 @@ const HOME: YardView = { }; /** Survives the tab unmounting (tab switch, the CONFIRMED box). */ -const store: { view: YardView } = { view: HOME }; +const store: { view: YardView; fitSerial: number | null } = { + view: HOME, + fitSerial: null, +}; /** Hulls whose preview pictures have been asked for this session. */ const previewed = new Set(); @@ -575,12 +578,31 @@ function Waiting() { ); } -export function YardTab() { +export function YardTab(props: { home: number }) { const { data, act } = useBackend(); const [view, set] = useYardView(); useEffect(() => { act('yard_open'); }, []); + // SHIPYARD pressed again while on it: back to the yard's front page. + const [home, setHome] = useState(props.home); + if (home !== props.home) { + setHome(props.home); + if (view.screen !== 'home') { + set(HOME); + } + } + // A commission put the player aboard: the fit was built, so forget it. A refusal keeps it. + const fitSerial = data.fit_serial; + useEffect(() => { + if (fitSerial === undefined) { + return; + } + if (store.fitSerial !== null && store.fitSerial !== fitSerial) { + set(HOME); + } + store.fitSerial = fitSerial; + }, [fitSerial]); const catalog = useMemo(() => data.catalog || [], [data.catalog]); const hull = useMemo( @@ -616,11 +638,7 @@ export function YardTab() { { - // The client forgets the fit once it is sent. - set(HOME); - act('yard_commission', params); - }} + onCommission={(params) => act('yard_commission', params)} /> ); } diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx index 0bfbbfe45b3..9f1cddb583d 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/index.tsx @@ -121,6 +121,8 @@ export function LobbyTerminal() { ); const [tab, setTab] = useState('crews'); + /** Bumped when SHIPYARD is pressed while already on it. */ + const [yardHome, setYardHome] = useState(0); const request = data.tab_request; useEffect(() => { if (request) { @@ -156,7 +158,7 @@ export function LobbyTerminal() { } else if (tab === 'crews') { body = ; } else if (tab === 'shipyard') { - body = ; + body = ; } else { body = ; } @@ -189,6 +191,9 @@ export function LobbyTerminal() {
{ + if (next === tab && next === 'shipyard') { + setYardHome(yardHome + 1); + } setTab(next); if (confirmed) { act('dismiss'); diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx index 8cb04e6070c..754a4d9788d 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/term.tsx @@ -139,6 +139,73 @@ export function Btn(props: BtnProps) { ); } +type FieldProps = { + /** Column of the opening bracket; the text starts one cell to the right. */ + x: number; + y: number; + /** Cells between the brackets. */ + w: number; + value: string; + max: number; + disabled?: boolean; + /** Mask the text (codes). */ + secret?: boolean; + onChange: (value: string) => void; + onEnter: () => void; +}; + +/** + * A one-line text field on the grid: `[ ]` in amber with an input over it. + * Enter submits. tgui-core's key passthrough ignores keys typed into an input, so typing + * here never reaches the player's BYOND hotkeys. + */ +export function Field(props: FieldProps) { + const { x, y, w, value, max, disabled, secret, onChange, onEnter } = props; + const { cw, ch } = useGrid(); + return ( + <> + + {`[${' '.repeat(w)}]`} + + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onEnter(); + } + }} + style={{ + position: 'absolute', + left: Math.round((x + 1) * cw), + top: Math.round(y * ch), + width: Math.round(w * cw), + height: ch, + padding: 0, + margin: 0, + border: 'none', + outline: 'none', + background: 'transparent', + color: disabled ? 'var(--lt-faint)' : 'var(--lt-amb)', + caretColor: 'var(--lt-amb)', + font: 'inherit', + lineHeight: `${ch}px`, + letterSpacing: 0, + textShadow: 'inherit', + userSelect: 'text', + zIndex: 6, + }} + /> + + ); +} + type FrameProps = { x: number; y: number; diff --git a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts index 1a9e277dfb0..caffe82edf5 100644 --- a/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts +++ b/tgui/packages/voidcrew_tgui/interfaces/LobbyTerminal/types.ts @@ -189,6 +189,8 @@ export type YardData = { can_requisition?: BooleanLike; /** Name of the crew the player would leave by founding a new one, or null. */ crew_change?: string | null; + /** Bumped when a commission put the player aboard: drop the fit. */ + fit_serial?: number; /** Saved ships, null entries for empty slots. Null hides the SAVED SHIPS box. Always sent by the shell. */ saves: (SaveCard | null)[] | null; }; diff --git a/voidcrew/edits/mobs/new_player.dm b/voidcrew/edits/mobs/new_player.dm index deb96839ade..01599264307 100644 --- a/voidcrew/edits/mobs/new_player.dm +++ b/voidcrew/edits/mobs/new_player.dm @@ -74,14 +74,24 @@ // Password gate. Saved clearance lasts until a password change or join access reset. // encode = FALSE: captains set the password through raw TGUI params, so the attempt // must stay raw too or any password with an HTML-special character never matches. + // Wrong guesses go through the dock terminal's code limiter (lobby_terminal/lobby_crews.dm). if(!ship.is_password_cleared(ckey)) + var/datum/lobby_terminal/terminal = client?.lobby_terminal + if(!terminal) + return + if(terminal.code_lock_wait(ckey, terminal.crew_code_key(ship))) + to_chat(src, span_warning("Too many wrong passwords for [ship.name]. Wait a minute.")) + return select_ship() var/attempt = tgui_input_text(src, "This ship is password-locked by its crew. Enter the join password.", "[ship.name] - Join Password", max_length = SHIP_JOIN_PASSWORD_MAX_LEN, encode = FALSE, timeout = 60 SECONDS) - if(isnull(attempt) || QDELETED(ship)) + if(isnull(attempt) || QDELETED(ship) || QDELETED(terminal)) return select_ship() // Cancelled, timed out, or the ship died mid-prompt - if(!ship.check_join_password(attempt)) - to_chat(src, span_warning("Incorrect join password for [ship.name].")) - return select_ship() - ship.password_cleared_ckeys[ckey] = TRUE + switch(terminal.crew_check_code(src, ship, attempt)) + if("locked") + to_chat(src, span_warning("Too many wrong passwords for [ship.name]. Wait a minute.")) + return select_ship() + if("wrong") + to_chat(src, span_warning("Incorrect join password for [ship.name].")) + return select_ship() // Build job choices var/list/job_choices = list() @@ -134,6 +144,7 @@ /** * Actually spawn the ship with the given upgrade selections and theme. * preset_form_new: the crew-leave answer the dock terminal already collected (lobby_terminal/lobby_yard.dm); null asks with a popup. + * Returns the ship when it was built but the player couldn't be seated on it. */ /mob/dead/new_player/proc/spawn_ship_with_upgrades(datum/map_template/shuttle/voidcrew/template, list/upgrade_selections, datum/ship_theme/selected_theme, preset_form_new = null) if(!template) @@ -173,7 +184,10 @@ to_chat(src, span_notice("Your ship is ready.")) if(!AttemptSpawnOnShip(target.job_slots[1], target)) + // The hull exists and is theirs; let them try again from the crew list. + spawning_ship = FALSE to_chat(src, span_danger("Your berth is unavailable.")) + return target /** * Spawns a free hull for a player the fleet has no room for, and seats them on it as @@ -187,6 +201,7 @@ * * The gate is re-checked here rather than trusted from ui_act, since the fleet can * open up in the time it takes someone to read the menu. + * Returns the ship when it was built but the player couldn't be seated on it. */ /mob/dead/new_player/proc/requisition_free_hull() if(!SSticker?.IsRoundInProgress()) @@ -224,7 +239,10 @@ ship_metric_purchased(target, src, requisition = TRUE) if(!AttemptSpawnOnShip(target.job_slots[1], target)) + // The hull exists and is theirs; let them try again from the crew list. + spawning_ship = FALSE to_chat(src, span_danger("Your berth is unavailable.")) + return target /** * Join as the given job diff --git a/voidcrew/edits/mobs/ship_join_menu.dm b/voidcrew/edits/mobs/ship_join_menu.dm index cfd1b6f135f..d2d15da29e4 100644 --- a/voidcrew/edits/mobs/ship_join_menu.dm +++ b/voidcrew/edits/mobs/ship_join_menu.dm @@ -194,8 +194,10 @@ if("join_outpost") var/obj/structure/overmap/dynamic/player_outpost/home = locate(params["ref"]) in GLOB.player_outposts - if(home && user == ui.user) - INVOKE_ASYNC(user, TYPE_PROC_REF(/mob/dead/new_player, join_outpost), home) + var/datum/lobby_terminal/terminal = user?.client?.lobby_terminal + // Through the dock terminal's code limiter, so this popup can't be used to guess codes. + if(home && terminal && user == ui.user) + INVOKE_ASYNC(terminal, TYPE_PROC_REF(/datum/lobby_terminal, housing_prompt_move_in), user, home) return TRUE if("open_wiki") var/wiki_url = CONFIG_GET(string/wikiurl) diff --git a/voidcrew/modules/lobby_terminal/html/lobby_terminal.css b/voidcrew/modules/lobby_terminal/html/lobby_terminal.css index bde1355098d..f1a83bc16b2 100644 --- a/voidcrew/modules/lobby_terminal/html/lobby_terminal.css +++ b/voidcrew/modules/lobby_terminal/html/lobby_terminal.css @@ -13,7 +13,7 @@ * .lt-deck-row * .lt-keys > .lt-key.lt-key--join (or --ready/--notready + .lt-lamp), --observe, --character * .lt-grille-slot > .lt-grille - * .lt-tray > .lt-sbtn x5 (spritesheet icon inside; .lt-sbtn-mark for the new-poll lamp) + * .lt-tray > .lt-sbtn x5 (an lt_ @@ -54,17 +59,19 @@ function SmallButton(props: { onClick: () => void; }) { const { id, title, disabled, mark, onClick } = props; - const [hover, setHover] = useState(false); - const state = disabled ? '_disabled' : hover ? '_highlighted' : ''; + const focus = useFocusable(!disabled, { + activate: onClick, + }); + const state = disabled ? '_disabled' : focus.focused ? '_highlighted' : ''; return (