From ca676e8e66b5e60b90da8922b56df9f0afdac4b0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Mar 2026 17:40:37 +0100 Subject: [PATCH] feat(db): add atlas Rust CLI for celestial bodies + stations Rust binary (server/src/bin/atlas.rs) with commands: show-system, show-body, show-station, list-bodies, list-stations, add-body, add-station, stats, populate. Reads from server/data/systems.db. Wrapper at tooling/atlas, skill at .claude/skills/atlas/SKILL.md. Scope note: atlas will grow to cover the full geographic hierarchy (planetary surfaces, settlements, districts) as the cascade progresses. Also: removed stale star_systems table from settledreach.db (belongs in systems.db only) and ran initial populate pass (329 bodies + 301 horizon stations across 301 systems). Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/atlas/SKILL.md | 143 +++++ docs/backups/settledreach.db.backup | Bin 962560 -> 962560 bytes server/src/bin/atlas.rs | 875 ++++++++++++++++++++++++++++ tooling/atlas | 24 + 4 files changed, 1042 insertions(+) create mode 100644 .claude/skills/atlas/SKILL.md create mode 100644 server/src/bin/atlas.rs create mode 100755 tooling/atlas diff --git a/.claude/skills/atlas/SKILL.md b/.claude/skills/atlas/SKILL.md new file mode 100644 index 000000000..fdf0b58de --- /dev/null +++ b/.claude/skills/atlas/SKILL.md @@ -0,0 +1,143 @@ +--- +name: atlas +description: > + Query and manage celestial bodies and stations in systems.db. Use when the user + says "atlas", "show system", "list bodies", "list stations", "populate bodies", + "show body", or invokes /atlas. Wraps the Rust atlas CLI binary. +user-invocable: true +allowed-tools: Bash, Read, Grep, Glob +--- + +# Atlas Skill + +Query and manage celestial bodies (planets, moons, gas giants, asteroid belts, +oort clouds) and stations in `server/data/systems.db`. + +**CLI binary:** `tooling/atlas` (wraps `cargo run --bin atlas`) +**Database:** `server/data/systems.db` (auto-detected from working directory) +**All output is JSON on stdout.** + +## Commands + +### Show full system hierarchy +```bash +tooling/atlas show-system "GJ 15A" +``` +Returns: system info + all bodies + all stations in the system. + +### Show a single body +```bash +tooling/atlas show-body "GJ15Ab" +``` +Returns: body details + stations orbiting that body. + +### Show a single station +```bash +tooling/atlas show-station "GJ15Ab-S1" +``` + +### List bodies with filters +```bash +tooling/atlas list-bodies +tooling/atlas list-bodies --system "GJ 15A" +tooling/atlas list-bodies --type planet +tooling/atlas list-bodies --type planet --inhabited +tooling/atlas list-bodies --unnamed +``` +Types: `planet`, `moon`, `gas_giant`, `asteroid_belt`, `oort_cloud` + +### List stations with filters +```bash +tooling/atlas list-stations +tooling/atlas list-stations --system "GJ 15A" +tooling/atlas list-stations --type horizon +``` +Types: `horizon`, `commercial`, `military`, `research`, `industrial`, `agricultural`, `transit` + +### Add a body +```bash +tooling/atlas add-body \ + --id "GJ15Ab" \ + --system "GJ 15A" \ + --type planet \ + --orbit 1 \ + --name "Xin Chengdu" \ + --mass-class terrestrial \ + --atmosphere breathable \ + --gravity 0.87 \ + --biome temperate \ + --inhabited \ + --population 1800000 +``` + +### Add a station +```bash +tooling/atlas add-station \ + --id "GJ15Ab-S1" \ + --system "GJ 15A" \ + --orbits "GJ15Ab" \ + --type commercial \ + --name "Chengdu Orbital" \ + --population 420000 \ + --docking major +``` + +### Database statistics +```bash +tooling/atlas stats +``` +Returns: system count, body count by type, inhabited count, station count by type. + +### Bulk populate from system data (classifier pass) +```bash +tooling/atlas populate --dry-run # preview what would be created +tooling/atlas populate # create body/station records +``` +Reads `habitable_planet_count`, `inhabited_planet_count`, `gas_giant`, +`asteroid_belt`, and `horizon_station` from existing system data. +Creates: planets (inhabited first), gas giants, asteroid belts, oort cloud per +system, horizon station per oort cloud. Skips systems that already have bodies. + +## Body ID Naming Convention + +``` +GJ-{n} — star (single) +GJ-{n}A — primary star (binary) +GJ-{n}B — secondary star (binary) +GJ-{n}b/c/d... — planets, innermost first +GJ-{n}Ab/c/d... — planets orbiting primary only +GJ-{n}Bb/c/d... — planets orbiting secondary only +GJ-{n}d-1 — first moon of third planet +GJ-{n}d-S1 — first station orbiting third planet +GJ-{n}-oort — oort cloud region +GJ-{n}-belt — asteroid belt +GJ-{n}-oort-S1 — horizon station in oort cloud +``` + +## Entity Hierarchy + +``` +System (star_systems) + └─ Body (bodies) — planet, moon, gas_giant, asteroid_belt, oort_cloud + └─ Station (stations) — horizon, commercial, military, research, etc. +``` + +Bodies have a self-referential `parent_body_id` for moons → planet relationships. +Stations reference `orbits_body_id` for what they orbit. + +## Scope — A True Atlas + +This skill will grow to cover the full geographic hierarchy of the Reach: + +- **Galactic:** 301 systems, gate topology, sector/corridor data +- **System:** orbital bodies, stations, oort cloud +- **Planetary surface:** continents, oceans, mountain ranges, rivers, biome regions +- **Settlement:** cities, towns, villages, outposts, rail lines, road hierarchy +- **District:** neighborhoods, zones, named areas/provinces +- **Local:** named locations, landmarks, facilities + +As the development cascade progresses through Phase 1 (wiki content) → Phase 3 +(planetary maps / Atlas of the Reach), the atlas CLI and its schema will extend +to cover each level. The goal is a single queryable geographic database from +galaxy scale to street level — the in-game implant's atlas app reads from this +same data. diff --git a/docs/backups/settledreach.db.backup b/docs/backups/settledreach.db.backup index 49b0f27785c53e8d6bb7f739f7757e24fe7a2d2d..f996fba1449e6737ee18102cc2b6370d4a36a248 100644 GIT binary patch delta 636 zcmd7Pze_?<6bJBo&wI~bsn6F?Nkbx1iPGXw_*ayZn^S{JxU|-@$fz)rh(}`}P34bF zUs-8CtlV0fB1rrFV`vR(=sN`MO>z00bI*l)IozW*gK9Il)UBi#V{bpa$bD&l0EYwf z?Gh?3e^XghoE^fG%hkG^`dl36zR~uGUU{5 zGGx#z85-nzD^3$!OD8FyN2Y0wYxN{u(1=sZrKoE*lp_#81PKga1QVFGP|lJqDfV}{ zB4U9D`e*?aqz7qfd delta 80013 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

