parliament_model.js 54 KB

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