clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

feat: friendly adjective-noun ids (lg_stern-wolf, sim_gilded-vale, usr_quiet-heron)

Dami committed e6243a5
Shared adjective pool, three disjoint noun pools, seeded deterministically
(content hash / game seed / external id) with collision retry. Ids are now
names, not capabilities: script source + createSim are owner-only; sims stay
public by link.
commit
e6243a50065a1314a1a4c19cfb60d1a80792838c
parent
dbee17d877671fb59a408ca9bc90964f04c67dda
author
Dami <dami.etoile@gmail.com>
23 files changed, +987 -16
M.gitignore1+
A.playwright-cli/console-2026-08-15T23-05-39-338Z.log1+
A.playwright-cli/console-2026-08-15T23-26-23-004Z.log3+
A.playwright-cli/console-2026-08-15T23-26-56-660Z.log9+
A.playwright-cli/console-2026-08-15T23-46-25-811Z.log1+
A.playwright-cli/page-2026-08-15T23-05-39-791Z.yml9+
A.playwright-cli/page-2026-08-15T23-05-52-800Z.pngbin
A.playwright-cli/page-2026-08-15T23-06-07-516Z.yml118+++++++
A.playwright-cli/page-2026-08-15T23-26-23-375Z.yml9+
A.playwright-cli/page-2026-08-15T23-26-56-844Z.yml9+
A.playwright-cli/page-2026-08-15T23-27-20-930Z.pngbin
A.playwright-cli/page-2026-08-15T23-28-05-639Z.pngbin
A.playwright-cli/page-2026-08-15T23-28-18-540Z.pngbin
A.playwright-cli/page-2026-08-15T23-28-30-614Z.pngbin
A.playwright-cli/page-2026-08-15T23-28-42-631Z.pngbin
A.playwright-cli/page-2026-08-15T23-29-17-774Z.yml124+++++++
A.playwright-cli/page-2026-08-15T23-29-23-434Z.pngbin
A.playwright-cli/page-2026-08-15T23-46-26-279Z.yml9+
A.playwright-cli/page-2026-08-15T23-46-43-120Z.yml533++++++++++++++++++++++++++++++
A.playwright-cli/page-2026-08-15T23-47-08-915Z.pngbin
Mworker/src/db.ts35+-
Aworker/src/friendly-id.ts97+++++
Mworker/src/service.ts45++-
M .gitignore +1 -0
A .playwright-cli/console-2026-08-15T23-05-39-338Z.log +1 -0
A .playwright-cli/console-2026-08-15T23-26-23-004Z.log +3 -0
A .playwright-cli/console-2026-08-15T23-26-56-660Z.log +9 -0
A .playwright-cli/console-2026-08-15T23-46-25-811Z.log +1 -0
A .playwright-cli/page-2026-08-15T23-05-39-791Z.yml +9 -0
A .playwright-cli/page-2026-08-15T23-05-52-800Z.png +0 -0

Binary file.

A .playwright-cli/page-2026-08-15T23-06-07-516Z.yml +118 -0
@@ -0,0 +1,118 @@
1+- generic [ref=e2]:2+ - generic [ref=e10]:3+ - link "TRIBES" [ref=e11] [cursor=pointer]:4+ - /url: /5+ - generic [ref=e12]: strategy-script fishbowl6+ - generic [ref=e13]: not signed in7+ - main [ref=e14]:8+ - generic [ref=e15]:9+ - heading "Sim sim_7jmZy31lPdgJx7SZUfj-8" [level=1] [ref=e16]10+ - generic [ref=e17]: seed 272639499400926 · 4 tribes · map 6411+ - generic [ref=e18]:12+ - generic [ref=e19]:13+ - generic [ref=e22]:14+ - button "Pause" [ref=e23] [cursor=pointer]15+ - button "1×" [ref=e24] [cursor=pointer]16+ - button "4×" [ref=e25] [cursor=pointer]17+ - button "16×" [ref=e26] [cursor=pointer]18+ - button "max" [active] [ref=e27] [cursor=pointer]19+ - button "Restart" [ref=e28] [cursor=pointer]20+ - button "Zoom 1:1" [ref=e29] [cursor=pointer]21+ - combobox [ref=e30]:22+ - 'option "POV: omniscient" [selected]'23+ - 'option "POV: warden (slot 0)"'24+ - 'option "POV: raider (slot 1)"'25+ - 'option "POV: gatherer-classic (slot 2)"'26+ - 'option "POV: rusher-classic (slot 3)"'27+ - generic [ref=e31]: tick 1675 / 1000028+ - paragraph29+ - generic [ref=e35]:30+ - generic [ref=e36]:31+ - generic [ref=e37]:32+ - generic [ref=e39]: warden33+ - generic [ref=e40]: Human Hero34+ - generic "over gas budget" [ref=e41]35+ - generic [ref=e42]: alive36+ - generic [ref=e43]:37+ - generic "food" [ref=e44]:38+ - img "food" [ref=e45]39+ - generic [ref=e46]: "500"40+ - generic "wood" [ref=e47]:41+ - img "wood" [ref=e48]42+ - generic [ref=e49]: "9"43+ - generic "stone" [ref=e50]:44+ - img "stone" [ref=e51]45+ - generic [ref=e52]: "40"46+ - generic "population / cap" [ref=e53]:47+ - text: pop48+ - generic [ref=e54]: 14/3549+ - generic "buildings" [ref=e55]:50+ - text: bld51+ - generic [ref=e56]: "8"52+ - generic [ref=e57]:53+ - generic [ref=e58]:54+ - generic [ref=e60]: raider55+ - generic [ref=e61]: Human Knight56+ - generic "over gas budget" [ref=e62]57+ - generic [ref=e63]: alive58+ - generic [ref=e64]:59+ - generic "food" [ref=e65]:60+ - img "food" [ref=e66]61+ - generic [ref=e67]: "300"62+ - generic "wood" [ref=e68]:63+ - img "wood" [ref=e69]64+ - generic [ref=e70]: "300"65+ - generic "stone" [ref=e71]:66+ - img "stone" [ref=e72]67+ - generic [ref=e73]: "300"68+ - generic "population / cap" [ref=e74]:69+ - text: pop70+ - generic [ref=e75]: 8/1071+ - generic "buildings" [ref=e76]:72+ - text: bld73+ - generic [ref=e77]: "73"74+ - generic [ref=e78]:75+ - generic [ref=e79]:76+ - generic [ref=e81]: gatherer-classic77+ - generic [ref=e82]: Dwarf Warrior78+ - generic "over gas budget" [ref=e83]79+ - generic [ref=e84]: alive80+ - generic [ref=e85]:81+ - generic "food" [ref=e86]:82+ - img "food" [ref=e87]83+ - generic [ref=e88]: "300"84+ - generic "wood" [ref=e89]:85+ - img "wood" [ref=e90]86+ - generic [ref=e91]: "24"87+ - generic "stone" [ref=e92]:88+ - img "stone" [ref=e93]89+ - generic [ref=e94]: "300"90+ - generic "population / cap" [ref=e95]:91+ - text: pop92+ - generic [ref=e96]: 10/1093+ - generic "buildings" [ref=e97]:94+ - text: bld95+ - generic [ref=e98]: "156"96+ - generic [ref=e99]:97+ - generic [ref=e100]:98+ - generic [ref=e102]: rusher-classic99+ - generic [ref=e103]: Dwarf Forger100+ - generic "over gas budget" [ref=e104]101+ - generic [ref=e105]: alive102+ - generic [ref=e106]:103+ - generic "food" [ref=e107]:104+ - img "food" [ref=e108]105+ - generic [ref=e109]: "300"106+ - generic "wood" [ref=e110]:107+ - img "wood" [ref=e111]108+ - generic [ref=e112]: "300"109+ - generic "stone" [ref=e113]:110+ - img "stone" [ref=e114]111+ - generic [ref=e115]: "0"112+ - generic "population / cap" [ref=e116]:113+ - text: pop114+ - generic [ref=e117]: 5/10115+ - generic "buildings" [ref=e118]:116+ - text: bld117+ - generic [ref=e119]: "2"118+ - contentinfo [ref=e120]: "Art: Ricardo Machado (beowulf.itch.io) · Icons: SSUGMI · Game assets are licensed for use in this game and are not redistributed. Sims & scripts expire after 14 days unless starred."\ No newline at end of file
A .playwright-cli/page-2026-08-15T23-26-23-375Z.yml +9 -0
A .playwright-cli/page-2026-08-15T23-26-56-844Z.yml +9 -0
A .playwright-cli/page-2026-08-15T23-27-20-930Z.png +0 -0