4J zq?kFiEKl94s`Sq>q!W+}?w=+Z;OT7hnR$L3Jh3(H|mca^?d8ovd!TwNWpSQvD?UaKhYPHkQ=AGZ>lZ=*RKi)C&6&iH2~2Do7#UU* zn^|7Pz*=Z-c*zhHKCZMTJIss;8e~jhlA&8h=Xl2Eo2c4&lIi`VkA-_I8YeBN9|j|W zibp57rLb*~HF;LH_-gW-223@DGjh{JDu(nlVcB30lZl0QmM6zt;OX+DYx(KfBq?M~ za4GUwbOA03lb8iLR%)Aum!d$E&fjq1hGwS0Z8&BqS9P>x5?7pM&0}Sd?lV{Zk2Tda z-eGaCQ5T>?OyZ0)O?DU#SkA}vf262!9N%_<+-(qlT%Hw+M!{D{{!X_E+&yb7Ht1$x zLwqpqHn5oH`~pVaJGlHg^v1N!Nt1!Brv6hw2(T~>EU5EFhLuW2&UNkwOYMq{89wyZ z$507LR2U5Af}P7)V1p=P)xD8zsc6*@ZBGqQA|AtgnK9a-sWK)ArU^^!T%I#Q47(<~ zXp@w^J_>X75X?qpyC|Ta8Du;M>&zH0yyZ2E2pd(?_MDUquPAGz)6+0(Q@I@=B@HaM zb2>YqGfW>%{+_n!-rP)m%~I09!rQQU{x*DF&Tj^AH5F*2qMJXYpR>;%2~>gfnqjGY#Yw$k+rK-vVY!#5wzj#*x2j{TCW=> zpo?UQQ<~M;fa4^IUlDCmmo?^;o6aNntF~PhcMl`O4CP@RChHL<(#_vNDm2y7Y}R&I zxPcrg8_9vQRax9QOr0C_Wqkuue>GL>v*9M7zOB<|$9P0ZfKJL{%SMNPjC{-Lu1@l} zV_bE>8}Jp~6i$=5jVs=2s48sQ=qB6uvKi0N{b#cQXKf8s4fPuDf!fI?1JRtpRFqv9 zoT>^-)u1jLx1w@@niGM7z$OlBZhFDadu`zxq;;mx-5Y!CxYO~6TZx!VP3B^#SJ zro9L3yI<4}fdsmy_BJem@CH7FpSMCZsNbrm1kbEKnblNajGz!7q>XJL%it_lJL_rE zK*Bbm3eCJj&?L|BC%_3~6WMNAtc7U%Eud;rf_K5tda7PcnOvFk2Qz_eEZ293@uhyll3KR1QpByLA%}RCN{>jQJd^fUT@=;ySR~bLM=wB$_qL znNqI|d;(RTh2^6OLkxzIEZgT}@Q*MUfgR4-O^q;BLu`Udw+|557BPKUYP{||w}yD5 z$v^)XN|7_-IV!5{JYBt3`3xPU%#qJXzm}dU^@;bvMu%MmJGn9Oc4A8Wkl4MkM07EX zc|L~`&%*kWy}*{fzlKrLwHVHYUGQ=$sN*Vhal#{qd#cX4 z1atW+BQbxYbEb2dvasbi^`)l07Ldt_vh^Iuk9p#HH!RMLlFxZv76G-k2@E-NPgnMr zEr?)>h+oIB7?#XuJzhaVj$bh{eXrIS@*2o*IJ(IVpaFl<1$Z-_0=&g`}y}c9|otYiL zBgCr1);zXptPJOK$oq_%+SxpWu9-`KF4vLjc$H&}q`nH)Q>0vm}rcUCpa zkRz-{b))a(v}D;Z3$)8}SeoW}(iqoYt^6KdUv{h09lZ+)11z8%mZtf9Zm0GY?VW2N zj8eh04JHw?rqFK(h0nFl*wK|*WhYXg)XEWQ%J#yX$+x1P;TfS7YPf>fEM*o_xll0` z0|Y}3i&0p}zz)j*%lp{;x_Oh#>Bb$P?O^?7WfUD;M_82FmfqZh11t9T(!0lXOS<47 zwG^(I8(S!Q(;2&9da&%w=UMAM$1Fjq#d-;CwNidQp=T6`g0e>|fVZ<`1~JbXQ`x*T z4ys8W@IIOFsv6O-y(2n3gT)}fEg39=r)WuMPiKFxb1g3x^BYqYH$(WUkEVGJLzg=k z5k_S5%4B}gM;439LZ-latFl>s5bb9lj-f+^ku&(oBW=g|D6bx?JfYOd`=qauzLVyP zC#7CWZJzu^;)eLY;%4mF=%cWk_5k5p^ffkVO0)NIzJ<|{RTqopv4%9Q3@h~W)rLRS zKBoHcCqzraJ_NJN=@y=fuI^XW$uemp!wk&;=@Dg{peMtP3S%wQn9m(vt;%$=T-q`X zrtvY>*DrW8r_%T}DacZG4X!DeWMSH|nxzA38`@E4Z7)Ewmi;DJI^o@Q)R2OdBy6^o|VqkJ6&6=+755N>@OUin(Faa1~C;jC5#Ls+H_btP1u|6{Rt5M zoS8$$K;bgCu@l#nvuq=Lw4syr)0|HpkY1ZUfUMdzcPY0TW;I|0>s>`Msp=LbJ%jBC z(=22gS|H+O?}{?u9RRt_=jU{f{I8AKo9k9V7i?^-IKzUhK|MppWwzej($n3&b~%i% zS9JjM!xA7a?e&fkO8pi_Eklp-KsBwj(Aj>)^&@wAvODLPou?ye|d0q+mwIkCj!<#R=oci4cwaf~R+>`iC9igqiKGpdVBNWeQl(8~H56#$D5Ym~(AmxIIu-Fjj=)SPC&Ap!E*mm346Q zxqfp-S!W8~oic>0{DOgLzHnY|YAeh5vSA%C7AoLDW0sq`F8KGz_}QsqJk+3e^7{it zAWLEQM_Et>QG~_3ZR@*|(8TQx^B{h2dw)+SpAMOg;pRHS?d%cuGKbmAeJ^n3NP$c( z=8Qb+uq+t0*x7!j3U5SnKkTmrPn>565>#baw&x6c?h=U14}qBt*z4H9CPHe9(>QkM z+|~B`VpgBQ?YgXH0%i(Wu*1g2=Ebl#W=%t@(~kYUT+)%M{LtG!ZsN%FyC)`kH`AMY zJ6&M#M$Kb^Y8DuyK9kS$^TmFGHdV|Qobl7FwNY(>dWG@@OdXw-eknapS}ER;`Zl#& z@|48i6652mVtoPF6^>t0V#EQ4fJ) zkC~4TXU=>HHke^&I&0uo^QP<0`sjki4CyeI5yrLY(z59m;!v;GOR(3Bo6-uXEV5EM zUtl~zx>4(Blc}(9#buOW*IB)@K&?C@UftQDljYJn<1ho9XUD`QsMGLfG)l65Bn%Eh@6gCR;<*lWoO%TvVIyC zS#fn`Rhe0?rtSUuD{Qt+Khm9TW2SDa`Y*^DYFJ9ZC{^7sy@REIDx-~977O8Zb%aw@ z)!Y|+U13e1&9g%Q-7tRos60eq%{v^|)eJ_s(>COp3u}7IFq@m#zyT`_&HZp53LHPs z%OyS1p46P*nyjY0s`SbUp7=DA);rbbu}7-AM0JCBj}3MMKVU=VA8PfA<_?37IBlBO zD4K+XYW9atqS9mwg}Lo;u1n8KcYcyhg;3-lnuKrp$kZJQq>UXr%2Pi90$#@^hh^m! zcf&a~Alm8OF@=J6pZH1gTR~nUD0he}wV2UTRVA&H2|#v_4(!?e|D~hzb^X9hW5!_L zzvG6g7~Nb~B#~)yIUQKOcXfMbmwR*SiWBoSOa(fFm5|;zBG>2CS`C$kMSYIl&adnC zPZ6kP2^K=x^txV$vKlyy4C`{NQ&G1ziQU5o0!=**Eso&;N07h{r{UEdcTSiZc8sa( zjC>C?9Ee;fS5BySZ#oz#!r_;c3Qq-fK;p2=+2r^&a`9R*uCnpFoBaJR%&k)>jb8^8 zfmhl)>J1Q?IvK)cE#U6<13R5-$e7I|E<@`i*9{c7t2uB#BaMXP302uTtm{~8Te`ke zJDffT=N=ilp0}BVS0#rx+2CFi1uem`+qQ{nt?J3j3(A&qfBL?3RN76v1Z|W0BxNPL z6Su{`L*EHE$LGdQj{YsWd1N(_75GxhmE2HCgLhN1$v?|(($Zm*KQsOypEIc4Q|82w zZkiuas%Dc_6}x1mY2G$jo6O?oNdl}NPKVb*TYaWfNBga7FJjxes$E7F<`&plavc`t zdWPzRFn^_Onpu`9RW>G;ZP^}!T?i|tUb?h4u4zc{Tj8IhT*eaRtaxS`E z7R>Ub5krTQrL9WCp$o#??kGldRkdNs`tMwZ9Zb^o-;DeU%^$s> zx3;hA>sU{hCbB7a?^DHsB&7Pg1LwM^+7l{4JhLT;Y8cQHxUn1w*po9@-xD9OC$Rqf z&03YR@=W2ZQXK?0#3GFukyo!iY#!yz94pbRO#_1@s&e_Pat44A@1@GrZZfsIR&&`xzTih@%UTMHEfGl+`WNhL% zGc3n}26taK@wb01>b$Hp?b0k~+O3+)n6D~4Z_U>f7wX{WG1BlZh*Be72tnGg>_7{u zEHZ1&jpHExMh(i7{VqpZXn95;yN8SMMg1c57_Bdvkgl|ore*%_k z$}s9iL3s7j3|eEJk#AM|CrA9(8CL88(pO{rGP6yPc}=BqkV3i|JFvFMhCoASCs4CY zJSQSjs&&In(4AYB+#rR6Z;{o>f^67dz-shtKE!9mH&^I71EaJho$x07O?FvRA;k?T zv)Fgd*%sLxKhXU-e8Xl5$0p>K#^xtWvp+qYYIqCoFroy(3&%7yd1Yr1V-e@IVb0|y{TloTpRcZ( zgz%3f*_NHkA5_s)!hsE8V~fXr2;w$pWm3VJ;j%4y_b+DPMJXpO!d)t6$qj7C&d*mB z?pZ9{b2is6y)kV_zx*jR#aEPFqJa&oczQS?MuOS^S?u`DuC^4#-!rROTSkau%qop{ z&4(HY!;_V^$qIV=C++%ntiTYuGNp0Y$5WG8LxSAcoLOVnB=$MpV%eQ<#KsZ?I>aPZ zx@O>rlEksGxiL5(J;M@rM9H)JCRh&Hk`w3Ow{4Gd zbP1|981YMna4D86)wyvGtFRSM86H{|bapqhZdY54QX9XAwxWUzJlV8+OxsPpNcl*y z_HqT^_D8c4F$JXfWi+TNX<^Q=4w@NO5dGsd(yRZ;N9E)JxtvIM!@l3@;R z7?aP%i-52gWQu?W^$9=fOf34VZ?>L?MKId>(@>P=I;1SP@Mht}M(F z#33ZhJU#6fL=;)04Qm7$HI~oU;3U{o0b!RQ5aF;fWJ_9^;{&W)5_Be?%`~_6^snmZ zX~<^EKow7vU&C^CNO*nCDs5GE`IH+mh%me?Hk)OoeLm}HMA8xcHZDg|s7?i$1tAH6 z-A4@`nG_b107bgVTDN(dpIvq318*Hwq-hO~M7soW2l>%(fXpHhXzUEPo&X}ycs4Hu z^1I(W3Kb79hnX2sq9F(?Qa-2+i9a#RO~U1w*unv=xJlKOEkXQ2tf@islo>v#y715w zmgcg9fxIucUah{C@a2m$N_7y25R*3eg10=D@USq1owCg9o3!FBoSY1V@N!KWI&l){ zvCBJyOm3C}V$Tdd65xEUp~;n=q1_QKDfI%e7P?iL)1SSfuCJBR_RO@`B1Q(;0%y;PHerp>$wGTL`BVy|D$H zLw{KM!L%anD_))YGL=vMGVxeqczk8-R&-$W-_Zq;4Z@4UBp4$ZBTI#n8yBt07>OPC z8RXo=JUdSU$p5d?)cZMB*`RZG23azmXM>pna)GDUx~%9|=3KuG?;myv@1qL{rNFkl z;j9)nbVjrRwhA{^vtRvMDuf4MPJbW2l~G-s7-Y@3&CYZJ(qm-=bP>Na8LaEVZa#KL zP>|$>b$D1s>8z;;SkLGvkSa!+gz9qOcnD31ZJ&xXsW@Ty1|+r*>)A7O*Y^1dz5!wb zh1mNeOVdj@Fkmf;6K0jL;$$QJ0CJP`X86X486`I&AQ5uz(T~-a)fhT9FzhV5mJ+Z& zW#FF;OeE-mrr~z9Ra)w(aXiqb5$>wnJFoy6SB}jd?E=_Zn91Mo1``QOum=Ddt*i_x z9K{P!Y+huuyhzCS2DF5`xMO-Zkub0^W{oI8R6r(1VPO9wF>+X!i2)&U{1TM6@i%Gc zkt?}j0KG>W2Q(syj{`de8LJ0Q$YSLuk?XZpSGce;MwB2RAZ|yGCnrFHI#F_92awkw zGcq~Pe)2%mhaYW+Q#Gr!3pU7L(x$AUgG>#BgvhZnIoKGnrv1?NE_$y?M>Ez&*y7w- zrJbE>sx)CRMOcDnbL`m&5Qu5hDGJklM#&9M=-ok5-Drh)Jy=lX*b}<|>m4}OYAS@w zE4_rH5@G{?aQlUX$g$YN-;9juC5kA&>fz9^it z=d-E&hAU@Lg)_KW+R7S|J=_QCuIIN~)&J7X?$1tsC zGE17(o!L^j|J>5EsRWS;YqO_x5)21?T#WUJv6VI60rnIp(8IahEJ1KWEHu4K0IN5j zPD0>7cy^8?zc;X&IlM_?(-{&q9*VS`)eDsOmArg(`WbX6iP>;d>g&`t$&(UKC&tAO zjXe?@5#2L#q42)2al#OB`;n!52}1f{m06azK`VC+vWcBtWC^$`vaQ)}bANJd1 zOR$3vcNFjWJpp2ltOy62jG6v`NG$F?15b4Yh!)Ok7e4;js#FJC`+&bH)fLEJWMw!z z1#FmZ>`aec7SN5knVA|`3~wT7EJ0L7#t5n?cg~n4Yr|pwk8vs*W{y8r)~_HKTO(XO z%q-!6ioo*TN%Md~h_fynf*~_jndyzD8K~N8U4=cV1hEt`J>c3b$7l+o2U|aOj*YNo zz@qTro#GWT!o}{a(m~EZvF!VWW{RL{3uizv0T~6(xG*h`tr}=sFbx$>kzIn&iMV3C zdOX0gch-Vu*w}G~$?PnyjjL;zuga8Y=)`he1rCD98gR>I*4Tm9-0x@gyOB7p87>8^ z5`<60<9x3q52`FO2g=i=du>C))&6eANq`!rU}l$AxntSEwI2x&WWi1vwzp-f=)XaS zXEg|?3@in1EJ4`B+JqAc5M=4>5d{WB2pZJYTWV-ob1TWq65+%^eSJK!6}_cA*-BF@{38B%fA- z;Dngxy=(mfBqxc*lV_)^3^>M=qv)qQld`3DhZu*?oiGRi9gxJ|G1*n+hVhd;-=qtM zx7^H99YiI>^;C941ZrRsfd}HNGCdGZF|AZnfD~nTJe;1U5)Mk}oK@l~ykL6ph!LEC zARAWAFfyL*=IUeK)5eXfLuHc`NuV=?r)BLst@x?dsm)XmRGv|Cay2x^e zbstfOanQ%9x008}Z-~7Vxg)YZx?A)gu`ObE$G+F*Ym(BfjF7iZzn$JEy@T|vbd|I~ z{93$8+#rrm{g`?nbxEofrk1`;-kZEOd3180YK4v0*Hb7Y^6o)ujieLpr3>yG_79*bWTKP)jWaeSgD@o4h(RGoH% zdav58+!T2q0!w@=KPq1dr4Ep0kJpr_tb{e zmeFGppC$D8WAUwG=fu?L@1kQOe~%n1ycHQ0Dee+^F!F75S@hOeCO$ph8ow)XXA&iM z)GpO}Vr|;y)&5hDRu`yKm9vyxqQc@kHXp#Hz%E_+9Zc;@z=tVtQrsF1FUQ|-0PeMS{jdEa(mU8&A5|gfmB`yD0pUub zbrEdjqL1O1ie3s|^8eI}e$)%)0-onc{TJd8e~-S5pur~ym1>y5W+|dis!;k2n+ZMb zr{XC;>d7);KO&xzul?D-#6ME3P=?Ng`uAshkE7);T>9Tt-uZKE9EwqDgp|L~Zz-zS zMx&9V2f2bDh%G^&`zj44NL-Hp&{8Ue{3-TZBs+YB>tK@L86wNR>;mfW;n07%U%4~( zmO}^IkUN&YZ=|mbAxa;jTjSlRena!{BE<;P{n?W~6uOb6LfDLN(LZt-MptQvKg3T# zcy0{Z^+3LV3Zvq$_5McQTyI9i#^?4eVrXW!X)1t!Z;raU69NntwI}D&B9dQ8^RPH3T^bz zUu<8o8lCGwKw6klrFG8smvdHft*=eEk{bPQosrythL%5}h#`Nb(~{4iA>~W}lpFR} z{>-QNQ75MoOg;H>2-6(Y>W+1l{&+%a|3TiG$$lR?&Q~xx){h!U-H5PLG*e`I<kj`UY zpkKE?n{OG#?*J$(vW#vZ<$yQfb^+d90? zGnEH6-f(?1GoAgdEtQvXBLOmL7eB;dcNuw!dbN0nC>JuA>WTkC~v*;0;jkW;_ z3Pm3ULpjS~2rXID_8jDU6>R_w^NLx7=*wxYR7txJVYl^$?MF`H9wk{)!D@9Xt4Pa= zB%^gA7E*Yz$!`)PkoKAOI@t2}X@Ar%(azA0(bj1PXuE22w2aoE4bhPLnfki=w0fU< zy?T*)s(Pe)kh+h$F!5_OuTE7*sBz^hzA}AoVvqFk>Av)e^x|}LI+GrsSe3drwRdU<7_l6i zn4g@J9H01CdPrK3zL4IM{w_T%-6~xPqnneY!==RsN&88Qq-~`psUfk2G(acyuHqaqBQ_*Ph(i*#h+sF?Q>kC1rl#tX-z48nKA*fLd3JJn zGLzI2k0)*uZd5Lc-qbLsAQZ7Gs`w|+Yq%9EY5wu+Lm&o*XRbpS$|2A#JmN!)@R+3N zZ(anp9E8oi7;H-1!65{JB~#dvOK-uyFc~AfgihddG>{^S9ld>RWMPjmo_}Qw_apUu zh7o+3b=(`n`3%GOB8nRKipsyDa2aGi1N1R(6AB`iPH^eCmj+V*F)l}hO{4#DKZ${r~c6we3s97#Ag`cX;kzn_bVUpZ@kZCc$-VV$usyr zxePCJ>4&)VyC3qEMO^wfT>3mNy@5>^(Wxu~l~@VT z&nEX~lY6qsrED_GCT%v^h?B*NteMI_CWcVu>16ilBsMvoP1do=&DbPSif+l6|5NPa z7|&&|@`&en#Iro&F&^<(9&x{eDE626|873ZO+4ay9&rti_#Kb9oJU;9B8scguUX7$ zbPkU=okyI+BTnQIzr@m~i#VT09M2<;!Qb!s-h;XHL0r0% zORvP~qNjl6T!zJ5dN(e;6PIq`(sQ}=R$SVIbkX|-O-0;!fyl;nSG*Pwltr$LgV~t_}mk_&eoPrA7Xa{3DkhQ(%DkK zR1hboK2PQ`V-s7&UyDB#(_%v8`^d*Iy|8U$obYMn_{gD=2O|H7 zHbhI&`=d9-PL6#YdoT7&RW~Sxe2ctFd_#OmykA_BIw)l( z&W~Oac{*Z8W@_hYr)VwOZ1s2QaS(N&S2<9bt&EhPlFyY-N`IBUIDKY%DfsSxkY1Da zkVeBK%3EOP{v~lrV$Vcl{KxpZ_%88gI8OEI*nzRVqEAIGjkF2>jqDb=Il6T*Ix9LX zx;*-3JQ}+ver){y_-BcQiKDcqv>Uaw@{00nB_;1E3(}=9-||=S2Js+q-_*15KgKu2 zC&q`y&V!kSmgr~E)1x~^($RIXY-~zmbL}hb0j*1Ggn>t^I$pV3xkgzke<0r@UoOv2 zzX{k{TO>K_Gs+3*zwWVqIXBH zjvg89jOJjb=cUL!kz*ojBRfSLx1kap3zHV@E89A{R-(_9A|!v&-?gHzv%3os`^vMh z4}F6WlmC-cba(`|2cUmZ;A7_bN0GHCPF^XO`5n$sPJIx$-q|Za3og2pe9x5gZzlCF zQ^Y%wm-yfKfyw?KO52;!l$*2s!T)7)zOF=zp4#|lbYCxVlxQL5eMw+H?|%$!KVli> zt-zO~SED2hSG~D}lqXkl?Q(G2CYWC!2D-PD=jj(c7k$l3A^j{tV%`_MzlNTTjpgb~ zNLL6bc3=X~)7}EmQ%vf~*zwL}K;%7^~wccmK3~vfuowy8v`?KEx>m*aTxRbvg8a@0JPn}pfAEHT3P5qm}9UYQ--`-MxUR&-YJs2 zTSlMDyn9aabLZV<%nxix4f9P~Fz=n7^3U@!8BV2N_N&wYny{gHEUAVv6(`ZhCo*rH zkopnr3ZJr5hHDd4V(2?Yj*f*{0Y|(R#U?ZvUTPTX#U3dh;b3Pbqr(Ld>+7+7;Nkhh zxb&go2Pg~07{nI#WbAbdAXdExf3S;R=ia-esT&)%7n+Kg4BFKWl2-8ULob!YZ1a@4 z+WYP*$>)0XzS|+)P_<~70wxM@1^0o=rKhXrce5WLeTeW`OqMYz9idvjRc-!heBLg?D6OZUE+QLgcn?Jb6k9X)N+sE#N7 zgbaE;_-Wqaw)B=T)tlJDo0y_*Ta858zLV7R9r-!O`_ed^pxiswt%l9jn>hMuj?wDB zok9r5(W2|LG5jxcRX<$YkI#XBn66?Z(u)4Z5HCrZOA?-8_f9?F7WbAQyZO@YUGf$p zdJ`#}pp+*$IuqV9$++9oC^65Q#g$R_mtZ_FR-_+-XDp6Q!mR0++Pm6I+T+@vV0iIc zGO9URJ6Kz(?Wrx)w$d84@fys-s9&q^sV}LIsrSIl#ii<5>hUlz>s0qu7p0y_J(xN& zr6oT}K9O9cZlz9B>tVR|rSgXIlyZ-9tx`N+IYH@HRw%nE+bNC87)69dTkpt!lkbBG_;Fh8+fdS-f5Iw5^2y)Hc= z-3jv$XGXYk=g+VilVP|&!%&JU_OAlZ4_;d+ z457FRO7Ww}k{OoXDD!I(|2u*Yg~&oACJ>kVtX*TO2)*FX^>>0Qu7=+)*TCQW*`D#E{_ICR=tn)^ zN8Rs7760VN-Qh>w=12X}kNSfjb(J4=r56Q@hH&*?;l(+q%l#;_I*F}VvO0-HUF?7H zH-6Lw4hsK%x%$uZXWQsUk>x||PoCw^b%q~xnjdwFU;dz9`m-I2XUdtMBXtc<+tKrKu(}$Vi!%X&JCipNTeHgp~q{y<> zFkdEx5yM|FskfQbYfS1@CiN1NdZLmlf&=jJN)l3!F{%5Q)GbWvCMNZJCUp&y`Zbd} z(~~N$L??TaE79>x>NqAf;I+WkdJ}El#6oXko;Q*8CYo@fNDYnY-bWL?i3V?Cyf-n% zn;7Ly)H|D<`y$#R^*k`lX3Bl(KczKknRpF^f0~=z0M6Tq@zt?g<>(L5d6CnEmxKn? z2g9{FBTHKjxJTT00<{I~S|EYdb9pvOsF4i|IKyp>4UbG<55Vy@cyE`x^DN}x03%DH z&1ASRDu88HC1P#UQ*C!MBZHhRIl++_?%B~zPj1dW(Zgau0vhKF)%7nLDUH+Yobdcig9}KA}dmx6J5DtK6 zB3@KGfzBXd)3XI`_n=)Z;EbQ5%2v2qHVzg_*@39rgv_?as=H-v9I{krPvA9d?l`W% zR`j!0_@=m719cH@w^!iiR1!En%SL!L0LeAs|8bk1H6S?yF zYS8_kgtbEVGvOed19gZI5lF1?JYMAI{xg>}8czIli#=Qfux#w00OA;i52Xs?EEk=K z43&Nu+ z<^BB)9w=nH`X)*E#?ny^XHBiA$MjX&kCR#CY*w9%8)O?*_7?jEJllLB^2#h7NyBMW z0`do_KXNK75X1!+fOA4aGIe^-x}# zRXW`1d6g(Hm8RoJat>tG*$}FRRjA=i6Qyr>HtX$jT!;W(at@EqGf|!mI6K<2eCg-mB&&2NCJGdC6}`vxIC5gGZ7~N< z7*@6>fmv<0Y}hd-dg+i_f*g*j<-}z*t;d^G;iB9u^}Cf--gq4t$Irr| zJa4h#-z;nJ$S2imDxA^vQXfbIytSAys;b&9#-iNL zGZr@1EzTykj2an>j+q<_Ay}9@gP@f$ZP9e~a^+vJ)A>m7GjAsCBVLsHC{;)fz~&TKZ zFpjf%0__VYR6E3%VZs6As|DNhE6s{YO=>#4`7;A>v<`_~?WwYW<|mQbvw8Ns7$a}7 z+wff_HQ0{#;Px*y4s7nUVkM+XphC!#5ba#t1VDQFNdQ5Nh6)Xw00yf);GU`jTC*IX zz51CYMjrPPJ(FdJju4@}7Nt-z!)oHHQq#a_7!QrFDaHf|(4I9iv$z0R`5J<$GPM;h z=5hlgq4}mw8&x^OFtGVZICR}^Zt3anUb`HQm0r~W$GxyA$EFSrw8W4W&h0Zt4AeWU zRaOkaq+wHoJ9);)=Rz@9xvGQ_QeiqnJbUFXQ_kP94wOvgD6l8mSc3_B8 zUmg`05I~3=nhVjV*#^{EtASM5pjN}+j*`;MfjZ5pwsMrnU~xr4(XauQbN<6F{A4lW zGgJ?jtO3=rU|=*`9vM}S5NePEbHQuF4rk%ST+Z6BE*3V;-p>sv<+$Bt?Htg87xj%hx>_%x<@z<%hQzit#eIYR^-UX{;eiehrw*zqUuV)4Zi4|$fjp@jdai+}~ z^>AdIe;`!5#0@d->-B*~u5N>dK_-dDY??Xd6mYdG%?tI3YgIAjCvVd-9TC!mj#aLg8adb3gRy2VI6`OO`! zmclKaEe7Dcbj($CN}Ve;0((UP%QEPntfvmJHQMod=rPib18___9MxE9u6Tuscm^j2 zvFS@Y``Qof>~e24PA{@XR|r=d18`Ql6V8%4#4ENbPY`y4!6=c{KycC}CopPbp)lv1 zF#u72=2u=u`K%{Jw=b-0+<2jHmmpcCBry29pZKEs+daQNm=s;ZEq z5MaLP4m{^{R^6N%MwZM0_muLo*%jQFc$!!@ICHg7TWoIs6l0h`$PK{xoCyxjF`2uGZ!s(WtVKotb-ICAU(uEn+Gn1k$KF`x6gfQez@Cy6mD z!Yj!b7*88+P{EZa=Q#=kK)J!$q7b3W%$ld7N$nHf!`%X>BkE>h9Z;l@t zc}~70S_j)OcTyI__KN8VF|l49oxU!4Q{?*8A+f*96QegL)%3FyKBPYRT%w_32v|CfN;#VoNqqDVn%J=Dq zq+h02rv{R%)Za$_B92VnC#PeRlS5Q3-J+bM^rrStv_y87Rw&n|evHSH(ZqvVefr(x z%ZXXa{nEd+0~6m$hFq^58J#Hark$PmW#YO>@!a(2Sa19n(bG}~#XplSN?jFSrW_?5 zp3aCzs8h82qkWO1r2EAk(>?L|Q8Af~bVEd$W%4J{CsXgnkBk2|d46)N_G0RS=zFP` z#oNUXqO%h1;@9$vu`6QdrC(8AOn#aEpLSw=SYmeSi}-l$;>5+#SCrN1qt$~Ve^nQX z*A*jg%1xTAHO6FEMgFWZI{u<^l6ro8H{}WS*vOHweDK6#(ipK@`$YX(JWG5i{iXC) z>K5^0_3q>osryq;q_yP#q!+Y_(FM`_)#5+oZ`B(hG^mkUp#E99CN*1oEB?0jc>Fi1 z(_?=Wm&d+O_9b4E&rVE~uah2EK2=XsKa}5!{Yt(~+AeaExJUf)Mw$!U!@`*8EG9&gTQp2c2%|8(5UUouSr|!w z)3${AasfG87(pLo452Q%pz@P09z0FL@MtHAsJPn2f%8Hb61}n<3D7MMkJJU#P2l0N zpwK5UAQfcyZQU#g>FE8;UJm!Ot-K({WGB8@k!H_XLNvCWk1$whA_#8zCj7LsGZud? zUQ>bo7yFgx7id|!j>sNdH(N9M!7Tw#nxp@e$u*kM(C_I>_FVLDXnC@yiR|FRZ{xEx z&=>Jx2%nue9=3{*Pr&CNpiknvIs5u{b+K@ARt|mW76>M(A-VT95mYkwcHsqO*p99(5D=1ReC(xQ@);Ot{`XYvX1HL!Eb{dUtpx0T<@&qcQ;-}94=Pi#dur|O+%qy<9Mcl5{9 zY|_6p`b`-F{XTWNLs~N>O*hds^ha!{sH@!yvl{AlOn6Q2I7KeNnX&L5R zbh(&D8;72eSdgPx?R*yfMl`C>Z04KMuib3WrRSpa#XTIl=m%I<0i7$Jg{DA1m_V7+ zGA(o_C90sK4X|}{TgWh*X3&9MPN5m#3r?2C5JG7~=-C1~f&Q)m-SL;wc1~1RN^`+7 z(XrAV6~=X|Loc9`)b06{baadWd}i9&=Av|FS!lUv%g8q$MY9<8+#{uHDn*v_n9YYv zkE43vq7B;*QKaZ7T%u*=4o$~4e)a+EGImjBKHrl*dgH-U6C;XF9$oKtpTR+AU9>>c zXkP20;Z*iD^lhv8pmJvG7Zl9fono|}e1i!hn0$?8>&>gumz2MT7FNi0xH)oq!J4}= z{dDDP@G@Z{y39=T@^azXcJ~Ef_vQoAUt;1nBfH&jrR9wL++}i;)8t#woL0u%$1T7t z*n5`?w(Plk$;(Iy2f2Ack{H_$t<#v&; zt9%H}oynPt%d3$BG!#{3S>bYk6`x$+X`?jf@ z_I9)+&?DPY0?h0-6wDc0D<_rfoi>a;+kFlA$u20jR)}@qGp)Hf494LhRo8f|%X-`)d zRw$#*mI3{;rFsxwvJOY*Y#~Jz!pCsX;uQ4;G#jclt5PaW(;TnK8#8~5k3Xwn&aBCl zQajUJuT3nMM~TX1>a<-veFIL19!85YtmdKG!4=vl^_Hd4*9)eqomeiEzF5fTW$j|$ zi&iEpX^;ERx}8Zuc}{1_WRh;#mYK%^A7{eXZCS)|9-%Tbc-Q2)AS$)5wGXscAtcMg z+FfMdsJE)uK-|X->T&8} z5c#o9T?`@Qw^B3eWObxCQdLwz`9gV5c@@I&KdjuL{2qclo~xX!9IX_4lvT=p$}SM; z(N?A@n=3<?lS&@;L zhLAMxr~e^NO8+hWNczt7AJUhnH>OWc9|KWeRzdv!UDET?IoM^{kRF~+LeQGerFW&5 zr6;8aAnM~a(nZ@!XGteWM@Zce6=qLqM`=6BkfunZBt?pd--;hYgq-Ka$HYI0H;Y$^ z7l@~e$HErOgTyv*iMT-AO3d6ebj}M&*oR9rBX-^i(_O&~)+%fucJSaM5~jNNf{i|> zpdnC8ssJV&(#)ibP@Wf4BP1XGHjtgxo{DVctHOsKQPca?hoVPxiGXqQM>H8uALS_-V762_ME7#1xMtoE;@Xpvafz<7V4SwPE_L93VM&Y9@GGHjrY zv~)P46+KDm$_dmig|l4HKWQ=9<~=B3+1$Jp^wliX_U*L)XR|P>I)?s2)RFiIE={7<+et7p+^ zG@EWurRa<)Ku05LJ)6;+C%akfLjFJs=JQ**xnTp501*HPbK;|F(G%{gaF*O$2qNG< z$TXvm=^I6`t}modaH~C6Ni?H>(f94b+;jzQ-j}{^XVHz64%^ntpuRuNVP(;`W!OO9 z&@7e?0XxP-aQ)lXEFGQbG7pDQF?Bg$Ujc5=C-UXd4f2xc zu@H@Ub{a|Vf!;kyS_djNEj}KbCiX)-w?gW)=nu*I#1FC2(VOFI;}U%3n&|Y%=drx@ zk+!$CncA)H4UrY@mJ{iB(xV_M-tWcxAVk}(iJN14L|=)#6h%etIqgx1@^G_uwRVwq zqBdO<)%VqB)koAb)V%VV@`$oh*;^Se|0usEuaVp3ZDd2<9L$6r(yDa3SeN>5>IsOU za7b$Z)a=x(UExoz;12BLs{%LpfeK9L^NlS(&Fy zhof(vl^;oe2Jxz&N}reROz$tfDqSXRl=g#gcx5uZ%N5#H~Jy4AOGIn@uM>xJ{bo77G+oDUOTSq5H zAz>1(M&Y6gR9 zzF0`zl9~uE-7^vgB$md%ia!*;J$_>B-RPIm_oCNEPl&t|SqoO`K}5nhYe$6fm<3&U zTp8Ad>1Eguw!{*uyqZY>N$0+z3yo#i5N3Gbk zfam!wI3~bT8R$VKDZueT-ls3H$rm3*Ty4P5aB|4^-+HLq>S0&6}7@im>0u=0GJ+Z_2k1$?T2nVUPd5sk0~Y zkLC@xxEEyV#9(@i!r`5|ni=KfT|V{Xsg7!?`5Nr#g64KjrYZyX0iFx#YIGBs z_iKGhNF9(ybtL*>rjLm!qz*{Umwb}L(PN~Va!I|9Iuptiqi>U!`ZyLX(hW_hTbQ!U`=nhi!&Oj3ao>=M7P@}Uw28w$>9|*a@iDKL z-e|OwUdQB=wH&TtFmnTg%Js2XjT$m}#*khWy9+ZoG+{C;7>CmBb7?tmSjHhK71A)0 zQXz%cafWL-d^d;JaJV#fO6Y>GkIY=&!Fe-E>@4=qb1ohGcOzA~I$oqf_xVf8e>8uf z6yxPeCdHuA$syEa>e<>6Z#MGeQH3hIg7GTkS&a7BEsSjO=A6tqF6EFE3aAS51WV=S zF^0@s29-GsW?B=Ml5D@R-UIUdMTFC{;b0m1Wnw|#5;GG;M)i%z4925OXE2>ee3nd4 z$Hyx%hKy^W(Td0!+|r^KAW~VRJqvN_G!CaSs7zrn9Tcaut`QBhG*qfYU{$?coG-u& zozFu3@tMTqiHG2%na{!f{4H?SOi?_S*qZ2p^JcP%PB^r3dSYt)EqICcnfU4WNx11A zj~_j}sv2u|?vEXT`|bU)QE_3c3}?t}iS@*~V@qO-VzXlr>4NlkQdfKm&XKuKIwBpE z4oJJDs#KJ=N5vdM0{0`ebxGdKB)`_rep*aCCcgV{}z?DXAvT zinc&C@qFYDkr%}Ik*A@W_+aGj$me?_w?&+Y9Vtb2L^i=o+};BZI~|cYR1@D6UlLD= zC&VYjW8yvHA$XbF9?=vB;V{x(afP^~`QM{6mY)mr>s2<;#jg;76vH`Mu_6)3xewdk z9Y>B9Gd#M4=wXCI91DL#_!SR3x|*Y#uT+t61~L5x;nN8J0^y$`{20TEaE#%ca1X+V z5k3@o)@gFB%+XpVb%-zfIg4dijuaOtLxEEVINZ)*FNZxGuB6a`>t&AYFi>r?I7ggA zQgNaTk?0*wT+Rj-uzqC&q~b!@vjMUV=wb2=1rL)=$6cChKO2C32rsJHKmcKZW8q&2 z{}bUi5q<;V^9a9&@T<|2r~__&m3l<0%pU$N5+M8Q9{mL=i-fuC_Ywd15GKV7PxcW+ zf4GhvHA}0r9@CfV4M4wBda0?ggNWzT2!9OWkFwxpZ{?6|U{XsJN?qfQKiR6J!(u#bnvr%VmbW1H&hBfd`FVfG#S)Tz{ z|3pUD0Ht+0Oggpc%20AqYtWde-;IN(`j3;Tc6pFiU>~Eyzr6#(+ls>L`BK$Py+;*WWa`o3@dtx#4yZC!O0YV)t6j1`7pa!M{OWvPzISDQQ$5xsDv(H^ zluj!WBFf#npj&TVwbMWgV6Qe{XjjA)kSDIeA>bx4E3Ema0h z&9co}!LFHlGec?jS1e|!V5=YisZmSCKeUve$UV7eh*vJK?{(2GEggh+a@H|CjLjlbWizSonxN zGUZ6V@Ys<&fRTiyv_3jKpbn2{8OGli1YCB8zm<+2g z)T(B~I~OaSe}&ozB$PIy4#MxZ;57|~={3MG)!cl7rU@+|ZEB?ooXIr^ zBLYcWAC!Wr>lJI*AU1|QYV(}iDTkBY?ZJIB*wTb&ue*SlWNKil@JPc-g`2sMyJF!M zcL|~DQlpoKlgrwj;J##Y!?sa1MvNi2Ch@gIIDT=vb(sdUWa8FaQ|A0|@?Fs7nWc)u zFXn8;Qr&wFpV{$?n|1jcOrkiu?WxhWaPnF(I@i}-r@l<8mR_(8(`WYKi<_kq4Tgtw z113Xt7#2Fpk+SE7lh=Uh**??6G8`~usqSiK_$(j0xMd>HV3<(J3JaME%lFc`q2#*u e$yPIJZyvqpP~FQD3xMzq|DD`~Tett?$NvDd+X|Ba diff --git a/server/src/bin/atlas.rs b/server/src/bin/atlas.rs new file mode 100644 index 000000000..a80d7b332 --- /dev/null +++ b/server/src/bin/atlas.rs @@ -0,0 +1,875 @@ +//! Atlas CLI — query and manage celestial bodies and stations in systems.db. +//! +//! # Usage +//! +//! ```sh +//! # Via wrapper script (recommended): +//! tooling/atlas list-bodies --system "GJ 15A" +//! tooling/atlas list-bodies --type planet --inhabited +//! tooling/atlas show-system "GJ 15A" +//! tooling/atlas show-body "GJ 15Ab" +//! tooling/atlas add-body --system "GJ 15A" --type planet --orbit 1 --id "GJ 15Ab" +//! tooling/atlas stats +//! tooling/atlas populate # bulk classifier pass +//! +//! # Direct: +//! cargo run --bin atlas -- [args] +//! ``` + +use std::path::PathBuf; +use std::process; + +use clap::{Parser, Subcommand}; +use rusqlite::{params, Connection}; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// CLI structure +// --------------------------------------------------------------------------- + +#[derive(Parser)] +#[command( + name = "atlas", + about = "Query and manage celestial bodies and stations in systems.db" +)] +struct Cli { + /// Path to the SQLite database (default: auto-detect from worktree) + #[arg(long, global = true)] + db: Option, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Show full system hierarchy (star → bodies → stations) + ShowSystem { + /// System ID (e.g., "GJ 15A") + system_id: String, + }, + /// Show a single body's details + ShowBody { + /// Body ID (e.g., "GJ 15Ab") + body_id: String, + }, + /// Show a single station's details + ShowStation { + /// Station ID (e.g., "GJ 15Ab-S1") + station_id: String, + }, + /// List bodies with optional filters + ListBodies { + #[arg(long)] + system: Option, + #[arg(long, value_name = "TYPE")] + r#type: Option, + #[arg(long)] + inhabited: bool, + #[arg(long)] + unnamed: bool, + }, + /// List stations with optional filters + ListStations { + #[arg(long)] + system: Option, + #[arg(long, value_name = "TYPE")] + r#type: Option, + }, + /// Add a body record + AddBody { + #[arg(long)] + id: String, + #[arg(long)] + system: String, + #[arg(long, value_name = "TYPE")] + r#type: String, + #[arg(long)] + orbit: Option, + #[arg(long)] + name: Option, + #[arg(long)] + parent: Option, + #[arg(long)] + mass_class: Option, + #[arg(long)] + atmosphere: Option, + #[arg(long)] + gravity: Option, + #[arg(long)] + biome: Option, + #[arg(long)] + inhabited: bool, + #[arg(long)] + population: Option, + }, + /// Add a station record + AddStation { + #[arg(long)] + id: String, + #[arg(long)] + system: String, + #[arg(long)] + orbits: Option, + #[arg(long, value_name = "TYPE")] + r#type: String, + #[arg(long)] + name: Option, + #[arg(long)] + population: Option, + #[arg(long)] + docking: Option, + #[arg(long)] + gate: bool, + }, + /// Database statistics + Stats, + /// Bulk populate bodies from existing system data (classifier pass) + Populate { + /// Dry run — show what would be created without writing + #[arg(long)] + dry_run: bool, + }, +} + +// --------------------------------------------------------------------------- +// Output types +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct BodyRow { + body_id: String, + system_id: String, + parent_body_id: Option, + body_type: String, + orbit_index: Option, + proper_name: Option, + mass_class: Option, + atmosphere: Option, + surface_gravity: Option, + biome_summary: Option, + hydrosphere: Option, + inhabited: bool, + population: i64, + economic_role: Option, + cultural_corridor: Option, + industrial_corridor: Option, +} + +#[derive(Serialize)] +struct StationRow { + station_id: String, + system_id: String, + orbits_body_id: Option, + station_type: String, + proper_name: Option, + population: i64, + economic_role: Option, + governance_type: Option, + docking_class: Option, + has_gate_infrastructure: bool, + district_count: i32, +} + +#[derive(Serialize)] +struct SystemSummary { + system_id: String, + proper_name: Option, + star_type: Option, + geographic_sector: Option, + habitable_planet_count: Option, + inhabited_planet_count: Option, + bodies: Vec, + stations: Vec, +} + +#[derive(Serialize)] +struct StatsOutput { + systems: i64, + bodies: i64, + bodies_by_type: Vec, + inhabited_bodies: i64, + stations: i64, + stations_by_type: Vec, + systems_with_bodies: i64, + systems_without_bodies: i64, +} + +#[derive(Serialize)] +struct TypeCount { + r#type: String, + count: i64, +} + +#[derive(Serialize)] +struct PopulateAction { + action: String, + id: String, + system_id: String, + body_type: String, + orbit_index: Option, +} + +// --------------------------------------------------------------------------- +// Database helpers +// --------------------------------------------------------------------------- + +fn resolve_db_path(explicit: Option) -> PathBuf { + if let Some(p) = explicit { + return p; + } + // Walk up from CWD looking for server/data/systems.db + let mut dir = std::env::current_dir().expect("Cannot determine CWD"); + loop { + let candidate = dir.join("server").join("data").join("systems.db"); + if candidate.exists() { + return candidate; + } + if !dir.pop() { + break; + } + } + eprintln!("error: cannot find server/data/systems.db — pass --db explicitly"); + process::exit(1); +} + +fn open_db(path: &PathBuf) -> Connection { + let conn = Connection::open(path).unwrap_or_else(|e| { + eprintln!("error: cannot open {}: {}", path.display(), e); + process::exit(1); + }); + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .unwrap(); + conn +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +fn cmd_show_system(conn: &Connection, system_id: &str) { + let mut stmt = conn + .prepare( + "SELECT system_id, proper_name, star_type, geographic_sector, + habitable_planet_count, inhabited_planet_count + FROM star_systems WHERE system_id = ?1", + ) + .unwrap(); + + let sys: Option = stmt + .query_row(params![system_id], |row| { + Ok(SystemSummary { + system_id: row.get(0)?, + proper_name: row.get(1)?, + star_type: row.get(2)?, + geographic_sector: row.get(3)?, + habitable_planet_count: row.get(4)?, + inhabited_planet_count: row.get(5)?, + bodies: Vec::new(), + stations: Vec::new(), + }) + }) + .ok(); + + let Some(mut sys) = sys else { + eprintln!("error: system '{}' not found", system_id); + process::exit(1); + }; + + sys.bodies = query_bodies(conn, Some(system_id), None, false, false); + sys.stations = query_stations(conn, Some(system_id), None); + + println!("{}", serde_json::to_string_pretty(&sys).unwrap()); +} + +fn cmd_show_body(conn: &Connection, body_id: &str) { + let row = conn + .query_row( + "SELECT body_id, system_id, parent_body_id, body_type, orbit_index, + proper_name, mass_class, atmosphere, surface_gravity, + biome_summary, hydrosphere, inhabited, population, + economic_role, cultural_corridor, industrial_corridor + FROM bodies WHERE body_id = ?1", + params![body_id], + |row| { + Ok(BodyRow { + body_id: row.get(0)?, + system_id: row.get(1)?, + parent_body_id: row.get(2)?, + body_type: row.get(3)?, + orbit_index: row.get(4)?, + proper_name: row.get(5)?, + mass_class: row.get(6)?, + atmosphere: row.get(7)?, + surface_gravity: row.get(8)?, + biome_summary: row.get(9)?, + hydrosphere: row.get(10)?, + inhabited: row.get::<_, i32>(11)? != 0, + population: row.get::<_, Option>(12)?.unwrap_or(0), + economic_role: row.get(13)?, + cultural_corridor: row.get(14)?, + industrial_corridor: row.get(15)?, + }) + }, + ) + .unwrap_or_else(|_| { + eprintln!("error: body '{}' not found", body_id); + process::exit(1); + }); + + // Also fetch stations orbiting this body + let stations = query_stations_for_body(conn, body_id); + + #[derive(Serialize)] + struct BodyDetail { + #[serde(flatten)] + body: BodyRow, + stations: Vec, + } + + let detail = BodyDetail { + body: row, + stations, + }; + println!("{}", serde_json::to_string_pretty(&detail).unwrap()); +} + +fn cmd_show_station(conn: &Connection, station_id: &str) { + let row = conn + .query_row( + "SELECT station_id, system_id, orbits_body_id, station_type, + proper_name, population, economic_role, governance_type, + docking_class, has_gate_infrastructure, district_count + FROM stations WHERE station_id = ?1", + params![station_id], + |row| { + Ok(StationRow { + station_id: row.get(0)?, + system_id: row.get(1)?, + orbits_body_id: row.get(2)?, + station_type: row.get(3)?, + proper_name: row.get(4)?, + population: row.get::<_, Option>(5)?.unwrap_or(0), + economic_role: row.get(6)?, + governance_type: row.get(7)?, + docking_class: row.get(8)?, + has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, + district_count: row.get::<_, Option>(10)?.unwrap_or(1), + }) + }, + ) + .unwrap_or_else(|_| { + eprintln!("error: station '{}' not found", station_id); + process::exit(1); + }); + + println!("{}", serde_json::to_string_pretty(&row).unwrap()); +} + +fn query_bodies( + conn: &Connection, + system: Option<&str>, + body_type: Option<&str>, + inhabited_only: bool, + unnamed_only: bool, +) -> Vec { + let mut sql = String::from( + "SELECT body_id, system_id, parent_body_id, body_type, orbit_index, + proper_name, mass_class, atmosphere, surface_gravity, + biome_summary, hydrosphere, inhabited, population, + economic_role, cultural_corridor, industrial_corridor + FROM bodies WHERE 1=1", + ); + let mut param_values: Vec> = Vec::new(); + + if let Some(s) = system { + sql.push_str(" AND system_id = ?"); + param_values.push(Box::new(s.to_string())); + } + if let Some(t) = body_type { + sql.push_str(" AND body_type = ?"); + param_values.push(Box::new(t.to_string())); + } + if inhabited_only { + sql.push_str(" AND inhabited = 1"); + } + if unnamed_only { + sql.push_str(" AND proper_name IS NULL"); + } + sql.push_str(" ORDER BY system_id, orbit_index"); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql).unwrap(); + let rows = stmt + .query_map(params_ref.as_slice(), |row| { + Ok(BodyRow { + body_id: row.get(0)?, + system_id: row.get(1)?, + parent_body_id: row.get(2)?, + body_type: row.get(3)?, + orbit_index: row.get(4)?, + proper_name: row.get(5)?, + mass_class: row.get(6)?, + atmosphere: row.get(7)?, + surface_gravity: row.get(8)?, + biome_summary: row.get(9)?, + hydrosphere: row.get(10)?, + inhabited: row.get::<_, i32>(11)? != 0, + population: row.get::<_, Option>(12)?.unwrap_or(0), + economic_role: row.get(13)?, + cultural_corridor: row.get(14)?, + industrial_corridor: row.get(15)?, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + rows +} + +fn query_stations( + conn: &Connection, + system: Option<&str>, + station_type: Option<&str>, +) -> Vec { + let mut sql = String::from( + "SELECT station_id, system_id, orbits_body_id, station_type, + proper_name, population, economic_role, governance_type, + docking_class, has_gate_infrastructure, district_count + FROM stations WHERE 1=1", + ); + let mut param_values: Vec> = Vec::new(); + + if let Some(s) = system { + sql.push_str(" AND system_id = ?"); + param_values.push(Box::new(s.to_string())); + } + if let Some(t) = station_type { + sql.push_str(" AND station_type = ?"); + param_values.push(Box::new(t.to_string())); + } + sql.push_str(" ORDER BY system_id, station_id"); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql).unwrap(); + stmt.query_map(params_ref.as_slice(), |row| { + Ok(StationRow { + station_id: row.get(0)?, + system_id: row.get(1)?, + orbits_body_id: row.get(2)?, + station_type: row.get(3)?, + proper_name: row.get(4)?, + population: row.get::<_, Option>(5)?.unwrap_or(0), + economic_role: row.get(6)?, + governance_type: row.get(7)?, + docking_class: row.get(8)?, + has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, + district_count: row.get::<_, Option>(10)?.unwrap_or(1), + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +fn query_stations_for_body(conn: &Connection, body_id: &str) -> Vec { + let mut stmt = conn + .prepare( + "SELECT station_id, system_id, orbits_body_id, station_type, + proper_name, population, economic_role, governance_type, + docking_class, has_gate_infrastructure, district_count + FROM stations WHERE orbits_body_id = ?1 ORDER BY station_id", + ) + .unwrap(); + stmt.query_map(params![body_id], |row| { + Ok(StationRow { + station_id: row.get(0)?, + system_id: row.get(1)?, + orbits_body_id: row.get(2)?, + station_type: row.get(3)?, + proper_name: row.get(4)?, + population: row.get::<_, Option>(5)?.unwrap_or(0), + economic_role: row.get(6)?, + governance_type: row.get(7)?, + docking_class: row.get(8)?, + has_gate_infrastructure: row.get::<_, Option>(9)?.unwrap_or(0) != 0, + district_count: row.get::<_, Option>(10)?.unwrap_or(1), + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect() +} + +fn cmd_list_bodies( + conn: &Connection, + system: Option<&str>, + body_type: Option<&str>, + inhabited: bool, + unnamed: bool, +) { + let bodies = query_bodies(conn, system, body_type, inhabited, unnamed); + println!("{}", serde_json::to_string_pretty(&bodies).unwrap()); +} + +fn cmd_list_stations(conn: &Connection, system: Option<&str>, station_type: Option<&str>) { + let stations = query_stations(conn, system, station_type); + println!("{}", serde_json::to_string_pretty(&stations).unwrap()); +} + +fn cmd_add_body(conn: &Connection, args: &Commands) { + let Commands::AddBody { + id, system, r#type, orbit, name, parent, + mass_class, atmosphere, gravity, biome, + inhabited, population, + } = args else { unreachable!() }; + + conn.execute( + "INSERT INTO bodies (body_id, system_id, parent_body_id, body_type, orbit_index, + proper_name, mass_class, atmosphere, surface_gravity, + biome_summary, inhabited, population) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + id, system, parent, r#type, orbit, + name, mass_class, atmosphere, gravity, + biome, *inhabited as i32, population.unwrap_or(0), + ], + ) + .unwrap_or_else(|e| { + eprintln!("error: {}", e); + process::exit(1); + }); + + println!(r#"{{"ok": true, "body_id": "{}"}}"#, id); +} + +fn cmd_add_station(conn: &Connection, args: &Commands) { + let Commands::AddStation { + id, system, orbits, r#type, name, + population, docking, gate, + } = args else { unreachable!() }; + + conn.execute( + "INSERT INTO stations (station_id, system_id, orbits_body_id, station_type, + proper_name, population, docking_class, has_gate_infrastructure) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id, system, orbits, r#type, name, + population.unwrap_or(0), docking, *gate as i32, + ], + ) + .unwrap_or_else(|e| { + eprintln!("error: {}", e); + process::exit(1); + }); + + println!(r#"{{"ok": true, "station_id": "{}"}}"#, id); +} + +fn cmd_stats(conn: &Connection) { + let systems: i64 = conn + .query_row("SELECT COUNT(*) FROM star_systems", [], |r| r.get(0)) + .unwrap(); + let bodies: i64 = conn + .query_row("SELECT COUNT(*) FROM bodies", [], |r| r.get(0)) + .unwrap(); + let inhabited_bodies: i64 = conn + .query_row("SELECT COUNT(*) FROM bodies WHERE inhabited = 1", [], |r| r.get(0)) + .unwrap(); + let stations: i64 = conn + .query_row("SELECT COUNT(*) FROM stations", [], |r| r.get(0)) + .unwrap(); + let systems_with_bodies: i64 = conn + .query_row( + "SELECT COUNT(DISTINCT system_id) FROM bodies", + [], + |r| r.get(0), + ) + .unwrap(); + + let mut bodies_by_type = Vec::new(); + { + let mut stmt = conn + .prepare("SELECT body_type, COUNT(*) FROM bodies GROUP BY body_type ORDER BY body_type") + .unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(TypeCount { + r#type: row.get(0)?, + count: row.get(1)?, + }) + }) + .unwrap(); + for r in rows.flatten() { + bodies_by_type.push(r); + } + } + + let mut stations_by_type = Vec::new(); + { + let mut stmt = conn + .prepare("SELECT station_type, COUNT(*) FROM stations GROUP BY station_type ORDER BY station_type") + .unwrap(); + let rows = stmt + .query_map([], |row| { + Ok(TypeCount { + r#type: row.get(0)?, + count: row.get(1)?, + }) + }) + .unwrap(); + for r in rows.flatten() { + stations_by_type.push(r); + } + } + + let stats = StatsOutput { + systems, + bodies, + bodies_by_type, + inhabited_bodies, + stations, + stations_by_type, + systems_with_bodies, + systems_without_bodies: systems - systems_with_bodies, + }; + println!("{}", serde_json::to_string_pretty(&stats).unwrap()); +} + +fn cmd_populate(conn: &Connection, dry_run: bool) { + // Read all systems with their physical properties + let mut stmt = conn + .prepare( + "SELECT s.system_id, s.habitable_planet_count, s.inhabited_planet_count, + s.asteroid_belt, s.gas_giant, s.star_type, + g.horizon_station + FROM star_systems s + LEFT JOIN system_gates g ON s.system_id = g.system_id + ORDER BY s.system_id", + ) + .unwrap(); + + struct SystemInfo { + system_id: String, + habitable: i32, + inhabited: i32, + has_belt: bool, + has_gas_giant: bool, + has_horizon: bool, + } + + let systems: Vec = stmt + .query_map([], |row| { + Ok(SystemInfo { + system_id: row.get(0)?, + habitable: row.get::<_, Option>(1)?.unwrap_or(0), + inhabited: row.get::<_, Option>(2)?.unwrap_or(0), + has_belt: row.get::<_, Option>(3)?.unwrap_or(0) != 0, + has_gas_giant: row.get::<_, Option>(4)?.unwrap_or(0) != 0, + has_horizon: row.get::<_, Option>(6)?.unwrap_or(0) != 0, + }) + }) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + + // Check which systems already have bodies + let existing: std::collections::HashSet = { + let mut stmt = conn + .prepare("SELECT DISTINCT system_id FROM bodies") + .unwrap(); + stmt.query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect() + }; + + let mut actions: Vec = Vec::new(); + + for sys in &systems { + if existing.contains(&sys.system_id) { + continue; + } + + let sid = &sys.system_id; + // Normalize system ID for body naming (replace spaces with empty) + let sid_compact = sid.replace(' ', ""); + let mut orbit = 1; + + // Inner rocky planets (uninhabited count = habitable - inhabited, minimum 0) + // We place inhabited planets first, then habitable-but-uninhabited + let uninhabited_habitable = (sys.habitable - sys.inhabited).max(0); + + // Inhabited planets + for _ in 0..sys.inhabited { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = format!("{}{}", sid_compact, letter); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id, + system_id: sid.clone(), + body_type: "planet".into(), + orbit_index: Some(orbit), + }); + orbit += 1; + } + + // Habitable but uninhabited planets + for _ in 0..uninhabited_habitable { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = format!("{}{}", sid_compact, letter); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id, + system_id: sid.clone(), + body_type: "planet".into(), + orbit_index: Some(orbit), + }); + orbit += 1; + } + + // Asteroid belt + if sys.has_belt { + let body_id = format!("{}-belt", sid_compact); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id, + system_id: sid.clone(), + body_type: "asteroid_belt".into(), + orbit_index: Some(orbit), + }); + orbit += 1; + } + + // Gas giant + if sys.has_gas_giant { + let letter = (b'b' + orbit as u8 - 1) as char; + let body_id = format!("{}{}", sid_compact, letter); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id, + system_id: sid.clone(), + body_type: "gas_giant".into(), + orbit_index: Some(orbit), + }); + orbit += 1; + } + + // Oort cloud (always present — every system has one) + { + let body_id = format!("{}-oort", sid_compact); + actions.push(PopulateAction { + action: if dry_run { "would_create".into() } else { "created".into() }, + id: body_id.clone(), + system_id: sid.clone(), + body_type: "oort_cloud".into(), + orbit_index: Some(orbit), + }); + + // Horizon station in the oort cloud + if sys.has_horizon { + actions.push(PopulateAction { + action: if dry_run { "would_create_station".into() } else { "created_station".into() }, + id: format!("{}-oort-S1", sid_compact), + system_id: sid.clone(), + body_type: "horizon".into(), + orbit_index: None, + }); + } + } + } + + // Execute if not dry run + if !dry_run { + let tx = conn.unchecked_transaction().unwrap(); + for action in &actions { + if action.action == "created" { + let inhabited = if action.body_type == "planet" { + // Check if this is one of the inhabited planets (first N by orbit) + let sys = systems.iter().find(|s| s.system_id == action.system_id).unwrap(); + action.orbit_index.unwrap_or(0) <= sys.inhabited + } else { + false + }; + + tx.execute( + "INSERT OR IGNORE INTO bodies (body_id, system_id, body_type, orbit_index, inhabited) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + action.id, + action.system_id, + action.body_type, + action.orbit_index, + inhabited as i32, + ], + ) + .unwrap(); + } else if action.action == "created_station" { + let oort_id = format!( + "{}-oort", + action.system_id.replace(' ', "") + ); + tx.execute( + "INSERT OR IGNORE INTO stations (station_id, system_id, orbits_body_id, + station_type, has_gate_infrastructure) + VALUES (?1, ?2, ?3, ?4, 1)", + params![action.id, action.system_id, oort_id, "horizon"], + ) + .unwrap(); + } + } + tx.commit().unwrap(); + } + + #[derive(Serialize)] + struct PopulateResult { + dry_run: bool, + actions: Vec, + total_bodies: usize, + total_stations: usize, + systems_populated: usize, + } + + let station_count = actions.iter().filter(|a| a.action.contains("station")).count(); + let body_count = actions.len() - station_count; + let system_count = actions + .iter() + .map(|a| &a.system_id) + .collect::>() + .len(); + + let result = PopulateResult { + dry_run, + actions, + total_bodies: body_count, + total_stations: station_count, + systems_populated: system_count, + }; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +fn main() { + let cli = Cli::parse(); + let db_path = resolve_db_path(cli.db); + let conn = open_db(&db_path); + + match &cli.command { + Commands::ShowSystem { system_id } => cmd_show_system(&conn, system_id), + Commands::ShowBody { body_id } => cmd_show_body(&conn, body_id), + Commands::ShowStation { station_id } => cmd_show_station(&conn, station_id), + Commands::ListBodies { system, r#type, inhabited, unnamed } => { + cmd_list_bodies(&conn, system.as_deref(), r#type.as_deref(), *inhabited, *unnamed) + } + Commands::ListStations { system, r#type } => { + cmd_list_stations(&conn, system.as_deref(), r#type.as_deref()) + } + cmd @ Commands::AddBody { .. } => cmd_add_body(&conn, cmd), + cmd @ Commands::AddStation { .. } => cmd_add_station(&conn, cmd), + Commands::Stats => cmd_stats(&conn), + Commands::Populate { dry_run } => cmd_populate(&conn, *dry_run), + } +} diff --git a/tooling/atlas b/tooling/atlas new file mode 100755 index 000000000..621944072 --- /dev/null +++ b/tooling/atlas @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Atlas CLI wrapper — celestial bodies and stations in systems.db. +# +# Usage: +# tooling/atlas stats +# tooling/atlas show-system "GJ 15A" +# tooling/atlas list-bodies --system "GJ 15A" +# tooling/atlas populate --dry-run +# +# Builds on first run if binary doesn't exist. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BIN="$ROOT_DIR/server/target/debug/atlas" + +# Build if needed +if [ ! -f "$BIN" ]; then + echo "Building atlas..." >&2 + (cd "$ROOT_DIR/server" && cargo build --bin atlas 2>&1 | tail -3) >&2 +fi + +exec "$BIN" "$@"