larp_model.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891
  1. const pull = require('../server/node_modules/pull-stream');
  2. const fs = require('fs');
  3. const path = require('path');
  4. const HOUSES_PATH = path.join(__dirname, '..', 'client', 'assets', 'larp', 'houses.json');
  5. let HOUSES = {};
  6. try { HOUSES = JSON.parse(fs.readFileSync(HOUSES_PATH, 'utf8')); } catch (_) { HOUSES = {}; }
  7. const HOUSE_KEYS = ['academia','solaris','arrakis','terraverde','unsystem','dogma','helix','quark','hermandad'];
  8. const VALID_KEY = (k) => HOUSE_KEYS.includes(String(k || '').toLowerCase());
  9. const TEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
  10. const PROFILE_QUESTIONS = [
  11. {
  12. k: "larpProfileQ1",
  13. q: "When you face a complex problem, what do you do first?",
  14. options: [
  15. { k: "larpProfileQ1O1", t: "Talk to others to find consensus", w: { solaris: 3, hermandad: 1 } },
  16. { k: "larpProfileQ1O2", t: "Build a prototype to test", w: { arrakis: 3, hermandad: 1 } },
  17. { k: "larpProfileQ1O3", t: "Research the literature", w: { dogma: 3, terraverde: 1 } },
  18. { k: "larpProfileQ1O4", t: "Disrupt the system that creates it", w: { unsystem: 3, quark: 1 } }
  19. ]
  20. },
  21. {
  22. k: "larpProfileQ2",
  23. q: "What gives meaning to your daily work?",
  24. options: [
  25. { k: "larpProfileQ2O1", t: "Defending the people I love", w: { quark: 3, hermandad: 1 } },
  26. { k: "larpProfileQ2O2", t: "Creating something tangible", w: { arrakis: 2, hermandad: 2 } },
  27. { k: "larpProfileQ2O3", t: "Healing or nurturing life", w: { terraverde: 3, helix: 1 } },
  28. { k: "larpProfileQ2O4", t: "Crafting words and ideas", w: { dogma: 2, solaris: 2 } },
  29. { k: "larpProfileQ2O5", t: "Making others laugh", w: { helix: 3, unsystem: 1 } }
  30. ]
  31. },
  32. {
  33. k: "larpProfileQ3",
  34. q: "When conflict arises in your group, you…",
  35. options: [
  36. { k: "larpProfileQ3O1", t: "Lead the dialogue", w: { solaris: 3 } },
  37. { k: "larpProfileQ3O2", t: "Take a side and stand firm", w: { quark: 2, dogma: 1 } },
  38. { k: "larpProfileQ3O3", t: "Crack a joke to defuse", w: { helix: 3, unsystem: 1 } },
  39. { k: "larpProfileQ3O4", t: "Question whether the conflict is real",w: { unsystem: 3, dogma: 1 } },
  40. { k: "larpProfileQ3O5", t: "Look for root ecological causes", w: { terraverde: 2, dogma: 1 } }
  41. ]
  42. },
  43. {
  44. k: "larpProfileQ4",
  45. q: "Your favorite long-term project would be…",
  46. options: [
  47. { k: "larpProfileQ4O1", t: "Building a city", w: { hermandad: 3, arrakis: 1 } },
  48. { k: "larpProfileQ4O2", t: "Restoring a forest", w: { terraverde: 3, helix: 1 } },
  49. { k: "larpProfileQ4O3", t: "Writing a constitution", w: { solaris: 2, dogma: 2 } },
  50. { k: "larpProfileQ4O4", t: "Organising a festival", w: { helix: 3, hermandad: 1 } },
  51. { k: "larpProfileQ4O5", t: "Setting up a defense network", w: { quark: 3, hermandad: 1 } },
  52. { k: "larpProfileQ4O6", t: "Designing a new machine", w: { arrakis: 3, quark: 1 } },
  53. { k: "larpProfileQ4O7", t: "Curating an archive", w: { dogma: 3, solaris: 1 } },
  54. { k: "larpProfileQ4O8", t: "Disrupting an unjust order", w: { unsystem: 3, quark: 1 } }
  55. ]
  56. },
  57. {
  58. k: "larpProfileQ5",
  59. q: "What makes a good leader?",
  60. options: [
  61. { k: "larpProfileQ5O1", t: "Someone who listens and mediates", w: { solaris: 3, hermandad: 1 } },
  62. { k: "larpProfileQ5O2", t: "Someone who can fight and protect", w: { quark: 3, unsystem: 1 } },
  63. { k: "larpProfileQ5O3", t: "Someone who knows history", w: { dogma: 3, terraverde: 1 } },
  64. { k: "larpProfileQ5O4", t: "Someone who makes you smile", w: { helix: 3, hermandad: 1 } }
  65. ]
  66. },
  67. {
  68. k: "larpProfileQ6",
  69. q: "Your relationship with rules:",
  70. options: [
  71. { k: "larpProfileQ6O1", t: "Rules emerge from dialogue and law", w: { solaris: 3 } },
  72. { k: "larpProfileQ6O2", t: "Rules should be followed strictly", w: { dogma: 2, quark: 2 } },
  73. { k: "larpProfileQ6O3", t: "Rules should be broken often", w: { unsystem: 3, helix: 1 } },
  74. { k: "larpProfileQ6O4", t: "Rules should serve life", w: { terraverde: 3, helix: 1 } },
  75. { k: "larpProfileQ6O5", t: "Rules build solid infrastructure", w: { hermandad: 2, arrakis: 2 } }
  76. ]
  77. },
  78. {
  79. k: "larpProfileQ7",
  80. q: "How do you handle information?",
  81. options: [
  82. { k: "larpProfileQ7O1", t: "I curate and preserve it", w: { dogma: 3, hermandad: 1 } },
  83. { k: "larpProfileQ7O2", t: "I share it through stories", w: { helix: 2, dogma: 2 } },
  84. { k: "larpProfileQ7O3", t: "I question its origins", w: { unsystem: 3, dogma: 1 } },
  85. { k: "larpProfileQ7O4", t: "I extract the useful bits", w: { arrakis: 2, quark: 2 } },
  86. { k: "larpProfileQ7O5", t: "I use it to heal", w: { terraverde: 3 } }
  87. ]
  88. },
  89. {
  90. k: "larpProfileQ8",
  91. q: "Your idea of success is…",
  92. options: [
  93. { k: "larpProfileQ8O1", t: "A working machine", w: { arrakis: 3, hermandad: 1 } },
  94. { k: "larpProfileQ8O2", t: "A peaceful community", w: { solaris: 2, terraverde: 2 } },
  95. { k: "larpProfileQ8O3", t: "A lively festival", w: { helix: 3, hermandad: 1 } },
  96. { k: "larpProfileQ8O4", t: "A safe family", w: { quark: 3, terraverde: 1 } },
  97. { k: "larpProfileQ8O5", t: "An unbroken archive", w: { dogma: 3, hermandad: 1 } },
  98. { k: "larpProfileQ8O6", t: "A cracked dogma", w: { unsystem: 3, dogma: 1 } },
  99. { k: "larpProfileQ8O7", t: "A thriving harvest", w: { terraverde: 3, hermandad: 1 } },
  100. { k: "larpProfileQ8O8", t: "A finished building", w: { hermandad: 3, arrakis: 1 } }
  101. ]
  102. },
  103. {
  104. k: "larpProfileQ9",
  105. q: "When you wake up, you want to…",
  106. options: [
  107. { k: "larpProfileQ9O1", t: "Train your body", w: { quark: 3, helix: 1 } },
  108. { k: "larpProfileQ9O2", t: "Read or write", w: { dogma: 3, solaris: 1 } },
  109. { k: "larpProfileQ9O3", t: "Garden or cook", w: { terraverde: 3, helix: 1 } },
  110. { k: "larpProfileQ9O4", t: "Tinker with something", w: { arrakis: 3, hermandad: 1 } },
  111. { k: "larpProfileQ9O5", t: "Question authority", w: { unsystem: 3, dogma: 1 } },
  112. { k: "larpProfileQ9O6", t: "Plan a project", w: { hermandad: 3, solaris: 1 } },
  113. { k: "larpProfileQ9O7", t: "Talk to friends", w: { solaris: 2, helix: 2 } },
  114. { k: "larpProfileQ9O8", t: "Make art", w: { helix: 3, unsystem: 1 } }
  115. ]
  116. },
  117. {
  118. k: "larpProfileQ10",
  119. q: "Your weakness might be:",
  120. options: [
  121. { k: "larpProfileQ10O1", t: "Talking too much", w: { solaris: 3, dogma: 1 } },
  122. { k: "larpProfileQ10O2", t: "Being too pragmatic", w: { arrakis: 3, hermandad: 1 } },
  123. { k: "larpProfileQ10O3", t: "Being too idealistic", w: { terraverde: 3, helix: 1 } },
  124. { k: "larpProfileQ10O4", t: "Being too disruptive", w: { unsystem: 3, quark: 1 } },
  125. { k: "larpProfileQ10O5", t: "Being too rigid", w: { dogma: 3, quark: 1 } },
  126. { k: "larpProfileQ10O6", t: "Being too lighthearted", w: { helix: 3, unsystem: 1 } },
  127. { k: "larpProfileQ10O7", t: "Being too cautious", w: { quark: 3, hermandad: 1 } },
  128. { k: "larpProfileQ10O8", t: "Being too ambitious", w: { hermandad: 3, arrakis: 1 } }
  129. ]
  130. }
  131. ];
  132. const TEST_QUESTIONS_COUNT = PROFILE_QUESTIONS.length;
  133. const SOLAR_AGE_OFFSET = 10000000 - 2026;
  134. function computeCycle(now = new Date()) {
  135. const year = now.getFullYear();
  136. const monthIdx = now.getMonth();
  137. const start = new Date(year, 0, 1);
  138. const dayOfYear = Math.floor((now - start) / 86400000) + 1;
  139. const summerSolstice = new Date(year, 5, 21);
  140. const winterSolstice = new Date(year, 11, 21);
  141. const solsticeNum = now < summerSolstice ? 1 : (now < winterSolstice ? 2 : 1);
  142. const houseKey = HOUSE_KEYS[monthIdx % HOUSE_KEYS.length];
  143. const house = HOUSES[houseKey] || { short: houseKey.slice(0, 3), name: houseKey };
  144. const solarAge = year + SOLAR_AGE_OFFSET;
  145. const houseCycle = Math.floor(year - 2026 + 1);
  146. return {
  147. day: dayOfYear,
  148. solstice: solsticeNum,
  149. age: solarAge,
  150. houseKey,
  151. houseShort: house.short || houseKey.slice(0, 3),
  152. cycle: houseCycle,
  153. formatted: `${dayOfYear}.${solsticeNum}.${solarAge}.${house.short || houseKey.slice(0, 3)}.${houseCycle}`
  154. };
  155. }
  156. function getGoverningHouseKey(now = new Date()) {
  157. return HOUSE_KEYS[now.getMonth() % HOUSE_KEYS.length];
  158. }
  159. module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
  160. let ssb;
  161. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
  162. const houseTribeTag = (houseKey) => {
  163. const h = HOUSES[houseKey];
  164. return `larp-${h && h.name ? h.name : houseKey}`;
  165. };
  166. async function findMyHouseTribe(houseKey) {
  167. if (!tribesModel || !VALID_KEY(houseKey)) return null;
  168. const client = await openSsb();
  169. const me = client.id;
  170. let list = [];
  171. try { list = await tribesModel.listAll(); } catch (_) { return null; }
  172. const tag = houseTribeTag(houseKey);
  173. const candidates = list.filter(t => {
  174. const tags = Array.isArray(t.tags) ? t.tags : [];
  175. const members = Array.isArray(t.members) ? t.members : [];
  176. return tags.includes(tag) && members.includes(me);
  177. });
  178. if (!candidates.length) return null;
  179. candidates.sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime());
  180. return candidates[0];
  181. }
  182. async function findEarliestHouseAnchor(houseKey) {
  183. if (!VALID_KEY(houseKey)) return null;
  184. const client = await openSsb();
  185. return new Promise((resolve) => {
  186. const anchors = [];
  187. const tombstones = [];
  188. pull(
  189. client.createLogStream(),
  190. pull.drain((m) => {
  191. const c = m && m.value && m.value.content;
  192. if (!c) return;
  193. if (c.type === 'larpHouseTribeAnchor') {
  194. if (c.house !== houseKey) return;
  195. if (typeof c.tribeRootId !== 'string') return;
  196. const tribeTs = Number(Date.parse(c.tribeCreatedAt || '')) || m.value.timestamp || 0;
  197. anchors.push({ tribeRootId: c.tribeRootId, anchorAuthor: m.value.author, tribeTs });
  198. } else if (c.type === 'larpHouseTribeAnchorTombstone') {
  199. if (c.house !== houseKey) return;
  200. if (typeof c.tribeRootId !== 'string') return;
  201. tombstones.push({ tribeRootId: c.tribeRootId, tombstoneAuthor: m.value.author });
  202. }
  203. }, () => {
  204. const validKills = new Set();
  205. for (const t of tombstones) {
  206. const a = anchors.find(x => x.tribeRootId === t.tribeRootId);
  207. if (a && a.anchorAuthor === t.tombstoneAuthor) validKills.add(t.tribeRootId);
  208. }
  209. const live = anchors.filter(a => !validKills.has(a.tribeRootId));
  210. if (!live.length) return resolve(null);
  211. live.sort((a, b) => a.tribeTs - b.tribeTs);
  212. const first = live[0];
  213. resolve({ tribeRootId: first.tribeRootId, author: first.anchorAuthor, tribeTs: first.tribeTs });
  214. })
  215. );
  216. });
  217. }
  218. async function findHouseAnchorByTribe(houseKey, tribeRootId) {
  219. if (!VALID_KEY(houseKey) || !tribeRootId) return null;
  220. const client = await openSsb();
  221. return new Promise((resolve) => {
  222. let hit = null;
  223. pull(
  224. client.createLogStream(),
  225. pull.drain((m) => {
  226. if (hit) return;
  227. const c = m && m.value && m.value.content;
  228. if (!c || c.type !== 'larpHouseTribeAnchor') return;
  229. if (c.house !== houseKey) return;
  230. if (c.tribeRootId !== tribeRootId) return;
  231. hit = { author: m.value.author, ts: m.value.timestamp || 0 };
  232. }, () => resolve(hit))
  233. );
  234. });
  235. }
  236. async function publishHouseTribeAnchor(houseKey, tribeRootId, tribeCreatedAt) {
  237. if (!VALID_KEY(houseKey) || !tribeRootId) return null;
  238. const client = await openSsb();
  239. return new Promise((resolve) => {
  240. client.publish({
  241. type: 'larpHouseTribeAnchor',
  242. house: houseKey,
  243. tribeRootId,
  244. tribeCreatedAt: tribeCreatedAt || new Date().toISOString(),
  245. anchoredAt: new Date().toISOString()
  246. }, (err, msg) => resolve(err ? null : msg));
  247. });
  248. }
  249. async function listMyHouseTribes(houseKey) {
  250. if (!tribesModel || !VALID_KEY(houseKey)) return [];
  251. const client = await openSsb();
  252. const me = client.id;
  253. let list = [];
  254. try { list = await tribesModel.listAll(); } catch (_) { return []; }
  255. const tag = houseTribeTag(houseKey);
  256. const out = [];
  257. for (const t of list) {
  258. const tags = Array.isArray(t.tags) ? t.tags : [];
  259. const members = Array.isArray(t.members) ? t.members : [];
  260. if (!tags.includes(tag) || !members.includes(me)) continue;
  261. const rootId = await tribesModel.getRootId(t.id).catch(() => t.id);
  262. const createdAtTs = Number(Date.parse(t.createdAt || '')) || 0;
  263. out.push({ tribe: t, rootId, createdAtTs });
  264. }
  265. return out;
  266. }
  267. async function tombstoneMyTribe(houseKey, rootId, tribeId) {
  268. try { await tribesModel.publishTombstone(tribeId); } catch (_) {}
  269. if (houseKey !== 'academia') {
  270. await publishHouseAnchorTombstone(houseKey, rootId).catch(() => {});
  271. }
  272. if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') {
  273. try { tribeCrypto.dropKey(rootId); } catch (_) {}
  274. }
  275. }
  276. async function ensureHouseTribe(houseKey) {
  277. if (!tribesModel || !VALID_KEY(houseKey)) return null;
  278. const client = await openSsb();
  279. const me = client.id;
  280. if (houseKey === 'academia') {
  281. const existing = await findMyHouseTribe(houseKey);
  282. if (existing) return existing;
  283. const house = HOUSES[houseKey] || {};
  284. const tag = houseTribeTag(houseKey);
  285. try {
  286. await tribesModel.createTribe(house.name || houseKey, house.description || '', house.image || null, '', [tag], false, 'open', null, 'PUBLIC', '');
  287. } catch (_) {}
  288. return await findMyHouseTribe(houseKey);
  289. }
  290. const anchor = await findEarliestHouseAnchor(houseKey).catch(() => null);
  291. const myTribes = await listMyHouseTribes(houseKey);
  292. myTribes.sort((a, b) => a.createdAtTs - b.createdAtTs);
  293. let myCanonical = null;
  294. if (anchor) {
  295. myCanonical = myTribes.find(x => x.rootId === anchor.tribeRootId) || null;
  296. }
  297. if (!myCanonical && myTribes.length > 0) {
  298. const myOldest = myTribes[0];
  299. const myOldestTs = myOldest.createdAtTs;
  300. if (!anchor) {
  301. await publishHouseTribeAnchor(houseKey, myOldest.rootId, myOldest.tribe.createdAt).catch(() => {});
  302. myCanonical = myOldest;
  303. } else if (myOldestTs > 0 && anchor.tribeTs > 0 && myOldestTs < anchor.tribeTs) {
  304. const existingAnchor = await findHouseAnchorByTribe(houseKey, myOldest.rootId);
  305. if (!existingAnchor) {
  306. await publishHouseTribeAnchor(houseKey, myOldest.rootId, myOldest.tribe.createdAt).catch(() => {});
  307. }
  308. myCanonical = myOldest;
  309. }
  310. }
  311. for (const t of myTribes) {
  312. if (myCanonical && t.rootId === myCanonical.rootId) continue;
  313. if (t.tribe.author !== me) continue;
  314. await tombstoneMyTribe(houseKey, t.rootId, t.tribe.id);
  315. }
  316. if (myCanonical) {
  317. const existingAnchorForMine = await findHouseAnchorByTribe(houseKey, myCanonical.rootId);
  318. if (!existingAnchorForMine) {
  319. await publishHouseTribeAnchor(houseKey, myCanonical.rootId, myCanonical.tribe.createdAt).catch(() => {});
  320. }
  321. return myCanonical.tribe;
  322. }
  323. if (anchor) return null;
  324. const house = HOUSES[houseKey] || {};
  325. const tag = houseTribeTag(houseKey);
  326. try {
  327. await tribesModel.createTribe(house.name || houseKey, house.description || '', house.image || null, '', [tag], true, 'open', null, 'PRIVATE', '');
  328. } catch (_) {}
  329. const created = await findMyHouseTribe(houseKey);
  330. if (created) {
  331. const rootId = await tribesModel.getRootId(created.id).catch(() => created.id);
  332. await publishHouseTribeAnchor(houseKey, rootId, created.createdAt).catch(() => {});
  333. }
  334. return created;
  335. }
  336. async function publishHouseAnchorTombstone(houseKey, tribeRootId) {
  337. if (!VALID_KEY(houseKey) || !tribeRootId) return null;
  338. const client = await openSsb();
  339. return new Promise((resolve) => {
  340. client.publish({
  341. type: 'larpHouseTribeAnchorTombstone',
  342. house: houseKey,
  343. tribeRootId,
  344. tombstonedAt: new Date().toISOString()
  345. }, (err, msg) => resolve(err ? null : msg));
  346. });
  347. }
  348. async function leaveMyHouseTribe(houseKey) {
  349. if (!tribesModel) return;
  350. const client = await openSsb();
  351. const me = client.id;
  352. const myTribes = await listMyHouseTribes(houseKey);
  353. for (const t of myTribes) {
  354. const isSoloAuthor = t.tribe.author === me && Array.isArray(t.tribe.members) && t.tribe.members.length === 1 && t.tribe.members[0] === me;
  355. try { await tribesModel.leaveTribe(t.tribe.id, { force: true }); } catch (_) {}
  356. if (isSoloAuthor) {
  357. if (houseKey !== 'academia') {
  358. await publishHouseAnchorTombstone(houseKey, t.rootId).catch(() => {});
  359. }
  360. if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') {
  361. try { tribeCrypto.dropKey(t.rootId); } catch (_) {}
  362. }
  363. }
  364. }
  365. }
  366. async function publishJoin(houseKey) {
  367. if (!VALID_KEY(houseKey)) throw new Error('Invalid house key');
  368. const client = await openSsb();
  369. let previousHouse = null;
  370. try { previousHouse = await getUserHouse(client.id); } catch (_) {}
  371. await new Promise((resolve, reject) => {
  372. client.publish({
  373. type: 'larpJoinHouse',
  374. house: houseKey,
  375. joinedAt: new Date().toISOString()
  376. }, (err, msg) => err ? reject(err) : resolve(msg));
  377. });
  378. if (previousHouse && previousHouse !== houseKey) {
  379. await leaveMyHouseTribe(previousHouse).catch(() => {});
  380. }
  381. await ensureHouseTribe(houseKey).catch(() => {});
  382. await redeemPendingAutoInvites().catch(() => {});
  383. await issueAutoInvitesForMyHouse().catch(() => {});
  384. }
  385. async function getUserHouse(feedId) {
  386. const client = await openSsb();
  387. const target = feedId || client.id;
  388. return new Promise((resolve) => {
  389. let latest = null;
  390. let latestTs = 0;
  391. pull(
  392. client.createUserStream({ id: target, reverse: true }),
  393. pull.drain((m) => {
  394. const c = m && m.value && m.value.content;
  395. if (!c) return;
  396. const ts = m.value.timestamp || 0;
  397. if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
  398. if (ts > latestTs) { latestTs = ts; latest = c.house; }
  399. } else if (c.type === 'larpLeaveLarp') {
  400. if (ts > latestTs) { latestTs = ts; latest = null; }
  401. }
  402. }, () => resolve(latest))
  403. );
  404. });
  405. }
  406. async function listAllMemberships() {
  407. const client = await openSsb();
  408. return new Promise((resolve) => {
  409. const byAuthor = new Map();
  410. pull(
  411. client.createLogStream({ reverse: true }),
  412. pull.drain((m) => {
  413. const author = m && m.value && m.value.author;
  414. if (!author) return;
  415. const c = m.value.content;
  416. if (!c) return;
  417. const ts = m.value.timestamp || 0;
  418. if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
  419. const prev = byAuthor.get(author);
  420. if (!prev || ts > prev.ts) byAuthor.set(author, { house: c.house, ts });
  421. } else if (c.type === 'larpLeaveLarp') {
  422. const prev = byAuthor.get(author);
  423. if (!prev || ts > prev.ts) byAuthor.set(author, { house: null, ts });
  424. }
  425. }, () => {
  426. const result = new Map();
  427. for (const [a, v] of byAuthor.entries()) {
  428. if (v.house) result.set(a, v.house);
  429. }
  430. resolve(result);
  431. })
  432. );
  433. });
  434. }
  435. async function publishLeaveLarp() {
  436. const client = await openSsb();
  437. let previousHouse = null;
  438. try { previousHouse = await getUserHouse(client.id); } catch (_) {}
  439. await new Promise((resolve, reject) => {
  440. client.publish({
  441. type: 'larpLeaveLarp',
  442. leftAt: new Date().toISOString()
  443. }, (err, msg) => err ? reject(err) : resolve(msg));
  444. });
  445. if (previousHouse) {
  446. await leaveMyHouseTribe(previousHouse).catch(() => {});
  447. }
  448. }
  449. async function listHousesWithCounts() {
  450. const memberships = await listAllMemberships();
  451. const counts = Object.fromEntries(HOUSE_KEYS.map(k => [k, 0]));
  452. for (const house of memberships.values()) {
  453. if (counts[house] !== undefined) counts[house] += 1;
  454. }
  455. return HOUSE_KEYS.map(key => ({
  456. key,
  457. ...HOUSES[key],
  458. memberCount: counts[key] || 0
  459. }));
  460. }
  461. async function getMembersOfHouse(houseKey) {
  462. if (!VALID_KEY(houseKey)) return [];
  463. const memberships = await listAllMemberships();
  464. const out = [];
  465. for (const [author, house] of memberships.entries()) {
  466. if (house === houseKey) out.push(author);
  467. }
  468. return out;
  469. }
  470. async function publishHousePost({ house, text }) {
  471. if (!VALID_KEY(house)) throw new Error('Invalid house key');
  472. const client = await openSsb();
  473. const clean = String(text || '').trim().slice(0, 4000);
  474. if (!clean) throw new Error('Empty post');
  475. return new Promise((resolve, reject) => {
  476. client.publish({
  477. type: 'larpHousePost',
  478. house,
  479. text: clean,
  480. createdAt: new Date().toISOString()
  481. }, (err, msg) => err ? reject(err) : resolve(msg));
  482. });
  483. }
  484. async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) {
  485. if (!VALID_KEY(houseKey)) return [];
  486. const viewerIsMember = viewerHouse === houseKey;
  487. if (!viewerIsMember && !isGoverning) return [];
  488. const client = await openSsb();
  489. const memberships = await listAllMemberships();
  490. return new Promise((resolve) => {
  491. const posts = [];
  492. pull(
  493. client.createLogStream({ reverse: true }),
  494. pull.drain((m) => {
  495. const c = m && m.value && m.value.content;
  496. if (!c || c.type !== 'larpHousePost') return;
  497. if (c.house !== houseKey) return;
  498. const author = m.value.author;
  499. const memberHouse = memberships.get(author) || 'academia';
  500. if (memberHouse !== houseKey) return;
  501. posts.push({
  502. id: m.key,
  503. author,
  504. text: String(c.text || ''),
  505. createdAt: c.createdAt || new Date(m.value.timestamp || 0).toISOString(),
  506. ts: m.value.timestamp || 0
  507. });
  508. }, () => {
  509. posts.sort((a, b) => b.ts - a.ts);
  510. resolve(posts);
  511. })
  512. );
  513. });
  514. }
  515. async function getLastTestAttempt(feedId) {
  516. const client = await openSsb();
  517. const target = feedId || client.id;
  518. return new Promise((resolve) => {
  519. let latest = null;
  520. pull(
  521. client.createUserStream({ id: target, reverse: true }),
  522. pull.drain((m) => {
  523. const c = m && m.value && m.value.content;
  524. if (!c || c.type !== 'larpTestAttempt') return;
  525. if (!VALID_KEY(c.house)) return;
  526. const ts = m.value.timestamp || 0;
  527. if (!latest || ts > latest.ts) latest = { house: c.house, ts, passed: c.passed === true, score: c.score || 0 };
  528. }, () => resolve(latest))
  529. );
  530. });
  531. }
  532. async function canTakeTest(feedId) {
  533. const last = await getLastTestAttempt(feedId);
  534. if (!last) return { allowed: true, nextAt: 0, last: null };
  535. const elapsed = Date.now() - last.ts;
  536. if (elapsed >= TEST_COOLDOWN_MS) return { allowed: true, nextAt: 0, last };
  537. return { allowed: false, nextAt: last.ts + TEST_COOLDOWN_MS, last };
  538. }
  539. function getProfileTest() {
  540. return PROFILE_QUESTIONS.map(q => ({
  541. key: q.k,
  542. question: q.q,
  543. options: q.options.map(o => ({ key: o.k, text: o.t }))
  544. }));
  545. }
  546. function scoreProfileAnswers(answers, memberCounts = {}) {
  547. const scores = Object.fromEntries(HOUSE_KEYS.filter(k => k !== 'academia').map(k => [k, 0]));
  548. PROFILE_QUESTIONS.forEach((q, i) => {
  549. const choice = Number(answers && answers[i]);
  550. if (!Number.isInteger(choice) || choice < 0 || choice >= q.options.length) return;
  551. const weights = q.options[choice].w || {};
  552. for (const [house, weight] of Object.entries(weights)) {
  553. if (scores[house] === undefined) continue;
  554. scores[house] += Number(weight) || 0;
  555. }
  556. });
  557. const ranking = Object.entries(scores).sort((a, b) => {
  558. if (b[1] !== a[1]) return b[1] - a[1];
  559. const ma = memberCounts[a[0]] || 0;
  560. const mb = memberCounts[b[0]] || 0;
  561. if (ma !== mb) return ma - mb;
  562. return a[0].localeCompare(b[0]);
  563. });
  564. const bestHouse = ranking[0] ? ranking[0][0] : null;
  565. const bestScore = ranking[0] ? ranking[0][1] : 0;
  566. return { scores, ranking, bestHouse, bestScore };
  567. }
  568. async function submitProfileTest({ answers }) {
  569. const client = await openSsb();
  570. const can = await canTakeTest(client.id);
  571. if (!can.allowed) return { ok: false, reason: 'cooldown', nextAt: can.nextAt };
  572. const housesWithCounts = await listHousesWithCounts();
  573. const memberCounts = Object.fromEntries(housesWithCounts.map(h => [h.key, h.memberCount || 0]));
  574. const { scores, ranking, bestHouse, bestScore } = scoreProfileAnswers(answers, memberCounts);
  575. const target = bestHouse || 'academia';
  576. await new Promise((resolve, reject) => {
  577. client.publish({
  578. type: 'larpTestAttempt',
  579. house: target,
  580. passed: true,
  581. attemptedAt: new Date().toISOString()
  582. }, (err) => err ? reject(err) : resolve());
  583. });
  584. await publishJoin(target);
  585. return { ok: true, passed: true, house: target, score: bestScore, scores, ranking };
  586. }
  587. async function issueAutoInvitesForMyHouse() {
  588. if (!tribesModel) return;
  589. const client = await openSsb();
  590. const me = client.id;
  591. const myHouse = await getUserHouse(me).catch(() => null);
  592. if (!VALID_KEY(myHouse) || myHouse === 'academia') return;
  593. const anchor = await findEarliestHouseAnchor(myHouse).catch(() => null);
  594. if (!anchor) return;
  595. let canonicalTribe;
  596. try { canonicalTribe = await tribesModel.getTribeById(anchor.tribeRootId); } catch (_) { return; }
  597. if (!canonicalTribe) return;
  598. const tribeMembers = Array.isArray(canonicalTribe.members) ? canonicalTribe.members : [];
  599. if (!tribeMembers.includes(me)) return;
  600. const houseMembers = await getMembersOfHouse(myHouse).catch(() => []);
  601. const missing = houseMembers.filter(id => id && id !== me && !tribeMembers.includes(id));
  602. if (!missing.length) return;
  603. const sent = await listMyAutoInviteRecipients(myHouse, anchor.tribeRootId).catch(() => new Set());
  604. for (const newMember of missing) {
  605. if (sent.has(newMember)) continue;
  606. try {
  607. const code = await tribesModel.generateInvite(canonicalTribe.id);
  608. await new Promise((resolve) => {
  609. client.publish({
  610. type: 'larpAutoInvite',
  611. house: myHouse,
  612. tribeRootId: anchor.tribeRootId,
  613. to: newMember,
  614. code,
  615. sentAt: new Date().toISOString(),
  616. recps: [newMember, me]
  617. }, () => resolve());
  618. });
  619. } catch (_) {}
  620. }
  621. }
  622. async function listMyAutoInviteRecipients(houseKey, tribeRootId) {
  623. const client = await openSsb();
  624. const me = client.id;
  625. const ssbKeys = require('../server/node_modules/ssb-keys');
  626. const config = require('../server/ssb_config');
  627. return new Promise((resolve) => {
  628. const out = new Set();
  629. pull(
  630. client.createUserStream({ id: me }),
  631. pull.drain((m) => {
  632. const c = m && m.value && m.value.content;
  633. if (typeof c !== 'string' || !c.endsWith('.box')) return;
  634. let decoded;
  635. try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
  636. if (!decoded) return;
  637. if (typeof decoded === 'string') {
  638. try { decoded = JSON.parse(decoded); } catch (_) { return; }
  639. }
  640. if (!decoded || decoded.type !== 'larpAutoInvite') return;
  641. if (decoded.house !== houseKey) return;
  642. if (decoded.tribeRootId !== tribeRootId) return;
  643. if (typeof decoded.to === 'string' && decoded.to !== me) out.add(decoded.to);
  644. }, () => resolve(out))
  645. );
  646. });
  647. }
  648. async function alreadyInCanonical(houseKey) {
  649. if (!VALID_KEY(houseKey)) return false;
  650. const anchor = await findEarliestHouseAnchor(houseKey).catch(() => null);
  651. if (!anchor) return false;
  652. try {
  653. const canonical = await tribesModel.getTribeById(anchor.tribeRootId);
  654. const client = await openSsb();
  655. return !!(canonical && Array.isArray(canonical.members) && canonical.members.includes(client.id));
  656. } catch (_) { return false; }
  657. }
  658. async function redeemPendingAutoInvites() {
  659. if (!tribesModel) return;
  660. const client = await openSsb();
  661. const me = client.id;
  662. const myHouse = await getUserHouse(me).catch(() => null);
  663. if (!VALID_KEY(myHouse) || myHouse === 'academia') return;
  664. if (await alreadyInCanonical(myHouse)) return;
  665. const ssbKeys = require('../server/node_modules/ssb-keys');
  666. const config = require('../server/ssb_config');
  667. const codes = [];
  668. await new Promise((resolve) => {
  669. pull(
  670. client.createLogStream({ reverse: true, limit: 2000 }),
  671. pull.drain((m) => {
  672. const c = m && m.value && m.value.content;
  673. if (typeof c !== 'string' || !c.endsWith('.box')) return;
  674. let decoded;
  675. try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
  676. if (!decoded) return;
  677. if (typeof decoded === 'string') {
  678. try { decoded = JSON.parse(decoded); } catch (_) { return; }
  679. }
  680. if (!decoded || decoded.type !== 'larpAutoInvite') return;
  681. if (!VALID_KEY(decoded.house) || typeof decoded.code !== 'string') return;
  682. if (decoded.house !== myHouse) return;
  683. if (m.value.author === me) return;
  684. codes.push(decoded.code);
  685. }, () => resolve())
  686. );
  687. });
  688. for (const code of codes) {
  689. try { await tribesModel.joinByInvite(code); } catch (_) {}
  690. }
  691. }
  692. let liveSubscriberStarted = false;
  693. let initRan = false;
  694. let processingChain = Promise.resolve();
  695. function enqueue(fn) {
  696. processingChain = processingChain.then(fn).catch(() => {});
  697. return processingChain;
  698. }
  699. async function runCatchup() {
  700. try {
  701. const client = await openSsb();
  702. const me = client.id;
  703. const myHouse = await getUserHouse(me).catch(() => null);
  704. if (VALID_KEY(myHouse)) {
  705. await ensureHouseTribe(myHouse).catch(() => {});
  706. }
  707. await redeemPendingAutoInvites().catch(() => {});
  708. await issueAutoInvitesForMyHouse().catch(() => {});
  709. } catch (_) {}
  710. }
  711. async function handleLiveMessage(m) {
  712. const c = m && m.value && m.value.content;
  713. if (!c) return;
  714. const client = await openSsb();
  715. const me = client.id;
  716. const ssbKeys = require('../server/node_modules/ssb-keys');
  717. const config = require('../server/ssb_config');
  718. try {
  719. if (typeof c === 'object' && c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
  720. if (m.value.author === me) return;
  721. const myHouse = await getUserHouse(me).catch(() => null);
  722. if (myHouse !== c.house) return;
  723. await issueAutoInvitesForMyHouse().catch(() => {});
  724. return;
  725. }
  726. if (typeof c === 'object' && c.type === 'larpHouseTribeAnchor' && VALID_KEY(c.house)) {
  727. if (m.value.author === me) return;
  728. const myHouse = await getUserHouse(me).catch(() => null);
  729. if (myHouse !== c.house) return;
  730. await ensureHouseTribe(c.house).catch(() => {});
  731. await redeemPendingAutoInvites().catch(() => {});
  732. await issueAutoInvitesForMyHouse().catch(() => {});
  733. return;
  734. }
  735. if (typeof c === 'object' && c.type === 'larpHouseTribeAnchorTombstone' && VALID_KEY(c.house)) {
  736. if (m.value.author === me) return;
  737. const myHouse = await getUserHouse(me).catch(() => null);
  738. if (myHouse !== c.house) return;
  739. await ensureHouseTribe(c.house).catch(() => {});
  740. await redeemPendingAutoInvites().catch(() => {});
  741. return;
  742. }
  743. if (typeof c === 'string' && c.endsWith('.box')) {
  744. if (m.value.author === me) return;
  745. let decoded;
  746. try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
  747. if (!decoded) return;
  748. if (typeof decoded === 'string') {
  749. try { decoded = JSON.parse(decoded); } catch (_) { return; }
  750. }
  751. if (!decoded || decoded.type !== 'larpAutoInvite') return;
  752. if (!VALID_KEY(decoded.house) || typeof decoded.code !== 'string') return;
  753. const myHouse = await getUserHouse(me).catch(() => null);
  754. if (decoded.house !== myHouse) return;
  755. if (await alreadyInCanonical(myHouse)) return;
  756. try { await tribesModel.joinByInvite(decoded.code); } catch (_) {}
  757. }
  758. } catch (_) {}
  759. }
  760. async function init() {
  761. if (initRan) return;
  762. initRan = true;
  763. if (!liveSubscriberStarted) {
  764. liveSubscriberStarted = true;
  765. try {
  766. const client = await openSsb();
  767. pull(
  768. client.createLogStream({ live: true, old: false }),
  769. pull.drain((m) => { enqueue(() => handleLiveMessage(m)); }, () => { liveSubscriberStarted = false; })
  770. );
  771. } catch (_) { liveSubscriberStarted = false; }
  772. }
  773. enqueue(runCatchup);
  774. }
  775. async function createHouseInvite(houseKey) {
  776. if (!VALID_KEY(houseKey)) throw new Error('Invalid house key');
  777. if (houseKey === 'academia') throw new Error('ACADEMIA does not issue invites');
  778. const client = await openSsb();
  779. const myHouse = await getUserHouse(client.id);
  780. if (myHouse !== houseKey) throw new Error('Only members can issue invites');
  781. const tribe = await ensureHouseTribe(houseKey);
  782. if (!tribe) throw new Error('Could not resolve house tribe');
  783. if (!tribesModel) throw new Error('tribesModel unavailable');
  784. const code = await tribesModel.generateInvite(tribe.id);
  785. return { code, house: houseKey, tribeId: tribe.id };
  786. }
  787. async function redeemHouseInvite(rawCode) {
  788. const code = String(rawCode || '').trim();
  789. if (!code) return { ok: false };
  790. if (!tribesModel) return { ok: false };
  791. const client = await openSsb();
  792. const myHouse = await getUserHouse(client.id);
  793. if (myHouse && myHouse !== 'academia') return { ok: false };
  794. let rootId;
  795. try { rootId = await tribesModel.joinByInvite(code); } catch (_) { return { ok: false }; }
  796. if (!rootId) return { ok: false };
  797. let tribe = null;
  798. try { tribe = await tribesModel.getTribeById(rootId); } catch (_) { tribe = null; }
  799. const tags = (tribe && Array.isArray(tribe.tags)) ? tribe.tags : [];
  800. const houseTag = tags.find(t => typeof t === 'string' && t.startsWith('larp-'));
  801. if (!houseTag) return { ok: false };
  802. const suffix = houseTag.slice('larp-'.length);
  803. const houseKey = HOUSE_KEYS.find(k => (HOUSES[k] && HOUSES[k].name === suffix) || k === suffix);
  804. if (!houseKey || houseKey === 'academia') return { ok: false };
  805. await publishJoin(houseKey);
  806. return { ok: true, house: houseKey, tribeId: rootId };
  807. }
  808. return {
  809. HOUSES,
  810. HOUSE_KEYS,
  811. TEST_COOLDOWN_MS,
  812. TEST_QUESTIONS_COUNT,
  813. PROFILE_QUESTIONS,
  814. computeCycle,
  815. getGoverningHouseKey,
  816. publishJoin,
  817. publishLeaveLarp,
  818. getUserHouse,
  819. listHousesWithCounts,
  820. getMembersOfHouse,
  821. publishHousePost,
  822. listHousePosts,
  823. getLastTestAttempt,
  824. canTakeTest,
  825. getProfileTest,
  826. scoreProfileAnswers,
  827. submitProfileTest,
  828. createHouseInvite,
  829. redeemHouseInvite,
  830. findMyHouseTribe,
  831. ensureHouseTribe,
  832. leaveMyHouseTribe,
  833. issueAutoInvitesForMyHouse,
  834. redeemPendingAutoInvites,
  835. init,
  836. getHouse: (key) => HOUSES[key] || null
  837. };
  838. };