Binary file.

A .playwright-cli/page-2026-08-15T23-28-05-639Z.png +0 -0

Binary file.

A .playwright-cli/page-2026-08-15T23-28-18-540Z.png +0 -0

Binary file.

A .playwright-cli/page-2026-08-15T23-28-30-614Z.png +0 -0

Binary file.

A .playwright-cli/page-2026-08-15T23-28-42-631Z.png +0 -0

Binary file.

A .playwright-cli/page-2026-08-15T23-29-17-774Z.yml +124 -0
@@ -0,0 +1,124 @@
1+- generic [ref=e1]:2+ - generic [ref=e2]:3+ - generic [ref=e3]:4+ - link "TRIBES" [ref=e4] [cursor=pointer]:5+ - /url: /6+ - generic [ref=e5]: strategy-script fishbowl7+ - generic [ref=e6]: not signed in8+ - main [ref=e7]:9+ - generic [ref=e10]:10+ - heading "Sim test" [level=1] [ref=e11]11+ - generic [ref=e12]: seed 123456789 · 4 tribes · map 6412+ - generic [ref=e13]: "[mock engine]"13+ - generic [ref=e14]:14+ - generic [ref=e15]:15+ - generic [ref=e18]:16+ - button "Play" [active] [ref=e609] [cursor=pointer]17+ - button "1×" [ref=e20] [cursor=pointer]18+ - button "4×" [ref=e21] [cursor=pointer]19+ - button "16×" [ref=e22] [cursor=pointer]20+ - button "max" [ref=e23] [cursor=pointer]21+ - button "Restart" [ref=e24] [cursor=pointer]22+ - button "Zoom 1:1" [ref=e25] [cursor=pointer]23+ - combobox [ref=e26]:24+ - 'option "POV: omniscient"'25+ - 'option "POV: gatherers (slot 0)"'26+ - 'option "POV: raiders (slot 1)" [selected]'27+ - 'option "POV: turtles (slot 2)"'28+ - 'option "POV: nomads (slot 3)"'29+ - generic [ref=e27]: tick 2703 / 1000030+ - paragraph31+ - generic [ref=e31]:32+ - generic [ref=e32]:33+ - generic [ref=e33]:34+ - generic [ref=e35]: gatherers35+ - generic [ref=e36]: Human Hero36+ - generic "over gas budget" [ref=e37]37+ - generic [ref=e38]: alive38+ - generic [ref=e39]:39+ - generic "food" [ref=e40]:40+ - img "food" [ref=e41]41+ - generic [ref=e42]: "2211"42+ - generic "wood" [ref=e43]:43+ - img "wood" [ref=e44]44+ - generic [ref=e45]: "30"45+ - generic "stone" [ref=e46]:46+ - img "stone" [ref=e47]47+ - generic [ref=e48]: "427"48+ - generic "population / cap" [ref=e49]:49+ - text: pop50+ - generic [ref=e50]: 10/1051+ - generic "buildings" [ref=e51]:52+ - text: bld53+ - generic [ref=e52]: "1"54+ - generic [ref=e53]:55+ - generic [ref=e54]:56+ - generic [ref=e56]: raiders57+ - generic [ref=e57]: Human Knight58+ - generic "over gas budget" [ref=e58]59+ - generic [ref=e59]: alive60+ - generic [ref=e60]:61+ - generic "food" [ref=e61]:62+ - img "food" [ref=e62]63+ - generic [ref=e63]: "2423"64+ - generic "wood" [ref=e64]:65+ - img "wood" [ref=e65]66+ - generic [ref=e66]: "8978"67+ - generic "stone" [ref=e67]:68+ - img "stone" [ref=e68]69+ - generic [ref=e69]: "812"70+ - generic "population / cap" [ref=e70]:71+ - text: pop72+ - generic [ref=e71]: 50/5073+ - generic "buildings" [ref=e72]:74+ - text: bld75+ - generic [ref=e73]: "24"76+ - generic [ref=e74]:77+ - generic [ref=e75]:78+ - generic [ref=e77]: turtles79+ - generic [ref=e78]: Dwarf Warrior80+ - generic "over gas budget" [ref=e79]81+ - generic [ref=e80]: alive82+ - generic [ref=e81]:83+ - generic "food" [ref=e82]:84+ - img "food" [ref=e83]85+ - generic [ref=e84]: "1792"86+ - generic "wood" [ref=e85]:87+ - img "wood" [ref=e86]88+ - generic [ref=e87]: "12856"89+ - generic "stone" [ref=e88]:90+ - img "stone" [ref=e89]91+ - generic [ref=e90]: "10"92+ - generic "population / cap" [ref=e91]:93+ - text: pop94+ - generic [ref=e92]: 50/5095+ - generic "buildings" [ref=e93]:96+ - text: bld97+ - generic [ref=e94]: "24"98+ - generic [ref=e95]:99+ - generic [ref=e96]:100+ - generic [ref=e98]: nomads101+ - generic [ref=e99]: Dwarf Forger102+ - generic "over gas budget" [ref=e100]103+ - generic [ref=e101]: alive104+ - generic [ref=e102]:105+ - generic "food" [ref=e103]:106+ - img "food" [ref=e104]107+ - generic [ref=e105]: "1754"108+ - generic "wood" [ref=e106]:109+ - img "wood" [ref=e107]110+ - generic [ref=e108]: "30"111+ - generic "stone" [ref=e109]:112+ - img "stone" [ref=e110]113+ - generic [ref=e111]: "1271"114+ - generic "population / cap" [ref=e112]:115+ - text: pop116+ - generic [ref=e113]: 10/10117+ - generic "buildings" [ref=e114]:118+ - text: bld119+ - generic [ref=e115]: "1"120+ - generic [ref=e116]: Events121+ - contentinfo [ref=e9]: "Art: Ricardo Machado (beowulf.itch.io) · Icons: SSUGMI · Game assets are licensed for use in this game and are not redistributed. Sims & scripts expire after 14 days unless starred."122+ - generic:123+ - generic: town center — nomads124+ - generic: hp 500/500\ No newline at end of file
A .playwright-cli/page-2026-08-15T23-29-23-434Z.png +0 -0

