parliament_model.js 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165
  1. const pull = require('../server/node_modules/pull-stream');
  2. const moment = require('../server/node_modules/moment');
  3. const { getConfig } = require('../configs/config-manager.js');
  4. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  5. const TERM_DAYS = 60;
  6. const PROPOSAL_DAYS = 7;
  7. const REVOCATION_DAYS = 15;
  8. const METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY'];
  9. const FEED_ID_RE = /^@.+\.ed25519$/;
  10. module.exports = ({ cooler, services = {} }) => {
  11. let ssb;
  12. let userId;
  13. const openSsb = async () => {
  14. if (!ssb) {
  15. ssb = await cooler.open();
  16. userId = ssb.id;
  17. }
  18. return ssb;
  19. };
  20. const nowISO = () => new Date().toISOString();
  21. const parseISO = (s) => moment(s, moment.ISO_8601, true);
  22. const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
  23. const normMs = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
  24. async function readLog() {
  25. const ssbClient = await openSsb();
  26. return new Promise((res, rej) => {
  27. pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, arr) => (err ? rej(err) : res(arr))));
  28. });
  29. }
  30. async function listByType(type) {
  31. const msgs = await readLog();
  32. const tomb = new Set();
  33. const rep = new Map();
  34. const map = new Map();
  35. for (const m of msgs) {
  36. const k = m.key;
  37. const c = m.value?.content;
  38. if (!c) continue;
  39. if (c.type === 'tombstone' && c.target) tomb.add(c.target);
  40. if (c.type === type) {
  41. if (c.replaces) rep.set(c.replaces, k);
  42. map.set(k, { id: k, ...c });
  43. }
  44. }
  45. for (const oldId of rep.keys()) map.delete(oldId);
  46. for (const tId of tomb) map.delete(tId);
  47. return [...map.values()];
  48. }
  49. async function listTribesAny() {
  50. if (services.tribes?.listAll) return await services.tribes.listAll();
  51. return await listByType('tribe');
  52. }
  53. async function getLatestAboutFromLog(feedId) {
  54. const msgs = await readLog();
  55. let latest = null;
  56. for (let i = msgs.length - 1; i >= 0; i--) {
  57. const v = msgs[i].value || {};
  58. const c = v.content || {};
  59. if (!c || c.type !== 'about') continue;
  60. const bySelf = v.author === feedId && typeof c.name === 'string';
  61. const aboutTarget = c.about === feedId && (typeof c.name === 'string' || typeof c.description === 'string' || typeof c.image === 'string');
  62. if (bySelf || aboutTarget) {
  63. const ts = normMs(v.timestamp || msgs[i].timestamp || Date.now());
  64. if (!latest || ts > latest.ts) latest = { ts, content: c };
  65. }
  66. }
  67. return latest ? latest.content : null;
  68. }
  69. async function getTribeMetaById(tribeId) {
  70. let tribe = null;
  71. if (services.tribes?.getTribeById) {
  72. try { tribe = await services.tribes.getTribeById(tribeId); } catch {}
  73. }
  74. if (!tribe) return { isTribe: true, name: tribeId, avatarUrl: '/assets/images/default-tribe.png', bio: '' };
  75. const imgId = tribe.image || null;
  76. const avatarUrl = imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-tribe.png';
  77. return { isTribe: true, name: tribe.title || tribe.name || tribeId, avatarUrl, bio: tribe.description || '' };
  78. }
  79. async function getInhabitantMetaById(feedId) {
  80. let aboutRec = null;
  81. if (services.inhabitants?.getLatestAboutById) {
  82. try { aboutRec = await services.inhabitants.getLatestAboutById(feedId); } catch {}
  83. }
  84. if (!aboutRec) {
  85. try { aboutRec = await getLatestAboutFromLog(feedId); } catch {}
  86. }
  87. const name = (aboutRec && typeof aboutRec.name === 'string' && aboutRec.name.trim()) ? aboutRec.name.trim() : feedId;
  88. const imgField = aboutRec && aboutRec.image;
  89. const imgId = typeof imgField === 'string' ? imgField : (imgField && (imgField.link || imgField.url)) ? (imgField.link || imgField.url) : null;
  90. const avatarUrl = imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-avatar.png';
  91. const bio = (aboutRec && typeof aboutRec.description === 'string') ? aboutRec.description : '';
  92. return { isTribe: false, name, avatarUrl, bio };
  93. }
  94. async function actorMeta({ targetType, targetId }) {
  95. const t = String(targetType || '').toLowerCase();
  96. if (t === 'tribe') return await getTribeMetaById(targetId);
  97. return await getInhabitantMetaById(targetId);
  98. }
  99. async function getInhabitantTitleSSB(feedId) {
  100. const msgs = await readLog();
  101. for (let i = msgs.length - 1; i >= 0; i--) {
  102. const v = msgs[i].value || {};
  103. const c = v.content || {};
  104. if (!c || c.type !== 'about') continue;
  105. if (c.about === feedId && typeof c.name === 'string' && c.name.trim()) return c.name.trim();
  106. if (v.author === feedId && typeof c.name === 'string' && c.name.trim()) return c.name.trim();
  107. }
  108. return null;
  109. }
  110. async function findFeedIdByName(name) {
  111. const q = String(name || '').trim().toLowerCase();
  112. if (!q) return null;
  113. const msgs = await readLog();
  114. let best = null;
  115. for (let i = msgs.length - 1; i >= 0; i--) {
  116. const v = msgs[i].value || {};
  117. const c = v.content || {};
  118. if (!c || c.type !== 'about' || typeof c.name !== 'string') continue;
  119. if (c.name.trim().toLowerCase() !== q) continue;
  120. const fid = typeof c.about === 'string' && FEED_ID_RE.test(c.about) ? c.about : v.author;
  121. const ts = normMs(v.timestamp || msgs[i].timestamp || Date.now());
  122. if (!best || ts > best.ts) best = { id: fid, ts };
  123. }
  124. return best ? best.id : null;
  125. }
  126. async function resolveTarget(candidateInput) {
  127. const s = String(candidateInput || '').trim();
  128. if (!s) return null;
  129. const tribes = await listTribesAny();
  130. const t = tribes.find(tr =>
  131. tr.id === s ||
  132. (tr.title && tr.title.toLowerCase() === s.toLowerCase()) ||
  133. (tr.name && tr.name.toLowerCase() === s.toLowerCase())
  134. );
  135. if (t) {
  136. return { type: 'tribe', id: t.id, title: t.title || t.name || t.id, members: ensureArray(t.members) };
  137. }
  138. if (FEED_ID_RE.test(s)) {
  139. const title = await getInhabitantTitleSSB(s);
  140. return { type: 'inhabitant', id: s, title: title || s, members: [] };
  141. }
  142. const fid = await findFeedIdByName(s);
  143. if (fid) {
  144. const title = await getInhabitantTitleSSB(fid);
  145. return { type: 'inhabitant', id: fid, title: title || s, members: [] };
  146. }
  147. return null;
  148. }
  149. function majorityThreshold(total) { return Math.ceil(Number(total || 0) * 0.8); }
  150. function minorityThreshold(total) { return Math.ceil(Number(total || 0) * 0.2); }
  151. function democracyThreshold(total) { return Math.floor(Number(total || 0) / 2) + 1; }
  152. function passesThreshold(method, total, yes) {
  153. const m = String(method || '').toUpperCase();
  154. if (m === 'DEMOCRACY' || m === 'ANARCHY') return yes >= democracyThreshold(total);
  155. if (m === 'MAJORITY') return yes >= majorityThreshold(total);
  156. if (m === 'MINORITY') return yes >= minorityThreshold(total);
  157. return false;
  158. }
  159. function requiredVotes(method, total) {
  160. const m = String(method || '').toUpperCase();
  161. if (m === 'DEMOCRACY' || m === 'ANARCHY') return democracyThreshold(total);
  162. if (m === 'MAJORITY') return majorityThreshold(total);
  163. if (m === 'MINORITY') return minorityThreshold(total);
  164. return 0;
  165. }
  166. async function listCandidaturesOpenRaw() {
  167. const all = await listByType('parliamentCandidature');
  168. const filtered = all.filter(c => (c.status || 'OPEN') === 'OPEN');
  169. return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  170. }
  171. async function getFirstUserTimestamp(feedId) {
  172. const ssbClient = await openSsb();
  173. return new Promise((resolve) => {
  174. pull(
  175. ssbClient.createUserStream({ id: feedId, reverse: false }),
  176. pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
  177. pull.take(1),
  178. pull.collect((err, arr) => {
  179. if (err || !arr || !arr.length) return resolve(Date.now());
  180. const m = arr[0];
  181. const ts = normMs((m.value && m.value.timestamp) || m.timestamp);
  182. resolve(ts || Date.now());
  183. })
  184. );
  185. });
  186. }
  187. async function getInhabitantKarma(feedId) {
  188. if (services.banking?.getUserEngagementScore) {
  189. try { return Number(await services.banking.getUserEngagementScore(feedId)) || 0; } catch { return 0; }
  190. }
  191. return 0;
  192. }
  193. async function getTribeSince(tribeId) {
  194. if (services.tribes?.getTribeById) {
  195. try {
  196. const t = await services.tribes.getTribeById(tribeId);
  197. if (t?.createdAt) return new Date(t.createdAt).getTime();
  198. } catch {}
  199. }
  200. return Date.now();
  201. }
  202. async function listCandidaturesOpen() {
  203. const rows = await listCandidaturesOpenRaw();
  204. const enriched = await Promise.all(rows.map(async c => {
  205. if (c.targetType === 'inhabitant') {
  206. const karma = await getInhabitantKarma(c.targetId);
  207. const since = await getFirstUserTimestamp(c.targetId);
  208. return { ...c, karma, profileSince: since };
  209. } else {
  210. const since = await getTribeSince(c.targetId);
  211. return { ...c, karma: 0, profileSince: since };
  212. }
  213. }));
  214. return enriched;
  215. }
  216. async function listTermsBase(filter = 'all') {
  217. const all = await listByType('parliamentTerm');
  218. const collapsed = collapseOverlappingTerms(all);
  219. let arr = collapsed.map(t => ({ ...t, status: moment().isAfter(parseISO(t.endAt)) ? 'EXPIRED' : 'ACTIVE' }));
  220. if (filter === 'active') arr = arr.filter(t => t.status === 'ACTIVE');
  221. if (filter === 'expired') arr = arr.filter(t => t.status === 'EXPIRED');
  222. return arr.sort((a, b) => new Date(b.startAt) - new Date(a.startAt));
  223. }
  224. async function getCurrentTermBase() {
  225. const active = await listTermsBase('active');
  226. return active[0] || null;
  227. }
  228. function currentCycleStart(term) {
  229. return term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString();
  230. }
  231. async function archiveAllCandidatures() {
  232. const ssbClient = await openSsb();
  233. const all = await listCandidaturesOpenRaw();
  234. for (const c of all) {
  235. const tomb = { type: 'tombstone', target: c.id, deletedAt: nowISO(), author: userId };
  236. await new Promise((resolve) => ssbClient.publish(tomb, () => resolve()));
  237. }
  238. }
  239. async function chooseWinnerFromCandidaturesAsync(cands) {
  240. if (!cands.length) return null;
  241. const norm = cands.map(c => ({
  242. ...c,
  243. votes: Number(c.votes || 0),
  244. karma: Number(c.karma || 0),
  245. since: Number(c.profileSince || 0),
  246. createdAtMs: new Date(c.createdAt).getTime() || 0
  247. }));
  248. const totalVotes = norm.reduce((s, c) => s + c.votes, 0);
  249. if (totalVotes > 0) {
  250. const maxVotes = Math.max(...norm.map(c => c.votes));
  251. let tied = norm.filter(c => c.votes === maxVotes);
  252. if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
  253. const maxKarma = Math.max(...tied.map(c => c.karma));
  254. tied = tied.filter(c => c.karma === maxKarma);
  255. if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
  256. tied.sort((a, b) => (a.since || 0) - (b.since || 0));
  257. const oldestSince = tied[0].since || 0;
  258. tied = tied.filter(c => c.since === oldestSince);
  259. if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
  260. tied.sort((a, b) => a.createdAtMs - b.createdAtMs);
  261. const earliest = tied[0].createdAtMs;
  262. tied = tied.filter(c => c.createdAtMs === earliest);
  263. if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
  264. tied.sort((a, b) => String(a.targetId).localeCompare(String(b.targetId)));
  265. return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
  266. }
  267. const latest = norm.sort((a, b) => b.createdAtMs - a.createdAtMs)[0];
  268. return { chosen: latest, totalVotes: 0, winnerVotes: 0 };
  269. }
  270. async function summarizePoliciesForTerm(termOrId) {
  271. let termId = null;
  272. let termStartMs = null;
  273. let termEndMs = null;
  274. if (termOrId && typeof termOrId === 'object') {
  275. termId = termOrId.id || termOrId.startAt;
  276. if (termOrId.startAt) termStartMs = new Date(termOrId.startAt).getTime();
  277. if (termOrId.endAt) termEndMs = new Date(termOrId.endAt).getTime();
  278. } else {
  279. termId = termOrId;
  280. }
  281. const proposals = await listByType('parliamentProposal');
  282. const mine = proposals.filter(p => {
  283. if (termId && p.termId === termId) return true;
  284. if (termStartMs != null && termEndMs != null && p.createdAt) {
  285. const t = new Date(p.createdAt).getTime();
  286. return t >= termStartMs && t <= termEndMs;
  287. }
  288. return false;
  289. });
  290. const proposed = mine.length;
  291. let approved = 0;
  292. let declined = 0;
  293. let discarded = 0;
  294. for (const p of mine) {
  295. const baseStatus = String(p.status || 'OPEN').toUpperCase();
  296. let finalStatus = baseStatus;
  297. let isDiscarded = false;
  298. if (p.voteId && services.votes?.getVoteById) {
  299. try {
  300. const v = await services.votes.getVoteById(p.voteId);
  301. const votesMap = v.votes || {};
  302. const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
  303. const total = Number(v.totalVotes ?? v.total ?? sum);
  304. const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
  305. const dl = v.deadline || v.endAt || v.expiresAt || null;
  306. const closed = v.status === 'CLOSED' || (dl && moment(dl).isBefore(moment()));
  307. const reached = passesThreshold(p.method, total, yes);
  308. if (!closed) {
  309. if (dl && moment(dl).isBefore(moment()) && !reached) {
  310. isDiscarded = true;
  311. } else {
  312. finalStatus = 'OPEN';
  313. }
  314. } else {
  315. if (reached) finalStatus = 'APPROVED';
  316. else finalStatus = 'REJECTED';
  317. }
  318. } catch {}
  319. } else {
  320. if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) {
  321. isDiscarded = true;
  322. }
  323. }
  324. if (isDiscarded) {
  325. discarded++;
  326. continue;
  327. }
  328. if (finalStatus === 'ENACTED') {
  329. approved++;
  330. continue;
  331. }
  332. if (finalStatus === 'APPROVED') {
  333. approved++;
  334. continue;
  335. }
  336. if (finalStatus === 'REJECTED') {
  337. declined++;
  338. continue;
  339. }
  340. }
  341. const revs = await listByType('parliamentRevocation');
  342. const revocated = revs.filter(r => {
  343. if (r.status !== 'ENACTED') return false;
  344. if (termId && r.termId === termId) return true;
  345. if (termStartMs != null && termEndMs != null && r.createdAt) {
  346. const t = new Date(r.createdAt).getTime();
  347. return t >= termStartMs && t <= termEndMs;
  348. }
  349. return false;
  350. }).length;
  351. return { proposed, approved, declined, discarded, revocated };
  352. }
  353. async function computeGovernmentCard(term) {
  354. if (!term) return null;
  355. const method = term.method || 'DEMOCRACY';
  356. const isTribe = term.powerType === 'tribe';
  357. let members = 1;
  358. if (isTribe && term.powerId) {
  359. const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
  360. members = tribe && Array.isArray(tribe.members) ? tribe.members.length : 0;
  361. }
  362. const pol = await summarizePoliciesForTerm({ ...term });
  363. const eff = pol.proposed > 0 ? Math.round((pol.approved / pol.proposed) * 100) : 0;
  364. return {
  365. method,
  366. powerType: term.powerType,
  367. powerId: term.powerId,
  368. powerTitle: term.powerTitle,
  369. votesReceived: term.winnerVotes || 0,
  370. totalVotes: term.totalVotes || 0,
  371. members,
  372. since: term.startAt,
  373. end: term.endAt,
  374. proposed: pol.proposed,
  375. approved: pol.approved,
  376. declined: pol.declined,
  377. discarded: pol.discarded,
  378. revocated: pol.revocated,
  379. efficiency: eff
  380. };
  381. }
  382. async function countMyProposalsThisTerm(term) {
  383. const termId = term.id || term.startAt;
  384. const proposals = await listByType('parliamentProposal');
  385. const laws = await listByType('parliamentLaw');
  386. const nProp = proposals.filter(p => p.termId === termId && p.proposer === userId).length;
  387. const nLaw = laws.filter(l => l.termId === termId && l.proposer === userId).length;
  388. return nProp + nLaw;
  389. }
  390. async function getGroupMembers(term) {
  391. if (!term) return [];
  392. if (term.powerType === 'inhabitant') return [term.powerId];
  393. if (term.powerType === 'tribe') {
  394. const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
  395. return ensureArray(tribe?.members || []);
  396. }
  397. return [];
  398. }
  399. async function closeExpiredKarmatocracy(term) {
  400. const termId = term.id || term.startAt;
  401. const all = await listByType('parliamentProposal');
  402. const pending = all.filter(p =>
  403. p.termId === termId &&
  404. String(p.method).toUpperCase() === 'KARMATOCRACY' &&
  405. (p.status || 'OPEN') !== 'ENACTED' &&
  406. p.deadline && moment().isAfter(parseISO(p.deadline))
  407. );
  408. if (!pending.length) return;
  409. const withKarma = await Promise.all(pending.map(async p => ({
  410. ...p,
  411. karma: await getInhabitantKarma(p.proposer),
  412. createdAtMs: new Date(p.createdAt).getTime() || 0
  413. })));
  414. withKarma.sort((a, b) => {
  415. if (b.karma !== a.karma) return b.karma - a.karma;
  416. if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
  417. return String(a.proposer).localeCompare(String(b.proposer));
  418. });
  419. const winner = withKarma[0];
  420. const losers = withKarma.slice(1);
  421. const ssbClient = await openSsb();
  422. const approve = { ...winner, replaces: winner.id, status: 'APPROVED', updatedAt: nowISO() };
  423. await new Promise((resolve, reject) => ssbClient.publish(approve, (e, r) => (e ? reject(e) : resolve(r))));
  424. for (const lo of losers) {
  425. const rej = { ...lo, replaces: lo.id, status: 'REJECTED', updatedAt: nowISO() };
  426. await new Promise((resolve) => ssbClient.publish(rej, () => resolve()));
  427. }
  428. }
  429. async function closeExpiredDictatorship(term) {
  430. const termId = term.id || term.startAt;
  431. const all = await listByType('parliamentProposal');
  432. const pending = all.filter(p =>
  433. p.termId === termId &&
  434. String(p.method).toUpperCase() === 'DICTATORSHIP' &&
  435. (p.status || 'OPEN') !== 'ENACTED' &&
  436. p.deadline && moment().isAfter(parseISO(p.deadline))
  437. );
  438. if (!pending.length) return;
  439. const ssbClient = await openSsb();
  440. for (const p of pending) {
  441. const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
  442. await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  443. }
  444. }
  445. async function closeExpiredRevocationKarmatocracy(term) {
  446. const termId = term.id || term.startAt;
  447. const all = await listByType('parliamentRevocation');
  448. const pending = all.filter(p =>
  449. p.termId === termId &&
  450. String(p.method).toUpperCase() === 'KARMATOCRACY' &&
  451. (p.status || 'OPEN') !== 'ENACTED' &&
  452. p.deadline && moment().isAfter(parseISO(p.deadline))
  453. );
  454. if (!pending.length) return;
  455. const ssbClient = await openSsb();
  456. for (const p of pending) {
  457. const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
  458. await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  459. }
  460. }
  461. async function closeExpiredRevocationDictatorship(term) {
  462. const termId = term.id || term.startAt;
  463. const all = await listByType('parliamentRevocation');
  464. const pending = all.filter(p =>
  465. p.termId === termId &&
  466. String(p.method).toUpperCase() === 'DICTATORSHIP' &&
  467. (p.status || 'OPEN') !== 'ENACTED' &&
  468. p.deadline && moment().isAfter(parseISO(p.deadline))
  469. );
  470. if (!pending.length) return;
  471. const ssbClient = await openSsb();
  472. for (const p of pending) {
  473. const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
  474. await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  475. }
  476. }
  477. async function createRevocation({ lawId, title, reasons }) {
  478. const term = await getCurrentTermBase();
  479. if (!term) throw new Error('No active government');
  480. const allowed = await this.canPropose();
  481. if (!allowed) throw new Error('You are not in the goverment, yet.');
  482. const lawIdStr = String(lawId || '').trim();
  483. if (!lawIdStr) throw new Error('Law required');
  484. const laws = await listByType('parliamentLaw');
  485. const law = laws.find(l => l.id === lawIdStr);
  486. if (!law) throw new Error('Law not found');
  487. const method = String(term.method || 'DEMOCRACY').toUpperCase();
  488. const ssbClient = await openSsb();
  489. const deadline = moment().add(REVOCATION_DAYS, 'days').toISOString();
  490. if (method === 'DICTATORSHIP' || method === 'KARMATOCRACY') {
  491. const rev = {
  492. type: 'parliamentRevocation',
  493. lawId: lawIdStr,
  494. title: title || law.question || '',
  495. reasons: reasons || '',
  496. method,
  497. termId: term.id || term.startAt,
  498. proposer: userId,
  499. status: 'OPEN',
  500. deadline,
  501. createdAt: nowISO()
  502. };
  503. return await new Promise((resolve, reject) =>
  504. ssbClient.publish(rev, (e, r) => (e ? reject(e) : resolve(r)))
  505. );
  506. }
  507. const voteMsg = await services.votes.createVote(
  508. `Revoke: ${title || law.question || ''}`,
  509. deadline,
  510. ['YES', 'NO', 'ABSTENTION'],
  511. [`gov:${term.id || term.startAt}`, `govMethod:${method}`, 'revocation']
  512. );
  513. const rev = {
  514. type: 'parliamentRevocation',
  515. lawId: lawIdStr,
  516. title: title || law.question || '',
  517. reasons: reasons || '',
  518. method,
  519. voteId: voteMsg.key || voteMsg.id,
  520. termId: term.id || term.startAt,
  521. proposer: userId,
  522. status: 'OPEN',
  523. createdAt: nowISO()
  524. };
  525. return await new Promise((resolve, reject) =>
  526. ssbClient.publish(rev, (e, r) => (e ? reject(e) : resolve(r)))
  527. );
  528. }
  529. async function closeRevocation(revId) {
  530. const ssbClient = await openSsb();
  531. const msg = await new Promise((resolve, reject) => ssbClient.get(revId, (e, m) => (e || !m) ? reject(new Error('Revocation not found')) : resolve(m)));
  532. if (msg.content?.type !== 'parliamentRevocation') throw new Error('Revocation not found');
  533. const p = msg.content;
  534. if (p.method === 'DICTATORSHIP') {
  535. const updated = { ...p, replaces: revId, status: 'APPROVED', updatedAt: nowISO() };
  536. return await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  537. }
  538. if (p.method === 'KARMATOCRACY') {
  539. return p;
  540. }
  541. const v = await services.votes.getVoteById(p.voteId);
  542. const votesMap = v.votes || {};
  543. const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
  544. const total = Number(v.totalVotes ?? v.total ?? sum);
  545. const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
  546. let ok = false;
  547. const m = String(p.method || '').toUpperCase();
  548. if (m === 'DEMOCRACY' || m === 'ANARCHY') ok = yes >= democracyThreshold(total);
  549. else if (m === 'MAJORITY') ok = yes >= majorityThreshold(total);
  550. else if (m === 'MINORITY') ok = yes >= minorityThreshold(total);
  551. const updated = { ...p, replaces: revId, status: ok ? 'APPROVED' : 'REJECTED', updatedAt: nowISO() };
  552. return await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  553. }
  554. async function proposeCandidature({ candidateId, method }) {
  555. const m = String(method || '').toUpperCase();
  556. if (!METHODS.includes(m)) throw new Error('Invalid method');
  557. const target = await resolveTarget(candidateId);
  558. if (!target) throw new Error('Candidate not found');
  559. const term = await getCurrentTermBase();
  560. const since = currentCycleStart(term);
  561. const myAll = await listByType('parliamentCandidature');
  562. const mineThisCycle = myAll.filter(c => c.proposer === userId && new Date(c.createdAt) >= new Date(since));
  563. if (mineThisCycle.length >= 3) throw new Error('Candidate limit reached');
  564. const open = await listCandidaturesOpenRaw();
  565. const duplicate = open.find(c => c.targetType === target.type && c.targetId === target.id && new Date(c.createdAt) >= new Date(since));
  566. if (duplicate) throw new Error('Candidate already proposed this cycle');
  567. const content = {
  568. type: 'parliamentCandidature',
  569. targetType: target.type,
  570. targetId: target.id,
  571. targetTitle: target.title,
  572. method: m,
  573. votes: 0,
  574. voters: [],
  575. proposer: userId,
  576. status: 'OPEN',
  577. createdAt: nowISO()
  578. };
  579. const ssbClient = await openSsb();
  580. return new Promise((resolve, reject) => ssbClient.publish(content, (e, r) => (e ? reject(e) : resolve(r))));
  581. }
  582. async function voteCandidature(candidatureMsgId) {
  583. const ssbClient = await openSsb();
  584. const open = await listCandidaturesOpenRaw();
  585. const already = open.some(c => ensureArray(c.voters).includes(userId));
  586. if (already) throw new Error('Already voted this cycle');
  587. return new Promise((resolve, reject) => {
  588. ssbClient.get(candidatureMsgId, (err, msg) => {
  589. if (err || !msg || msg.content?.type !== 'parliamentCandidature') return reject(new Error('Candidate not found'));
  590. const c = msg.content;
  591. if ((c.status || 'OPEN') !== 'OPEN') return reject(new Error('Closed'));
  592. const updated = { ...c, replaces: candidatureMsgId, votes: Number(c.votes || 0) + 1, voters: [...ensureArray(c.voters), userId], updatedAt: nowISO() };
  593. ssbClient.publish(updated, (e2, r2) => (e2 ? reject(e2) : resolve(r2)));
  594. });
  595. });
  596. }
  597. async function createProposal({ title, description }) {
  598. let term = await getCurrentTermBase();
  599. if (!term) {
  600. await this.resolveElection();
  601. term = await getCurrentTermBase();
  602. }
  603. if (!term) throw new Error('No active government');
  604. const allowed = await this.canPropose();
  605. if (!allowed) throw new Error('You are not in the goverment, yet.');
  606. if (!title || !title.trim()) throw new Error('Title required');
  607. if (String(description || '').length > 1000) throw new Error('Description too long');
  608. const used = await countMyProposalsThisTerm(term);
  609. if (used >= 3) throw new Error('Proposal limit reached');
  610. const method = String(term.method || 'DEMOCRACY').toUpperCase();
  611. const ssbClient = await openSsb();
  612. if (method === 'DICTATORSHIP') {
  613. const deadline = moment().add(PROPOSAL_DAYS, 'days').toISOString();
  614. const proposal = { type: 'parliamentProposal', title, description: description || '', method, termId: term.id || term.startAt, proposer: userId, status: 'OPEN', deadline, createdAt: nowISO() };
  615. return await new Promise((resolve, reject) => ssbClient.publish(proposal, (e, r) => (e ? reject(e) : resolve(r))));
  616. }
  617. if (method === 'KARMATOCRACY') {
  618. const deadline = moment().add(PROPOSAL_DAYS, 'days').toISOString();
  619. const proposal = { type: 'parliamentProposal', title, description: description || '', method, termId: term.id || term.startAt, proposer: userId, status: 'OPEN', deadline, createdAt: nowISO() };
  620. return await new Promise((resolve, reject) => ssbClient.publish(proposal, (e, r) => (e ? reject(e) : resolve(r))));
  621. }
  622. const deadline = moment().add(PROPOSAL_DAYS, 'days').toISOString();
  623. const voteMsg = await services.votes.createVote(title, deadline, ['YES', 'NO', 'ABSTENTION'], [`gov:${term.id || term.startAt}`, `govMethod:${method}`, 'proposal']);
  624. const proposal = { type: 'parliamentProposal', title, description: description || '', method, voteId: voteMsg.key || voteMsg.id, termId: term.id || term.startAt, proposer: userId, status: 'OPEN', createdAt: nowISO() };
  625. return await new Promise((resolve, reject) => ssbClient.publish(proposal, (e, r) => (e ? reject(e) : resolve(r))));
  626. }
  627. async function closeProposal(proposalId) {
  628. const ssbClient = await openSsb();
  629. const msg = await new Promise((resolve, reject) => ssbClient.get(proposalId, (e, m) => (e || !m) ? reject(new Error('Proposal not found')) : resolve(m)));
  630. if (msg.content?.type !== 'parliamentProposal') throw new Error('Proposal not found');
  631. const p = msg.content;
  632. if (p.method === 'DICTATORSHIP') {
  633. const updated = { ...p, replaces: proposalId, status: 'APPROVED', updatedAt: nowISO() };
  634. return await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  635. }
  636. if (p.method === 'KARMATOCRACY') {
  637. return p;
  638. }
  639. const v = await services.votes.getVoteById(p.voteId);
  640. const votesMap = v.votes || {};
  641. const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
  642. const total = Number(v.totalVotes ?? v.total ?? sum);
  643. const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
  644. let ok = false;
  645. const m = String(p.method || '').toUpperCase();
  646. if (m === 'DEMOCRACY' || m === 'ANARCHY') ok = yes >= democracyThreshold(total);
  647. else if (m === 'MAJORITY') ok = yes >= majorityThreshold(total);
  648. else if (m === 'MINORITY') ok = yes >= minorityThreshold(total);
  649. const updated = { ...p, replaces: proposalId, status: ok ? 'APPROVED' : 'REJECTED', updatedAt: nowISO() };
  650. return await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  651. }
  652. async function sweepProposals() {
  653. const term = await getCurrentTermBase();
  654. if (!term) return;
  655. await closeExpiredKarmatocracy(term);
  656. await closeExpiredDictatorship(term);
  657. const ssbClient = await openSsb();
  658. const allProps = await listByType('parliamentProposal');
  659. const voteProps = allProps.filter(p => {
  660. const m = String(p.method || '').toUpperCase();
  661. return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId;
  662. });
  663. for (const p of voteProps) {
  664. try {
  665. const v = await services.votes.getVoteById(p.voteId);
  666. const votesMap = v.votes || {};
  667. const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
  668. const total = Number(v.totalVotes ?? v.total ?? sum);
  669. const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
  670. const closed = v.status === 'CLOSED' || (v.deadline && moment(v.deadline).isBefore(moment()));
  671. if (closed) { try { await this.closeProposal(p.id); } catch {} ; continue; }
  672. if ((p.status || 'OPEN') === 'OPEN' && passesThreshold(p.method, total, yes)) {
  673. const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
  674. await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  675. }
  676. } catch {}
  677. }
  678. await closeExpiredRevocationKarmatocracy(term);
  679. await closeExpiredRevocationDictatorship(term);
  680. const revs = await listByType('parliamentRevocation');
  681. const voteRevs = revs.filter(p => {
  682. const m = String(p.method || '').toUpperCase();
  683. return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId;
  684. });
  685. for (const p of voteRevs) {
  686. try {
  687. const v = await services.votes.getVoteById(p.voteId);
  688. const votesMap = v.votes || {};
  689. const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
  690. const total = Number(v.totalVotes ?? v.total ?? sum);
  691. const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
  692. const closed = v.status === 'CLOSED' || (v.deadline && moment(v.deadline).isBefore(moment()));
  693. if (closed) { try { await closeRevocation(p.id); } catch {} ; continue; }
  694. if ((p.status || 'OPEN') === 'OPEN' && passesThreshold(p.method, total, yes)) {
  695. const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
  696. await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  697. }
  698. } catch {}
  699. }
  700. }
  701. async function getActorMeta({ targetType, targetId }) {
  702. return await actorMeta({ targetType, targetId });
  703. }
  704. async function listCandidatures(filter = 'OPEN') {
  705. if (filter === 'OPEN') return await listCandidaturesOpen();
  706. const all = await listByType('parliamentCandidature');
  707. return all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  708. }
  709. async function listTerms(filter = 'all') {
  710. return await listTermsBase(filter);
  711. }
  712. async function getCurrentTerm() {
  713. return await getCurrentTermBase();
  714. }
  715. async function listLeaders() {
  716. const terms = await listTermsBase('all');
  717. const map = new Map();
  718. for (const t of terms) {
  719. if (!(t.powerType === 'tribe' || t.powerType === 'inhabitant')) continue;
  720. const k = `${t.powerType}:${t.powerId}`;
  721. if (!map.has(k)) map.set(k, { powerType: t.powerType, powerId: t.powerId, powerTitle: t.powerTitle, inPower: 0, presented: 0, proposed: 0, approved: 0, declined: 0, discarded: 0, revocated: 0 });
  722. const rec = map.get(k);
  723. rec.inPower += 1;
  724. const sum = await summarizePoliciesForTerm(t);
  725. rec.proposed += sum.proposed;
  726. rec.approved += sum.approved;
  727. rec.declined += sum.declined;
  728. rec.discarded += sum.discarded;
  729. rec.revocated += sum.revocated;
  730. }
  731. const cands = await listByType('parliamentCandidature');
  732. for (const c of cands) {
  733. const k = `${c.targetType}:${c.targetId}`;
  734. if (!map.has(k)) map.set(k, { powerType: c.targetType, powerId: c.targetId, powerTitle: c.targetTitle, inPower: 0, presented: 0, proposed: 0, approved: 0, declined: 0, discarded: 0, revocated: 0 });
  735. const rec = map.get(k);
  736. rec.presented = (rec.presented || 0) + 1;
  737. }
  738. const rows = [...map.values()].map(r => ({ ...r, presented: r.presented || 0, efficiency: (r.proposed > 0 ? r.approved / r.proposed : 0) }));
  739. rows.sort((a, b) => {
  740. if (b.approved !== a.approved) return b.approved - a.approved;
  741. if ((b.efficiency || 0) !== (a.efficiency || 0)) return (b.efficiency || 0) - (a.efficiency || 0);
  742. if (b.inPower !== a.inPower) return b.inPower - a.inPower;
  743. if (b.proposed !== a.proposed) return b.proposed - a.proposed;
  744. return String(a.powerId).localeCompare(String(b.powerId));
  745. });
  746. return rows;
  747. }
  748. async function listProposalsCurrent() {
  749. const all = await listByType('parliamentProposal');
  750. const rows = all
  751. .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  752. const out = [];
  753. for (const p of rows) {
  754. const status = String(p.status || 'OPEN').toUpperCase();
  755. if (status === 'ENACTED' || status === 'REJECTED' || status === 'DISCARDED') continue;
  756. let deadline = p.deadline || null;
  757. let yes = 0;
  758. let total = 0;
  759. let voteClosed = false;
  760. if (p.voteId && services.votes?.getVoteById) {
  761. try {
  762. const v = await services.votes.getVoteById(p.voteId);
  763. const votesMap = v.votes || {};
  764. const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
  765. total = Number(v.totalVotes ?? v.total ?? sum);
  766. yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
  767. deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
  768. voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
  769. if (voteClosed) {
  770. try { await this.closeProposal(p.id); } catch {}
  771. continue;
  772. }
  773. const reached = passesThreshold(p.method, total, yes);
  774. if (reached && status !== 'APPROVED') {
  775. const ssbClient = await openSsb();
  776. const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
  777. await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  778. }
  779. } catch {}
  780. }
  781. const needed = requiredVotes(p.method, total);
  782. const onTrack = passesThreshold(p.method, total, yes);
  783. out.push({ ...p, deadline, yes, total, needed, onTrack });
  784. }
  785. return out;
  786. }
  787. async function listFutureLawsCurrent() {
  788. const term = await getCurrentTermBase();
  789. if (!term) return [];
  790. const termId = term.id || term.startAt;
  791. const all = await listByType('parliamentProposal');
  792. const rows = all
  793. .filter(p => p.termId === termId)
  794. .filter(p => p.status === 'APPROVED')
  795. .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  796. const out = [];
  797. for (const p of rows) {
  798. let yes = 0;
  799. let total = 0;
  800. let deadline = p.deadline || null;
  801. let voteClosed = true;
  802. if (p.voteId && services.votes?.getVoteById) {
  803. try {
  804. const v = await services.votes.getVoteById(p.voteId);
  805. const votesMap = v.votes || {};
  806. const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
  807. total = Number(v.totalVotes ?? v.total ?? sum);
  808. yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
  809. deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
  810. voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
  811. if (!voteClosed) continue;
  812. } catch {}
  813. }
  814. const needed = requiredVotes(p.method, total);
  815. out.push({ ...p, deadline, yes, total, needed });
  816. }
  817. return out;
  818. }
  819. async function listRevocationsCurrent() {
  820. const all = await listByType('parliamentRevocation');
  821. const rows = all
  822. .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  823. const out = [];
  824. for (const p of rows) {
  825. const status = String(p.status || 'OPEN').toUpperCase();
  826. if (status === 'ENACTED' || status === 'REJECTED' || status === 'DISCARDED') continue;
  827. let deadline = p.deadline || null;
  828. let yes = 0;
  829. let total = 0;
  830. let voteClosed = false;
  831. if (p.voteId && services.votes?.getVoteById) {
  832. try {
  833. const v = await services.votes.getVoteById(p.voteId);
  834. const votesMap = v.votes || {};
  835. const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
  836. total = Number(v.totalVotes ?? v.total ?? sum);
  837. yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
  838. deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
  839. voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
  840. if (voteClosed) {
  841. try { await closeRevocation(p.id); } catch {}
  842. continue;
  843. }
  844. const reached = passesThreshold(p.method, total, yes);
  845. if (reached && status !== 'APPROVED') {
  846. const ssbClient = await openSsb();
  847. const updated = { ...p, replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
  848. await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  849. }
  850. } catch {}
  851. }
  852. const needed = requiredVotes(p.method, total);
  853. const onTrack = passesThreshold(p.method, total, yes);
  854. out.push({ ...p, deadline, yes, total, needed, onTrack });
  855. }
  856. return out;
  857. }
  858. async function listFutureRevocationsCurrent() {
  859. const term = await getCurrentTermBase();
  860. if (!term) return [];
  861. const termId = term.id || term.startAt;
  862. const all = await listByType('parliamentRevocation');
  863. const rows = all
  864. .filter(p => p.termId === termId)
  865. .filter(p => p.status === 'APPROVED')
  866. .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  867. const out = [];
  868. for (const p of rows) {
  869. let yes = 0;
  870. let total = 0;
  871. let deadline = p.deadline || null;
  872. let voteClosed = true;
  873. if (p.voteId && services.votes?.getVoteById) {
  874. try {
  875. const v = await services.votes.getVoteById(p.voteId);
  876. const votesMap = v.votes || {};
  877. const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
  878. total = Number(v.totalVotes ?? v.total ?? sum);
  879. yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
  880. deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
  881. voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
  882. if (!voteClosed) continue;
  883. } catch {}
  884. }
  885. const needed = requiredVotes(p.method, total);
  886. out.push({ ...p, deadline, yes, total, needed });
  887. }
  888. return out;
  889. }
  890. async function countRevocationsEnacted() {
  891. const all = await listByType('parliamentRevocation');
  892. return all.filter(r => r.status === 'ENACTED').length;
  893. }
  894. async function enactApprovedChanges(expiringTerm) {
  895. if (!expiringTerm) return;
  896. const termId = expiringTerm.id || expiringTerm.startAt;
  897. const ssbClient = await openSsb();
  898. const proposals = await listByType('parliamentProposal');
  899. const revocations = await listByType('parliamentRevocation');
  900. const approvedProps = proposals.filter(p => p.termId === termId && p.status === 'APPROVED');
  901. for (const p of approvedProps) {
  902. const law = {
  903. type: 'parliamentLaw',
  904. question: p.title,
  905. description: p.description || '',
  906. method: p.method,
  907. proposer: p.proposer,
  908. termId: p.termId,
  909. votes: p.votes || (p.voteId ? {} : { YES: 1, NO: 0, ABSTENTION: 0, total: 1 }),
  910. proposedAt: p.createdAt,
  911. proposalId: p.id,
  912. enactedAt: nowISO()
  913. };
  914. await new Promise((resolve, reject) => ssbClient.publish(law, (e, r) => (e ? reject(e) : resolve(r))));
  915. const updated = { ...p, replaces: p.id, status: 'ENACTED', updatedAt: nowISO() };
  916. await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => (e ? reject(e) : resolve(r))));
  917. }
  918. const approvedRevs = revocations.filter(r => r.termId === termId && r.status === 'APPROVED');
  919. for (const r of approvedRevs) {
  920. const tomb = { type: 'tombstone', target: r.lawId, deletedAt: nowISO(), author: userId };
  921. await new Promise((resolve) => ssbClient.publish(tomb, () => resolve()));
  922. const updated = { ...r, replaces: r.id, status: 'ENACTED', updatedAt: nowISO() };
  923. await new Promise((resolve, reject) => ssbClient.publish(updated, (e, rs) => (e ? reject(e) : resolve(rs))));
  924. }
  925. }
  926. async function resolveElection() {
  927. const now = moment();
  928. const current = await getCurrentTermBase();
  929. if (current && now.isBefore(parseISO(current.endAt))) return current;
  930. if (current) {
  931. try { await enactApprovedChanges(current); } catch {}
  932. }
  933. const open = await listCandidaturesOpen();
  934. let chosen = null;
  935. let totalVotes = 0;
  936. let winnerVotes = 0;
  937. if (open.length) {
  938. const pick = await chooseWinnerFromCandidaturesAsync(open);
  939. chosen = pick && pick.chosen;
  940. totalVotes = (pick && pick.totalVotes) || 0;
  941. winnerVotes = (pick && pick.winnerVotes) || 0;
  942. }
  943. const startAt = now.toISOString();
  944. const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
  945. if (!chosen) {
  946. const termAnarchy = {
  947. type: 'parliamentTerm',
  948. method: 'ANARCHY',
  949. powerType: 'none',
  950. powerId: null,
  951. powerTitle: 'ANARCHY',
  952. winnerTribeId: null,
  953. winnerInhabitantId: null,
  954. winnerVotes: 0,
  955. totalVotes: 0,
  956. startAt,
  957. endAt,
  958. createdBy: userId,
  959. createdAt: nowISO()
  960. };
  961. const ssbClient = await openSsb();
  962. const resAnarchy = await new Promise((resolve, reject) =>
  963. ssbClient.publish(termAnarchy, (e, r) => (e ? reject(e) : resolve(r)))
  964. );
  965. try {
  966. await sleep(250);
  967. const canonical = await getCurrentTermBase();
  968. if (canonical && canonical.id !== (resAnarchy.key || resAnarchy.id)) {
  969. const tomb = { type: 'tombstone', target: resAnarchy.key || resAnarchy.id, deletedAt: nowISO(), author: userId };
  970. await new Promise((resolve) => ssbClient.publish(tomb, () => resolve()));
  971. return canonical;
  972. }
  973. } catch {}
  974. await archiveAllCandidatures();
  975. return resAnarchy;
  976. }
  977. const term = {
  978. type: 'parliamentTerm',
  979. method: chosen.method,
  980. powerType: chosen.targetType,
  981. powerId: chosen.targetId,
  982. powerTitle: chosen.targetTitle,
  983. winnerTribeId: chosen.targetType === 'tribe' ? chosen.targetId : null,
  984. winnerInhabitantId: chosen.targetType === 'inhabitant' ? chosen.targetId : null,
  985. winnerVotes,
  986. totalVotes,
  987. startAt,
  988. endAt,
  989. createdBy: userId,
  990. createdAt: nowISO()
  991. };
  992. const ssbClient = await openSsb();
  993. const res = await new Promise((resolve, reject) =>
  994. ssbClient.publish(term, (e, r) => (e ? reject(e) : resolve(r)))
  995. );
  996. try {
  997. await sleep(250);
  998. const canonical = await getCurrentTermBase();
  999. if (canonical && canonical.id !== (res.key || res.id)) {
  1000. const tomb = { type: 'tombstone', target: res.key || res.id, deletedAt: nowISO(), author: userId };
  1001. await new Promise((resolve) => ssbClient.publish(tomb, () => resolve()));
  1002. return canonical;
  1003. }
  1004. } catch {}
  1005. await archiveAllCandidatures();
  1006. return res;
  1007. }
  1008. async function getGovernmentCard() {
  1009. let term = await getCurrentTermBase();
  1010. if (!term) {
  1011. await this.resolveElection();
  1012. term = await getCurrentTermBase();
  1013. }
  1014. if (!term) return null;
  1015. try { await this.sweepProposals(); } catch {}
  1016. const full = await computeGovernmentCard({ ...term, id: term.id || term.startAt });
  1017. return full;
  1018. }
  1019. async function listLaws() {
  1020. const items = await listByType('parliamentLaw');
  1021. return items.sort((a, b) => new Date(b.enactedAt) - new Date(a.enactedAt));
  1022. }
  1023. async function listHistorical() {
  1024. const list = await listTermsBase('expired');
  1025. const out = [];
  1026. for (const t of list) {
  1027. const card = await computeGovernmentCard({ ...t, id: t.id || t.startAt });
  1028. if (card) out.push(card);
  1029. }
  1030. return out;
  1031. }
  1032. async function canPropose() {
  1033. const term = await getCurrentTermBase();
  1034. if (!term) return true;
  1035. if (String(term.method || '').toUpperCase() === 'ANARCHY') return true;
  1036. if (term.powerType === 'inhabitant') return term.powerId === userId;
  1037. if (term.powerType === 'tribe') {
  1038. const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
  1039. const members = ensureArray(tribe?.members);
  1040. return members.includes(userId);
  1041. }
  1042. return false;
  1043. }
  1044. return {
  1045. proposeCandidature,
  1046. voteCandidature,
  1047. resolveElection,
  1048. getGovernmentCard,
  1049. listLaws,
  1050. listHistorical,
  1051. canPropose,
  1052. listProposalsCurrent,
  1053. listFutureLawsCurrent,
  1054. createProposal,
  1055. closeProposal,
  1056. listCandidatures,
  1057. listTerms,
  1058. getCurrentTerm,
  1059. listLeaders,
  1060. sweepProposals,
  1061. getActorMeta,
  1062. createRevocation,
  1063. listRevocationsCurrent,
  1064. listFutureRevocationsCurrent,
  1065. closeRevocation,
  1066. countRevocationsEnacted
  1067. };
  1068. };
  1069. function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
  1070. function collapseOverlappingTerms(terms = []) {
  1071. if (!terms.length) return [];
  1072. const sorted = [...terms].sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
  1073. const groups = [];
  1074. for (const t of sorted) {
  1075. const tStart = new Date(t.startAt).getTime();
  1076. const tEnd = new Date(t.endAt).getTime();
  1077. let placed = false;
  1078. for (const g of groups) {
  1079. if (tStart < g.maxEnd && tEnd > g.minStart) {
  1080. g.items.push(t);
  1081. if (tStart < g.minStart) g.minStart = tStart;
  1082. if (tEnd > g.maxEnd) g.maxEnd = tEnd;
  1083. placed = true;
  1084. break;
  1085. }
  1086. }
  1087. if (!placed) groups.push({ items: [t], minStart: tStart, maxEnd: tEnd });
  1088. }
  1089. const winners = groups.map(g => {
  1090. g.items.sort((a, b) => {
  1091. const aAn = String(a.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
  1092. const bAn = String(b.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
  1093. if (aAn !== bAn) return aAn - bAn;
  1094. const aC = new Date(a.createdAt || a.startAt).getTime();
  1095. const bC = new Date(b.createdAt || b.startAt).getTime();
  1096. if (aC !== bC) return aC - bC;
  1097. const aS = new Date(a.startAt).getTime();
  1098. const bS = new Date(b.startAt).getTime();
  1099. if (aS !== bS) return aS - bS;
  1100. return String(a.id || '').localeCompare(String(b.id || ''));
  1101. });
  1102. return g.items[0];
  1103. });
  1104. return winners.sort((a, b) => new Date(b.startAt) - new Date(a.startAt));
  1105. }