Binary file.

A .playwright-cli/page-2026-08-15T23-46-26-279Z.yml +9 -0
A .playwright-cli/page-2026-08-15T23-46-43-120Z.yml +533 -0
@@ -0,0 +1,533 @@
1+- generic [ref=e2]:2+ - generic [ref=e10]:3+ - link "TRIBES" [ref=e11] [cursor=pointer]:4+ - /url: /5+ - generic [ref=e12]: strategy-script fishbowl6+ - generic [ref=e13]: not signed in7+ - main [ref=e14]:8+ - generic [ref=e15]:9+ - heading "Sim sim_VJxnrWYTYy0R7yMZXLAWP" [level=1] [ref=e16]10+ - generic [ref=e17]: seed 777777 · 4 tribes · map 6411+ - generic [ref=e18]:12+ - generic [ref=e19]:13+ - generic [ref=e22]:14+ - button "Pause" [ref=e23] [cursor=pointer]15+ - button "1×" [ref=e24] [cursor=pointer]16+ - button "4×" [ref=e25] [cursor=pointer]17+ - button "16×" [active] [ref=e26] [cursor=pointer]18+ - button "max" [ref=e27] [cursor=pointer]19+ - button "Restart" [ref=e28] [cursor=pointer]20+ - button "Zoom 1:1" [ref=e29] [cursor=pointer]21+ - combobox [ref=e30]:22+ - 'option "POV: omniscient" [selected]'23+ - 'option "POV: warden (slot 0)"'24+ - 'option "POV: raider (slot 1)"'25+ - 'option "POV: gatherer-classic (slot 2)"'26+ - 'option "POV: rusher-classic (slot 3)"'27+ - generic [ref=e31]: tick 699 / 1000028+ - paragraph29+ - generic [ref=e35]:30+ - generic [ref=e36]:31+ - generic [ref=e37]:32+ - generic [ref=e39]: warden33+ - generic [ref=e40]: Human Hero34+ - generic "over gas budget" [ref=e41]35+ - generic [ref=e42]: dead36+ - generic [ref=e43]:37+ - generic "food" [ref=e44]:38+ - img "food" [ref=e45]39+ - generic [ref=e46]: "0"40+ - generic "wood" [ref=e47]:41+ - img "wood" [ref=e48]42+ - generic [ref=e49]: "0"43+ - generic "stone" [ref=e50]:44+ - img "stone" [ref=e51]45+ - generic [ref=e52]: "0"46+ - generic "population / cap" [ref=e53]:47+ - text: pop48+ - generic [ref=e54]: 0/049+ - generic "buildings" [ref=e55]:50+ - text: bld51+ - generic [ref=e56]: "0"52+ - generic [ref=e57]:53+ - generic [ref=e58]:54+ - generic [ref=e60]: raider55+ - generic [ref=e61]: Human Knight56+ - generic "over gas budget" [ref=e62]57+ - generic [ref=e63]: alive58+ - generic [ref=e64]:59+ - generic "food" [ref=e65]:60+ - img "food" [ref=e66]61+ - generic [ref=e67]: "300"62+ - generic "wood" [ref=e68]:63+ - img "wood" [ref=e69]64+ - generic [ref=e70]: "20"65+ - generic "stone" [ref=e71]:66+ - img "stone" [ref=e72]67+ - generic [ref=e73]: "300"68+ - generic "population / cap" [ref=e74]:69+ - text: pop70+ - generic [ref=e75]: 45/4571+ - generic "buildings" [ref=e76]:72+ - text: bld73+ - generic [ref=e77]: "16"74+ - generic [ref=e78]:75+ - generic [ref=e79]:76+ - generic [ref=e81]: gatherer-classic77+ - generic [ref=e82]: Dwarf Warrior78+ - generic "over gas budget" [ref=e83]79+ - generic [ref=e84]: alive80+ - generic [ref=e85]:81+ - generic "food" [ref=e86]:82+ - img "food" [ref=e87]83+ - generic [ref=e88]: "300"84+ - generic "wood" [ref=e89]:85+ - img "wood" [ref=e90]86+ - generic [ref=e91]: "16"87+ - generic "stone" [ref=e92]:88+ - img "stone" [ref=e93]89+ - generic [ref=e94]: "300"90+ - generic "population / cap" [ref=e95]:91+ - text: pop92+ - generic [ref=e96]: 10/1093+ - generic "buildings" [ref=e97]:94+ - text: bld95+ - generic [ref=e98]: "69"96+ - generic [ref=e99]:97+ - generic [ref=e100]:98+ - generic [ref=e102]: rusher-classic99+ - generic [ref=e103]: Dwarf Forger100+ - generic "over gas budget" [ref=e104]101+ - generic [ref=e105]: dead102+ - generic [ref=e106]:103+ - generic "food" [ref=e107]:104+ - img "food" [ref=e108]105+ - generic [ref=e109]: "0"106+ - generic "wood" [ref=e110]:107+ - img "wood" [ref=e111]108+ - generic [ref=e112]: "0"109+ - generic "stone" [ref=e113]:110+ - img "stone" [ref=e114]111+ - generic [ref=e115]: "0"112+ - generic "population / cap" [ref=e116]:113+ - text: pop114+ - generic [ref=e117]: 0/0115+ - generic "buildings" [ref=e118]:116+ - text: bld117+ - generic [ref=e119]: "0"118+ - generic [ref=e120]:119+ - generic [ref=e121]: Events120+ - generic [ref=e122]:121+ - generic [ref=e123]:122+ - text: "[t16]"123+ - generic [ref=e124]: warden124+ - text: completed a granary at (55, 31)125+ - generic [ref=e126]:126+ - text: "[t31]"127+ - generic [ref=e127]: warden128+ - text: completed a house at (56, 31)129+ - generic [ref=e129]:130+ - text: "[t33]"131+ - generic [ref=e130]: raider132+ - text: completed a barracks at (31, 55)133+ - generic [ref=e132]:134+ - text: "[t46]"135+ - generic [ref=e133]: raider136+ - text: completed a house at (32, 55)137+ - generic [ref=e135]:138+ - text: "[t49]"139+ - generic [ref=e136]: warden140+ - text: "'s villager starved at (54, 31)"141+ - generic [ref=e138]:142+ - text: "[t49]"143+ - generic [ref=e139]: warden144+ - text: "'s villager starved at (56, 31)"145+ - generic [ref=e141]:146+ - text: "[t49]"147+ - generic [ref=e142]: warden148+ - text: "'s villager starved at (55, 32)"149+ - generic [ref=e144]:150+ - text: "[t57]"151+ - generic [ref=e145]: raider152+ - text: completed a house at (33, 55)153+ - generic [ref=e147]:154+ - text: "[t99]"155+ - generic [ref=e148]: warden156+ - text: "'s villager starved at (57, 31)"157+ - generic [ref=e150]:158+ - text: "[t99]"159+ - generic [ref=e151]: warden160+ - text: "'s villager starved at (58, 31)"161+ - generic [ref=e153]:162+ - text: "[t122]"163+ - generic [ref=e154]: raider164+ - text: sighted165+ - generic [ref=e156]: rusher-classic166+ - text: near (32, 8)167+ - generic [ref=e158]:168+ - text: "[t122]"169+ - generic [ref=e159]: rusher-classic170+ - text: sighted171+ - generic [ref=e161]: raider172+ - text: near (35, 14)173+ - generic [ref=e163]:174+ - text: "[t132]"175+ - generic [ref=e164]: rusher-classic176+ - text: lost a villager to177+ - generic [ref=e166]: raider178+ - text: at (30, 7)179+ - generic [ref=e168]:180+ - text: "[t137]"181+ - generic [ref=e169]: rusher-classic182+ - text: lost a villager to183+ - generic [ref=e171]: raider184+ - text: at (30, 7)185+ - generic [ref=e173]:186+ - text: "[t140]"187+ - generic [ref=e174]: warden188+ - text: completed a barracks at (57, 31)189+ - generic [ref=e176]:190+ - text: "[t142]"191+ - generic [ref=e177]: rusher-classic192+ - text: lost a villager to193+ - generic [ref=e179]: raider194+ - text: at (30, 7)195+ - generic [ref=e181]:196+ - text: "[t146]"197+ - generic [ref=e182]: rusher-classic198+ - text: lost a villager to199+ - generic [ref=e184]: raider200+ - text: at (30, 8)201+ - generic [ref=e186]:202+ - text: "[t150]"203+ - generic [ref=e187]: rusher-classic204+ - text: lost a villager to205+ - generic [ref=e189]: raider206+ - text: at (30, 8)207+ - generic [ref=e191]:208+ - text: "[t155]"209+ - generic [ref=e192]: rusher-classic210+ - text: lost a villager to211+ - generic [ref=e194]: raider212+ - text: at (30, 8)213+ - generic [ref=e196]:214+ - text: "[t160]"215+ - generic [ref=e197]: rusher-classic216+ - text: lost a villager to217+ - generic [ref=e199]: raider218+ - text: at (30, 8)219+ - generic [ref=e201]:220+ - text: "[t165]"221+ - generic [ref=e202]: rusher-classic222+ - text: lost a villager to223+ - generic [ref=e204]: raider224+ - text: at (34, 8)225+ - generic [ref=e206]:226+ - text: "[t169]"227+ - generic [ref=e207]: rusher-classic228+ - text: lost a villager to229+ - generic [ref=e209]: raider230+ - text: at (30, 8)231+ - generic [ref=e211]:232+ - text: "[t172]"233+ - generic [ref=e212]: rusher-classic234+ - text: lost a villager to235+ - generic [ref=e214]: raider236+ - text: at (30, 8)237+ - generic [ref=e216]:238+ - text: "[t175]"239+ - generic [ref=e217]: rusher-classic240+ - text: lost a villager to241+ - generic [ref=e219]: raider242+ - text: at (34, 8)243+ - generic [ref=e221]:244+ - text: "[t177]"245+ - generic [ref=e222]: raider246+ - text: completed a house at (31, 56)247+ - generic [ref=e224]:248+ - text: "[t178]"249+ - generic [ref=e225]: rusher-classic250+ - text: lost a villager to251+ - generic [ref=e227]: raider252+ - text: at (30, 8)253+ - generic [ref=e229]:254+ - text: "[t185]"255+ - generic [ref=e230]: rusher-classic256+ - text: "'s barracks destroyed by"257+ - generic [ref=e232]: raider258+ - text: at (31, 7)259+ - generic [ref=e234]:260+ - text: "[t189]"261+ - generic [ref=e235]: rusher-classic262+ - text: lost a villager to263+ - generic [ref=e237]: raider264+ - text: at (30, 8)265+ - generic [ref=e239]:266+ - text: "[t189]"267+ - generic [ref=e240]: rusher-classic268+ - text: "'s town center destroyed by"269+ - generic [ref=e242]: raider270+ - text: at (32, 8)271+ - generic [ref=e244]:272+ - text: "[t191]"273+ - generic [ref=e245]: rusher-classic274+ - text: lost a villager to275+ - generic [ref=e247]: raider276+ - text: at (34, 7)277+ - generic [ref=e249]:278+ - text: "[t194]"279+ - generic [ref=e250]: rusher-classic280+ - text: lost a villager to281+ - generic [ref=e252]: raider282+ - text: at (34, 7)283+ - generic [ref=e254]:284+ - text: "[t196]"285+ - generic [ref=e255]: rusher-classic286+ - text: lost a villager to287+ - generic [ref=e257]: raider288+ - text: at (34, 7)289+ - generic [ref=e259]:290+ - text: "[t202]"291+ - generic [ref=e260]: rusher-classic292+ - text: "'s barracks destroyed by"293+ - generic [ref=e262]: raider294+ - text: at (31, 7)295+ - generic [ref=e264]:296+ - text: "[t202]"297+ - generic [ref=e265]: rusher-classic298+ - text: lost a villager to299+ - generic [ref=e267]: raider300+ - text: at (32, 5)301+ - generic [ref=e269]:302+ - text: "[t202]"303+ - generic [ref=e270]: rusher-classic304+ - text: was eliminated305+ - generic [ref=e272]:306+ - text: "[t212]"307+ - generic [ref=e273]: warden308+ - text: completed a house at (57, 32)309+ - generic [ref=e275]:310+ - text: "[t217]"311+ - generic [ref=e276]: warden312+ - text: completed a house at (56, 33)313+ - generic [ref=e278]:314+ - text: "[t219]"315+ - generic [ref=e279]: warden316+ - text: completed a house at (55, 32)317+ - generic [ref=e281]:318+ - text: "[t221]"319+ - generic [ref=e282]: warden320+ - text: completed a house at (55, 33)321+ - generic [ref=e284]:322+ - text: "[t252]"323+ - generic [ref=e285]: raider324+ - text: completed a house at (33, 56)325+ - generic [ref=e287]:326+ - text: "[t263]"327+ - generic [ref=e288]: raider328+ - text: completed a house at (31, 57)329+ - generic [ref=e291]:330+ - text: "[t384]"331+ - generic [ref=e292]: raider332+ - text: completed a house at (32, 57)333+ - generic [ref=e294]:334+ - text: "[t394]"335+ - generic [ref=e295]: raider336+ - text: completed a house at (33, 57)337+ - generic [ref=e297]:338+ - text: "[t444]"339+ - generic [ref=e298]: warden340+ - text: sighted341+ - generic [ref=e300]: raider342+ - text: near (41, 36)343+ - generic [ref=e302]:344+ - text: "[t444]"345+ - generic [ref=e303]: raider346+ - text: sighted347+ - generic [ref=e305]: warden348+ - text: near (42, 30)349+ - generic [ref=e307]:350+ - text: "[t467]"351+ - generic [ref=e308]: warden352+ - text: lost a villager to353+ - generic [ref=e310]: raider354+ - text: at (42, 30)355+ - generic [ref=e312]:356+ - text: "[t468]"357+ - generic [ref=e313]: warden358+ - text: lost a villager to359+ - generic [ref=e315]: raider360+ - text: at (42, 30)361+ - generic [ref=e317]:362+ - text: "[t479]"363+ - generic [ref=e318]: warden364+ - text: lost a villager to365+ - generic [ref=e320]: raider366+ - text: at (45, 30)367+ - generic [ref=e322]:368+ - text: "[t480]"369+ - generic [ref=e323]: warden370+ - text: lost a villager to371+ - generic [ref=e325]: raider372+ - text: at (47, 30)373+ - generic [ref=e327]:374+ - text: "[t481]"375+ - generic [ref=e328]: warden376+ - text: lost a villager to377+ - generic [ref=e330]: raider378+ - text: at (47, 30)379+ - generic [ref=e332]:380+ - text: "[t485]"381+ - generic [ref=e333]: warden382+ - text: lost a villager to383+ - generic [ref=e335]: raider384+ - text: at (51, 30)385+ - generic [ref=e337]:386+ - text: "[t485]"387+ - generic [ref=e338]: warden388+ - text: lost a villager to389+ - generic [ref=e340]: raider390+ - text: at (52, 30)391+ - generic [ref=e342]:392+ - text: "[t486]"393+ - generic [ref=e343]: warden394+ - text: lost a villager to395+ - generic [ref=e345]: raider396+ - text: at (52, 30)397+ - generic [ref=e347]:398+ - text: "[t486]"399+ - generic [ref=e348]: raider400+ - text: lost a soldier to401+ - generic [ref=e350]: warden402+ - text: at (52, 30)403+ - generic [ref=e352]:404+ - text: "[t486]"405+ - generic [ref=e353]: warden406+ - text: lost a soldier to407+ - generic [ref=e355]: raider408+ - text: at (53, 30)409+ - generic [ref=e357]:410+ - text: "[t487]"411+ - generic [ref=e358]: warden412+ - text: lost a soldier to413+ - generic [ref=e360]: raider414+ - text: at (54, 31)415+ - generic [ref=e362]:416+ - text: "[t488]"417+ - generic [ref=e363]: warden418+ - text: lost a villager to419+ - generic [ref=e365]: raider420+ - text: at (55, 33)421+ - generic [ref=e367]:422+ - text: "[t488]"423+ - generic [ref=e368]: warden424+ - text: lost a villager to425+ - generic [ref=e370]: raider426+ - text: at (55, 31)427+ - generic [ref=e372]:428+ - text: "[t488]"429+ - generic [ref=e373]: warden430+ - text: "'s house destroyed by"431+ - generic [ref=e375]: raider432+ - text: at (55, 32)433+ - generic [ref=e377]:434+ - text: "[t488]"435+ - generic [ref=e378]: warden436+ - text: "'s house destroyed by"437+ - generic [ref=e380]: raider438+ - text: at (55, 33)439+ - generic [ref=e382]:440+ - text: "[t489]"441+ - generic [ref=e383]: warden442+ - text: lost a villager to443+ - generic [ref=e385]: raider444+ - text: at (55, 31)445+ - generic [ref=e387]:446+ - text: "[t489]"447+ - generic [ref=e388]: warden448+ - text: "'s granary destroyed by"449+ - generic [ref=e390]: raider450+ - text: at (55, 31)451+ - generic [ref=e392]:452+ - text: "[t490]"453+ - generic [ref=e393]: warden454+ - text: lost a villager to455+ - generic [ref=e395]: raider456+ - text: at (56, 32)457+ - generic [ref=e397]:458+ - text: "[t491]"459+ - generic [ref=e398]: warden460+ - text: lost a villager to461+ - generic [ref=e400]: raider462+ - text: at (55, 31)463+ - generic [ref=e402]:464+ - text: "[t491]"465+ - generic [ref=e403]: warden466+ - text: lost a villager to467+ - generic [ref=e405]: raider468+ - text: at (56, 32)469+ - generic [ref=e407]:470+ - text: "[t491]"471+ - generic [ref=e408]: warden472+ - text: "'s house destroyed by"473+ - generic [ref=e410]: raider474+ - text: at (56, 31)475+ - generic [ref=e412]:476+ - text: "[t491]"477+ - generic [ref=e413]: warden478+ - text: "'s house destroyed by"479+ - generic [ref=e415]: raider480+ - text: at (56, 33)481+ - generic [ref=e417]:482+ - text: "[t492]"483+ - generic [ref=e418]: warden484+ - text: lost a villager to485+ - generic [ref=e420]: raider486+ - text: at (56, 32)487+ - generic [ref=e422]:488+ - text: "[t493]"489+ - generic [ref=e423]: warden490+ - text: "'s town center destroyed by"491+ - generic [ref=e425]: raider492+ - text: at (56, 32)493+ - generic [ref=e427]:494+ - text: "[t494]"495+ - generic [ref=e428]: warden496+ - text: lost a villager to497+ - generic [ref=e430]: raider498+ - text: at (55, 34)499+ - generic [ref=e432]:500+ - text: "[t495]"501+ - generic [ref=e433]: warden502+ - text: "'s house destroyed by"503+ - generic [ref=e435]: raider504+ - text: at (57, 32)505+ - generic [ref=e437]:506+ - text: "[t495]"507+ - generic [ref=e438]: warden508+ - text: "'s barracks destroyed by"509+ - generic [ref=e440]: raider510+ - text: at (57, 31)511+ - generic [ref=e442]:512+ - text: "[t496]"513+ - generic [ref=e443]: warden514+ - text: lost a villager to515+ - generic [ref=e445]: raider516+ - text: at (58, 32)517+ - generic [ref=e447]:518+ - text: "[t504]"519+ - generic [ref=e448]: warden520+ - text: lost a villager to521+ - generic [ref=e450]: raider522+ - text: at (42, 30)523+ - generic [ref=e452]:524+ - text: "[t507]"525+ - generic [ref=e453]: warden526+ - text: lost a villager to527+ - generic [ref=e455]: raider528+ - text: at (42, 30)529+ - generic [ref=e457]:530+ - text: "[t507]"531+ - generic [ref=e458]: warden532+ - text: was eliminated533+ - contentinfo [ref=e290]: "Art: Ricardo Machado (beowulf.itch.io) · Icons: SSUGMI · Game assets are licensed for use in this game and are not redistributed. Sims & scripts expire after 14 days unless starred."\ No newline at end of file
A .playwright-cli/page-2026-08-15T23-47-08-915Z.png +0 -0

Binary file.

M worker/src/db.ts +24 -11
@@ -12,6 +12,7 @@
1212 */1313 import { LIMITS } from '@tribes/protocol';1414 import { nanoid, nowSec } from './util';15+import { friendlyId, isUniqueViolation } from './friendly-id';1516 1617 // ---------------------------------------------------------------------------1718 // Row types
@@ -83,17 +84,29 @@ export async function upsertUser(
8384 externalId: string,8485 name: string | null,8586 ): Promise<UserRow> {86- const row = await db87- .prepare(88- `INSERT INTO users (id, external_id, name, created_at)89- VALUES (?1, ?2, ?3, ?4)90- ON CONFLICT(external_id) DO UPDATE SET name = COALESCE(excluded.name, users.name)91- RETURNING *`,92- )93- .bind('usr_' + nanoid(), externalId, name, nowSec())94- .first<UserRow>();95- if (!row) throw new Error('user upsert returned no row');96- return row;87+ // Friendly id seeded by the external id; on the (rare) id collision with a88+ // DIFFERENT user, retry with the next attempt, then fall back to a nanoid.89+ for (let attempt = 0; ; attempt++) {90+ const id =91+ attempt > 8 ? 'usr_' + nanoid() : 'usr_' + friendlyId('user', externalId, attempt);92+ try {93+ const row = await db94+ .prepare(95+ `INSERT INTO users (id, external_id, name, created_at)96+ VALUES (?1, ?2, ?3, ?4)97+ ON CONFLICT(external_id) DO UPDATE SET name = COALESCE(excluded.name, users.name)98+ RETURNING *`,99+ )100+ .bind(id, externalId, name, nowSec())101+ .first<UserRow>();102+ if (!row) throw new Error('user upsert returned no row');103+ return row;104+ } catch (err) {105+ // ON CONFLICT only absorbs external_id conflicts; a PRIMARY KEY clash106+ // (same friendly id, different user) surfaces as a unique violation.107+ if (!isUniqueViolation(err) || attempt > 9) throw err;108+ }109+ }97110 }98111 99112 export function getUserById(db: D1Database, id: string): Promise<UserRow | null> {
A worker/src/friendly-id.ts +97 -0
@@ -0,0 +1,97 @@
1+/**2+ * Friendly ids: `adjective-noun` instead of raw random tails.3+ *4+ * One shared adjective pool; three DISJOINT noun pools (users=birds,5+ * scripts=beasts, sims=places) so a bare name always reveals its category6+ * (disjointness is enforced by a test). Each entity's natural seed feeds a7+ * deterministic pick: users ← external id, scripts ← source hash + owner,8+ * sims ← game seed + creator. Collisions are handled by the caller retrying9+ * with attempt+1, which appends a short numeric tag (`brave-otter-2`).10+ *11+ * NOTE: friendly ids are ENUMERABLE — they are names, not secrets. Anything12+ * addressable by one must be safe to expose or authz-checked (script sources13+ * are owner-only; sim bundles are public by design).14+ */15+16+export const ADJECTIVES = [17+ 'amber', 'ancient', 'ashen', 'bold', 'brave', 'bright', 'brisk', 'bronze',18+ 'calm', 'clever', 'copper', 'crimson', 'curious', 'dappled', 'dawn', 'dusky',19+ 'eager', 'early', 'ebon', 'elder', 'ember', 'fabled', 'fierce', 'flint',20+ 'frosty', 'gentle', 'gilded', 'glad', 'golden', 'granite', 'grim', 'hardy',21+ 'hidden', 'hollow', 'humble', 'iron', 'ivory', 'jade', 'keen', 'kindled',22+ 'late', 'lively', 'lone', 'lucky', 'lunar', 'merry', 'misty', 'mossy',23+ 'nimble', 'noble', 'northern', 'oaken', 'pale', 'patient', 'proud', 'quiet',24+ 'rapid', 'restless', 'rustic', 'sable', 'sage', 'salt', 'shrewd', 'silent',25+ 'silver', 'sly', 'solemn', 'stark', 'steady', 'stern', 'still', 'stone',26+ 'sunny', 'swift', 'tidal', 'true', 'umber', 'vivid', 'wandering', 'wary',27+ 'weathered', 'wild', 'winter', 'zealous',28+] as const;29+30+const USER_NOUNS = [31+ 'albatross', 'bittern', 'bunting', 'condor', 'cormorant', 'crane', 'crow',32+ 'cuckoo', 'curlew', 'dove', 'dunlin', 'eagle', 'egret', 'falcon', 'finch',33+ 'gannet', 'goshawk', 'grebe', 'grouse', 'gull', 'harrier', 'heron', 'hoopoe',34+ 'ibis', 'jackdaw', 'jay', 'kestrel', 'kite', 'lark', 'linnet', 'magpie',35+ 'merlin', 'nightjar', 'oriole', 'osprey', 'owl', 'petrel', 'pipit', 'plover',36+ 'puffin', 'quail', 'raven', 'robin', 'rook', 'sandpiper', 'shrike', 'siskin',37+ 'skylark', 'snipe', 'sparrow', 'starling', 'stork', 'swallow', 'swan',38+ 'swift-bird', 'tern', 'thrush', 'wagtail', 'warbler', 'wren',39+] as const;40+41+const SCRIPT_NOUNS = [42+ 'aurochs', 'badger', 'bear', 'bison', 'boar', 'bobcat', 'caribou', 'civet',43+ 'coyote', 'elk', 'ermine', 'ferret', 'fox', 'hare', 'hedgehog', 'ibex',44+ 'jackal', 'lemming', 'lynx', 'marmot', 'marten', 'mole', 'moose', 'mouflon',45+ 'muskox', 'otter', 'pangolin', 'pika', 'polecat', 'porcupine', 'ram',46+ 'reindeer', 'sable-beast', 'serval', 'shrew', 'stag', 'stoat', 'tapir',47+ 'vole', 'weasel', 'wisent', 'wolf', 'wolverine', 'yak', 'zorilla',48+] as const;49+50+const SIM_NOUNS = [51+ 'archipelago', 'basin', 'bay', 'bluff', 'bog', 'canyon', 'cape', 'cavern',52+ 'cove', 'crag', 'delta', 'dell', 'dune', 'fen', 'fjord', 'foothill', 'ford',53+ 'glade', 'glen', 'gorge', 'grotto', 'grove', 'heath', 'highland', 'hollow-vale',54+ 'isle', 'knoll', 'lagoon', 'lowland', 'marsh', 'mesa', 'moor', 'oasis',55+ 'outcrop', 'plateau', 'prairie', 'ravine', 'reef', 'ridge', 'rise', 'savanna',56+ 'shoal', 'steppe', 'summit', 'taiga', 'tarn', 'thicket', 'tundra', 'vale',57+ 'wold',58+] as const;59+60+export type FriendlyKind = 'user' | 'script' | 'sim';61+62+const NOUNS: Record<FriendlyKind, readonly string[]> = {63+ user: USER_NOUNS,64+ script: SCRIPT_NOUNS,65+ sim: SIM_NOUNS,66+};67+68+export const NOUN_POOLS = NOUNS;69+70+/** FNV-1a 32-bit over a string. */71+function fnv(text: string): number {72+ let h = 0x811c9dc5;73+ for (let i = 0; i < text.length; i++) {74+ h ^= text.charCodeAt(i);75+ h = Math.imul(h, 0x01000193);76+ }77+ return h >>> 0;78+}79+80+/**81+ * Deterministic `adjective-noun` for (kind, seed, attempt). attempt 0 gives82+ * the bare name; attempt > 0 mixes the hash and appends `-<attempt+1>` so83+ * retries stay readable (`brave-otter`, then `stern-wolf-2`, ...).84+ */85+export function friendlyId(kind: FriendlyKind, seed: string, attempt = 0): string {86+ const nouns = NOUNS[kind];87+ const h = fnv(`${kind}:${seed}:${attempt}`);88+ const adjective = ADJECTIVES[h % ADJECTIVES.length]!;89+ const noun = nouns[Math.floor(h / ADJECTIVES.length) % nouns.length]!;90+ const base = `${adjective}-${noun}`;91+ return attempt === 0 ? base : `${base}-${attempt + 1}`;92+}93+94+/** True when a D1/SQLite error is a UNIQUE-constraint violation. */95+export function isUniqueViolation(err: unknown): boolean {96+ return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);97+}
M worker/src/service.ts +40 -5
@@ -14,9 +14,12 @@
1414 * (expires_at IS NULL OR expires_at > now) OR starred by anyone. Reads of1515 * expired-but-unswept entities throw ServiceError('not_found') — never1616 * 'expired' — so clients see one uniform failure and existence isn't leaked.17- * - IDS ARE CAPABILITIES. Script/sim ids are unguessable nanoids; any authed18- * user who has an id may read (getLogic) or star it, and use scripts in19- * createSim. Listing remains owner/creator-scoped; delete is owner-only.17+ * - IDS ARE NAMES, NOT SECRETS. Friendly `adjective-noun` ids are enumerable,18+ * so authz gates access: script SOURCE reads (getLogic) and createSim are19+ * owner-only (bundles publish locked sources, so locking a stranger's20+ * script would leak it); sims/bundles are public by design (the fishbowl);21+ * starring any live id is allowed (it only pins TTLs). Listing remains22+ * owner/creator-scoped; delete is owner-only.2023 * - RESUBMITTING A NAME REVIVES IT. If (owner,name) exists but has expired and2124 * not yet been swept, a successful submit bumps the version and refreshes2225 * expires_at — the stable id is retained.
@@ -44,6 +47,7 @@ import type {
4447 import type { Env } from './env';4548 import * as db from './db';4649 import { isoOf, nanoid, nowSec, randomDecimalSeed, sha256Hex } from './util';50+import { friendlyId } from './friendly-id';4751 4852 const TTL_SECONDS = LIMITS.TTL_DAYS * 24 * 3600;4953
@@ -164,8 +168,18 @@ export function createService(env: Env, deps: ServiceDeps): TribesService {
164168 return { script, report };165169 }166170 167- const id = 'lg_' + nanoid();171+ // Friendly id seeded by content hash + owner; retry through collisions.172+ let id = '';168173 const expiresAt = now + TTL_SECONDS;174+ for (let attempt = 0; ; attempt++) {175+ id = 'lg_' + friendlyId('script', `${hash}:${user.userId}`, attempt);176+ const taken = await db.getScriptById(D, id, user.userId);177+ if (taken === null) break;178+ if (attempt >= 8) {179+ id = 'lg_' + nanoid();180+ break;181+ }182+ }169183 await D.batch([170184 db.stmtUpsertBlob(D, hash, input.source, now),171185 db.stmtInsertScript(D, {
@@ -201,6 +215,11 @@ export function createService(env: Env, deps: ServiceDeps): TribesService {
201215 async getLogic(user: AuthedUser, id: string, version?: number): Promise<ScriptWithSource> {202216 const now = nowSec();203217 const row = await requireLiveScript(id, user.userId, now);218+ // Friendly ids are enumerable — script SOURCE is owner-only. (not_found,219+ // not forbidden: don't confirm existence to non-owners.)220+ if (row.owner_id !== user.userId) {221+ throw new ServiceError('not_found', `script ${id} not found`);222+ }204223 let hash = row.hash;205224 let effectiveVersion = row.version;206225 if (version !== undefined && version !== row.version) {
@@ -271,6 +290,12 @@ export function createService(env: Env, deps: ServiceDeps): TribesService {
271290 for (let slot = 0; slot < input.scriptIds.length; slot++) {272291 const scriptId = input.scriptIds[slot]!;273292 const row = await requireLiveScript(scriptId, user.userId, now);293+ // Sim bundles expose locked sources publicly, and friendly ids are294+ // enumerable — so you may only lock scripts you own. (Fork an example295+ // or someone's shared source to use it.)296+ if (row.owner_id !== user.userId) {297+ throw new ServiceError('not_found', `script ${scriptId} not found`);298+ }274299 if (row.status !== 'validated') {275300 throw new ServiceError('bad_input', `script ${scriptId} is not validated`);276301 }
@@ -278,8 +303,18 @@ export function createService(env: Env, deps: ServiceDeps): TribesService {
278303 refs.push({ slot, scriptId: row.id, scriptName: row.name, version: row.version, hash: row.hash });279304 }280305 281- const id = 'sim_' + nanoid();282306 const seed = input.seed ?? randomDecimalSeed();307+ // Friendly id seeded by game seed + creator; retry through collisions.308+ let id = '';309+ for (let attempt = 0; ; attempt++) {310+ id = 'sim_' + friendlyId('sim', `${seed}:${user.userId}`, attempt);311+ const taken = await db.getSimById(D, id, user.userId);312+ if (taken === null) break;313+ if (attempt >= 8) {314+ id = 'sim_' + nanoid();315+ break;316+ }317+ }283318 const config = {284319 tribeCount: input.scriptIds.length,285320 mapSize: LIMITS.MAP_SIZE,