| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373 |
- const pull = require('../server/node_modules/pull-stream');
- const moment = require('../server/node_modules/moment');
- const { getConfig } = require('../configs/config-manager.js');
- const logLimit = getConfig().ssbLogStream?.limit || 1000;
- const TERM_DAYS = 60;
- const PROPOSAL_DAYS = 7;
- const REVOCATION_DAYS = 15;
- const METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY'];
- const FEED_ID_RE = /^@.+\.ed25519$/;
- module.exports = ({ cooler, services = {} }) => {
- let ssb;
- let userId;
- const CACHE_MS = 250;
- let logCache = { at: 0, arr: null };
- let myCache = new Map();
- let electionInFlight = null;
- let sweepInFlight = null;
- const openSsb = async () => {
- if (!ssb) {
- ssb = await cooler.open();
- userId = ssb.id;
- }
- return ssb;
- };
- const nowISO = () => new Date().toISOString();
- const parseISO = (s) => moment(s, moment.ISO_8601, true);
- const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
- const stripId = (obj) => {
- if (!obj || typeof obj !== 'object') return obj;
- const { id, ...rest } = obj;
- return rest;
- };
- const normMs = (t) => (t && t < 1e12 ? t * 1000 : t || 0);
- const isExpiredTerm = (t) => {
- const end = t && t.endAt ? parseISO(t.endAt) : null;
- if (!end || !end.isValid()) return false;
- return moment().isSameOrAfter(end);
- };
- async function publishMsg(content) {
- const ssbClient = await openSsb();
- const res = await new Promise((resolve, reject) =>
- ssbClient.publish(content, (e, r) => (e ? reject(e) : resolve(r)))
- );
- logCache = { at: 0, arr: null };
- myCache.clear();
- return res;
- }
- async function readLog() {
- const now = Date.now();
- if (logCache.arr && now - logCache.at < CACHE_MS) return logCache.arr;
- const ssbClient = await openSsb();
- const arr = await new Promise((res, rej) => {
- pull(
- ssbClient.createLogStream({ limit: logLimit }),
- pull.collect((err, out) => (err ? rej(err) : res(out || [])))
- );
- });
- logCache = { at: now, arr };
- return arr;
- }
- async function readMyByTypes(types = [], limit = logLimit) {
- const ssbClient = await openSsb();
- const key = `${String(userId)}|${String(limit)}|${types.slice().sort().join(',')}`;
- const now = Date.now();
- const hit = myCache.get(key);
- if (hit && hit.arr && now - hit.at < CACHE_MS) return hit.arr;
- const set = new Set(types);
- const arr = await new Promise((res, rej) => {
- pull(
- ssbClient.createUserStream({ id: userId, reverse: true }),
- pull.filter(m => {
- const c = m && m.value && m.value.content;
- return c && set.has(c.type);
- }),
- pull.take(Number(limit) || logLimit),
- pull.collect((err, out) => (err ? rej(err) : res(out || [])))
- );
- });
- myCache.set(key, { at: now, arr });
- return arr;
- }
- function listByTypeFromMsgs(msgs, type) {
- const tomb = new Set();
- const rep = new Map();
- const children = new Map();
- const map = new Map();
- for (const m of msgs || []) {
- const k = m.key;
- const v = m.value || {};
- const c = v.content;
- if (!c) continue;
- if (c.type === 'tombstone' && c.target) tomb.add(c.target);
- if (c.type === type) {
- if (c.replaces) {
- const oldId = c.replaces;
- const ts = normMs(v.timestamp || m.timestamp || Date.now());
- const prev = rep.get(oldId);
- if (!prev || ts > prev.ts) rep.set(oldId, { id: k, ts });
- if (!children.has(oldId)) children.set(oldId, new Set());
- children.get(oldId).add(k);
- }
- map.set(k, { ...c, id: k });
- }
- }
- for (const oldId of rep.keys()) map.delete(oldId);
- for (const [oldId, kids] of children.entries()) {
- const winner = rep.get(oldId)?.id || null;
- for (const kid of kids) {
- if (kid !== winner) map.delete(kid);
- }
- }
- for (const tId of tomb) map.delete(tId);
- return [...map.values()];
- }
- async function listByType(type) {
- const isParl = String(type || '').startsWith('parliament') || type === 'tombstone';
- const msgs = isParl ? await readMyByTypes([type, 'tombstone'], logLimit) : await readLog();
- return listByTypeFromMsgs(msgs, type);
- }
- async function listTribesAny() {
- if (services.tribes?.listAll) return await services.tribes.listAll();
- return await listByType('tribe');
- }
- async function getLatestAboutFromLog(feedId) {
- const msgs = await readLog();
- let latest = null;
- for (let i = msgs.length - 1; i >= 0; i--) {
- const v = msgs[i].value || {};
- const c = v.content || {};
- if (!c || c.type !== 'about') continue;
- const bySelf = v.author === feedId && typeof c.name === 'string';
- const aboutTarget = c.about === feedId && (typeof c.name === 'string' || typeof c.description === 'string' || typeof c.image === 'string');
- if (bySelf || aboutTarget) {
- const ts = normMs(v.timestamp || msgs[i].timestamp || Date.now());
- if (!latest || ts > latest.ts) latest = { ts, content: c };
- }
- }
- return latest ? latest.content : null;
- }
- async function getTribeMetaById(tribeId) {
- let tribe = null;
- if (services.tribes?.getTribeById) {
- try { tribe = await services.tribes.getTribeById(tribeId); } catch {}
- }
- if (!tribe) return { isTribe: true, name: tribeId, avatarUrl: '/assets/images/default-tribe.png', bio: '' };
- const imgId = tribe.image || null;
- const avatarUrl = imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-tribe.png';
- return { isTribe: true, name: tribe.title || tribe.name || tribeId, avatarUrl, bio: tribe.description || '' };
- }
- async function getInhabitantMetaById(feedId) {
- let aboutRec = null;
- if (services.inhabitants?.getLatestAboutById) {
- try { aboutRec = await services.inhabitants.getLatestAboutById(feedId); } catch {}
- }
- if (!aboutRec) {
- try { aboutRec = await getLatestAboutFromLog(feedId); } catch {}
- }
- const name = (aboutRec && typeof aboutRec.name === 'string' && aboutRec.name.trim()) ? aboutRec.name.trim() : feedId;
- const imgField = aboutRec && aboutRec.image;
- const imgId = typeof imgField === 'string' ? imgField : (imgField && (imgField.link || imgField.url)) ? (imgField.link || imgField.url) : null;
- const avatarUrl = imgId ? `/image/256/${encodeURIComponent(imgId)}` : '/assets/images/default-avatar.png';
- const bio = (aboutRec && typeof aboutRec.description === 'string') ? aboutRec.description : '';
- return { isTribe: false, name, avatarUrl, bio };
- }
- async function actorMeta({ targetType, targetId }) {
- const t = String(targetType || '').toLowerCase();
- if (t === 'tribe') return await getTribeMetaById(targetId);
- return await getInhabitantMetaById(targetId);
- }
- async function getInhabitantTitleSSB(feedId) {
- const msgs = await readLog();
- for (let i = msgs.length - 1; i >= 0; i--) {
- const v = msgs[i].value || {};
- const c = v.content || {};
- if (!c || c.type !== 'about') continue;
- if (c.about === feedId && typeof c.name === 'string' && c.name.trim()) return c.name.trim();
- if (v.author === feedId && typeof c.name === 'string' && c.name.trim()) return c.name.trim();
- }
- return null;
- }
- async function findFeedIdByName(name) {
- const q = String(name || '').trim().toLowerCase();
- if (!q) return null;
- const msgs = await readLog();
- let best = null;
- for (let i = msgs.length - 1; i >= 0; i--) {
- const v = msgs[i].value || {};
- const c = v.content || {};
- if (!c || c.type !== 'about' || typeof c.name !== 'string') continue;
- if (c.name.trim().toLowerCase() !== q) continue;
- const fid = typeof c.about === 'string' && FEED_ID_RE.test(c.about) ? c.about : v.author;
- const ts = normMs(v.timestamp || msgs[i].timestamp || Date.now());
- if (!best || ts > best.ts) best = { id: fid, ts };
- }
- return best ? best.id : null;
- }
- async function resolveTarget(candidateInput) {
- const s = String(candidateInput || '').trim();
- if (!s) return null;
- const tribes = await listTribesAny();
- const t = tribes.find(tr =>
- tr.id === s ||
- (tr.title && tr.title.toLowerCase() === s.toLowerCase()) ||
- (tr.name && tr.name.toLowerCase() === s.toLowerCase())
- );
- if (t) {
- return { type: 'tribe', id: t.id, title: t.title || t.name || t.id, members: ensureArray(t.members) };
- }
- if (FEED_ID_RE.test(s)) {
- const title = await getInhabitantTitleSSB(s);
- return { type: 'inhabitant', id: s, title: title || s, members: [] };
- }
- const fid = await findFeedIdByName(s);
- if (fid) {
- const title = await getInhabitantTitleSSB(fid);
- return { type: 'inhabitant', id: fid, title: title || s, members: [] };
- }
- return null;
- }
- function majorityThreshold(total) { return Math.ceil(Number(total || 0) * 0.8); }
- function minorityThreshold(total) { return Math.ceil(Number(total || 0) * 0.2); }
- function democracyThreshold(total) { return Math.floor(Number(total || 0) / 2) + 1; }
- function passesThreshold(method, total, yes) {
- const m = String(method || '').toUpperCase();
- if (m === 'DEMOCRACY' || m === 'ANARCHY') return yes >= democracyThreshold(total);
- if (m === 'MAJORITY') return yes >= majorityThreshold(total);
- if (m === 'MINORITY') return yes >= minorityThreshold(total);
- return false;
- }
- function requiredVotes(method, total) {
- const m = String(method || '').toUpperCase();
- if (m === 'DEMOCRACY' || m === 'ANARCHY') return democracyThreshold(total);
- if (m === 'MAJORITY') return majorityThreshold(total);
- if (m === 'MINORITY') return minorityThreshold(total);
- return 0;
- }
- async function listCandidaturesOpenRaw() {
- const all = await listByType('parliamentCandidature');
- const filtered = all.filter(c => (c.status || 'OPEN') === 'OPEN');
- return filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
- }
- async function getFirstUserTimestamp(feedId) {
- const ssbClient = await openSsb();
- return new Promise((resolve) => {
- pull(
- ssbClient.createUserStream({ id: feedId, reverse: false }),
- pull.filter(m => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
- pull.take(1),
- pull.collect((err, arr) => {
- if (err || !arr || !arr.length) return resolve(Date.now());
- const m = arr[0];
- const ts = normMs((m.value && m.value.timestamp) || m.timestamp);
- resolve(ts || Date.now());
- })
- );
- });
- }
- async function getInhabitantKarma(feedId) {
- if (services.banking?.getUserEngagementScore) {
- try { return Number(await services.banking.getUserEngagementScore(feedId)) || 0; } catch { return 0; }
- }
- return 0;
- }
- async function getTribeSince(tribeId) {
- if (services.tribes?.getTribeById) {
- try {
- const t = await services.tribes.getTribeById(tribeId);
- if (t?.createdAt) return new Date(t.createdAt).getTime();
- } catch {}
- }
- return Date.now();
- }
- async function listCandidaturesOpen() {
- const rows = await listCandidaturesOpenRaw();
- const enriched = await Promise.all(rows.map(async c => {
- if (c.targetType === 'inhabitant') {
- const karma = await getInhabitantKarma(c.targetId);
- const since = await getFirstUserTimestamp(c.targetId);
- return { ...c, karma, profileSince: since };
- } else {
- const since = await getTribeSince(c.targetId);
- return { ...c, karma: 0, profileSince: since };
- }
- }));
- return enriched;
- }
- async function listTermsBase(filter = 'all') {
- const all = await listByType('parliamentTerm');
- const collapsed = collapseOverlappingTerms(all);
- let arr = collapsed.map(t => ({ ...t, status: isExpiredTerm(t) ? 'EXPIRED' : 'ACTIVE' }));
- if (filter === 'active') arr = arr.filter(t => t.status === 'ACTIVE');
- if (filter === 'expired') arr = arr.filter(t => t.status === 'EXPIRED');
- return arr.sort((a, b) => new Date(b.startAt) - new Date(a.startAt));
- }
- async function getLatestTermAny() {
- const msgs = await readMyByTypes(['parliamentTerm', 'tombstone'], Math.max(50, Math.min(500, logLimit)));
- const terms = listByTypeFromMsgs(msgs, 'parliamentTerm');
- const collapsed = collapseOverlappingTerms(terms);
- return collapsed[0] || null;
- }
- async function getCurrentTermBase() {
- const t = await getLatestTermAny();
- if (!t) return null;
- return isExpiredTerm(t) ? null : t;
- }
- function currentCycleStart(term) {
- return term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString();
- }
- async function archiveAllCandidatures() {
- const all = await listCandidaturesOpenRaw();
- for (const c of all) {
- const tomb = { type: 'tombstone', target: c.id, deletedAt: nowISO(), author: userId };
- await publishMsg(tomb);
- }
- }
- async function chooseWinnerFromCandidaturesAsync(cands) {
- if (!cands.length) return null;
- const norm = cands.map(c => ({
- ...c,
- votes: Number(c.votes || 0),
- karma: Number(c.karma || 0),
- since: Number(c.profileSince || 0),
- createdAtMs: new Date(c.createdAt).getTime() || 0
- }));
- const totalVotes = norm.reduce((s, c) => s + c.votes, 0);
- if (totalVotes > 0) {
- const maxVotes = Math.max(...norm.map(c => c.votes));
- let tied = norm.filter(c => c.votes === maxVotes);
- if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
- const maxKarma = Math.max(...tied.map(c => c.karma));
- tied = tied.filter(c => c.karma === maxKarma);
- if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
- tied.sort((a, b) => (a.since || 0) - (b.since || 0));
- const oldestSince = tied[0].since || 0;
- tied = tied.filter(c => c.since === oldestSince);
- if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
- tied.sort((a, b) => a.createdAtMs - b.createdAtMs);
- const earliest = tied[0].createdAtMs;
- tied = tied.filter(c => c.createdAtMs === earliest);
- if (tied.length === 1) return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
- tied.sort((a, b) => String(a.targetId).localeCompare(String(b.targetId)));
- return { chosen: tied[0], totalVotes, winnerVotes: maxVotes };
- }
- const latest = norm.sort((a, b) => b.createdAtMs - a.createdAtMs)[0];
- return { chosen: latest, totalVotes: 0, winnerVotes: 0 };
- }
- async function summarizePoliciesForTerm(termOrId) {
- let termId = null;
- if (termOrId && typeof termOrId === 'object') {
- termId = termOrId.id || termOrId.startAt;
- } else {
- termId = termOrId;
- }
- const proposals = await listByType('parliamentProposal');
- const mine = termId ? proposals.filter(p => p.termId === termId) : [];
- const proposed = mine.length;
- let approved = 0;
- let declined = 0;
- let discarded = 0;
- for (const p of mine) {
- const baseStatus = String(p.status || 'OPEN').toUpperCase();
- let finalStatus = baseStatus;
- let isDiscarded = false;
- if (p.voteId && services.votes?.getVoteById) {
- try {
- const v = await services.votes.getVoteById(p.voteId);
- const votesMap = v.votes || {};
- const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
- const total = Number(v.totalVotes ?? v.total ?? sum);
- const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
- const dl = v.deadline || v.endAt || v.expiresAt || null;
- const closed = v.status === 'CLOSED' || (dl && moment(dl).isBefore(moment()));
- const reached = passesThreshold(p.method, total, yes);
- if (!closed) {
- if (dl && moment(dl).isBefore(moment()) && !reached) isDiscarded = true;
- else finalStatus = 'OPEN';
- } else {
- finalStatus = reached ? 'APPROVED' : 'REJECTED';
- }
- } catch {}
- } else {
- if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) isDiscarded = true;
- }
- if (isDiscarded) {
- discarded++;
- continue;
- }
- if (finalStatus === 'ENACTED' || finalStatus === 'APPROVED') {
- approved++;
- continue;
- }
- if (finalStatus === 'REJECTED') {
- declined++;
- continue;
- }
- }
- const revs = await listByType('parliamentRevocation');
- const revocated = termId
- ? revs.filter(r => r.status === 'ENACTED' && r.termId === termId).length
- : 0;
- return { proposed, approved, declined, discarded, revocated };
- }
- async function computeGovernmentCard(term) {
- if (!term) return null;
- const method = term.method || 'DEMOCRACY';
- const isTribe = term.powerType === 'tribe';
- let members = 1;
- if (isTribe && term.powerId) {
- const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
- members = tribe && Array.isArray(tribe.members) ? tribe.members.length : 0;
- }
- const pol = await summarizePoliciesForTerm({ ...term });
- const eff = pol.proposed > 0 ? Math.round((pol.approved / pol.proposed) * 100) : 0;
- return {
- method,
- powerType: term.powerType,
- powerId: term.powerId,
- powerTitle: term.powerTitle,
- votesReceived: term.winnerVotes || 0,
- totalVotes: term.totalVotes || 0,
- members,
- since: term.startAt,
- end: term.endAt,
- proposed: pol.proposed,
- approved: pol.approved,
- declined: pol.declined,
- discarded: pol.discarded,
- revocated: pol.revocated,
- efficiency: eff
- };
- }
- async function countMyProposalsThisTerm(term) {
- const termId = term.id || term.startAt;
- const proposals = await listByType('parliamentProposal');
- const laws = await listByType('parliamentLaw');
- const nProp = proposals.filter(p => p.termId === termId && p.proposer === userId).length;
- const nLaw = laws.filter(l => l.termId === termId && l.proposer === userId).length;
- return nProp + nLaw;
- }
- async function closeExpiredKarmatocracy(term) {
- const termId = term.id || term.startAt;
- const all = await listByType('parliamentProposal');
- const pending = all.filter(p =>
- p.termId === termId &&
- String(p.method).toUpperCase() === 'KARMATOCRACY' &&
- (p.status || 'OPEN') !== 'ENACTED' &&
- p.deadline && moment().isAfter(parseISO(p.deadline))
- );
- if (!pending.length) return;
- const withKarma = await Promise.all(pending.map(async p => ({
- ...p,
- karma: await getInhabitantKarma(p.proposer),
- createdAtMs: new Date(p.createdAt).getTime() || 0
- })));
- withKarma.sort((a, b) => {
- if (b.karma !== a.karma) return b.karma - a.karma;
- if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
- return String(a.proposer).localeCompare(String(b.proposer));
- });
- const winner = withKarma[0];
- const losers = withKarma.slice(1);
- const approve = { ...stripId(winner), replaces: winner.id, status: 'APPROVED', updatedAt: nowISO() };
- await publishMsg(approve);
- for (const lo of losers) {
- const rej = { ...stripId(lo), replaces: lo.id, status: 'REJECTED', updatedAt: nowISO() };
- await publishMsg(rej);
- }
- }
- async function closeExpiredDictatorship(term) {
- const termId = term.id || term.startAt;
- const all = await listByType('parliamentProposal');
- const pending = all.filter(p =>
- p.termId === termId &&
- String(p.method).toUpperCase() === 'DICTATORSHIP' &&
- (p.status || 'OPEN') !== 'ENACTED' &&
- p.deadline && moment().isAfter(parseISO(p.deadline))
- );
- if (!pending.length) return;
- for (const p of pending) {
- const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
- await publishMsg(updated);
- }
- }
- async function closeExpiredRevocationKarmatocracy(term) {
- const termId = term.id || term.startAt;
- const all = await listByType('parliamentRevocation');
- const pending = all.filter(p =>
- p.termId === termId &&
- String(p.method).toUpperCase() === 'KARMATOCRACY' &&
- (p.status || 'OPEN') !== 'ENACTED' &&
- p.deadline && moment().isAfter(parseISO(p.deadline))
- );
- if (!pending.length) return;
- for (const p of pending) {
- const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
- await publishMsg(updated);
- }
- }
- async function closeExpiredRevocationDictatorship(term) {
- const termId = term.id || term.startAt;
- const all = await listByType('parliamentRevocation');
- const pending = all.filter(p =>
- p.termId === termId &&
- String(p.method).toUpperCase() === 'DICTATORSHIP' &&
- (p.status || 'OPEN') !== 'ENACTED' &&
- p.deadline && moment().isAfter(parseISO(p.deadline))
- );
- if (!pending.length) return;
- for (const p of pending) {
- const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
- await publishMsg(updated);
- }
- }
- async function createRevocation({ lawId, title, reasons }) {
- const term = await getCurrentTermBase();
- if (!term) throw new Error('No active government');
- const allowed = await canPropose();
- if (!allowed) throw new Error('You are not in the goverment, yet.');
- const lawIdStr = String(lawId || '').trim();
- if (!lawIdStr) throw new Error('Law required');
- const laws = await listByType('parliamentLaw');
- const law = laws.find(l => l.id === lawIdStr);
- if (!law) throw new Error('Law not found');
- const method = String(term.method || 'DEMOCRACY').toUpperCase();
- const deadline = moment().add(REVOCATION_DAYS, 'days').toISOString();
- if (method === 'DICTATORSHIP' || method === 'KARMATOCRACY') {
- const rev = {
- type: 'parliamentRevocation',
- lawId: lawIdStr,
- title: title || law.question || '',
- reasons: reasons || '',
- method,
- termId: term.id || term.startAt,
- proposer: userId,
- status: 'OPEN',
- deadline,
- createdAt: nowISO()
- };
- return await publishMsg(rev);
- }
- const voteMsg = await services.votes.createVote(
- `Revoke: ${title || law.question || ''}`,
- deadline,
- ['YES', 'NO', 'ABSTENTION'],
- [`gov:${term.id || term.startAt}`, `govMethod:${method}`, 'revocation']
- );
- const rev = {
- type: 'parliamentRevocation',
- lawId: lawIdStr,
- title: title || law.question || '',
- reasons: reasons || '',
- method,
- voteId: voteMsg.key || voteMsg.id,
- termId: term.id || term.startAt,
- proposer: userId,
- status: 'OPEN',
- createdAt: nowISO()
- };
- return await publishMsg(rev);
- }
- async function closeRevocation(revId) {
- const ssbClient = await openSsb();
- const msg = await new Promise((resolve, reject) =>
- ssbClient.get(revId, (e, m) => (e || !m) ? reject(new Error('Revocation not found')) : resolve(m))
- );
- if (msg.content?.type !== 'parliamentRevocation') throw new Error('Revocation not found');
- const p = msg.content;
- const currentStatus = String(p.status || 'OPEN').toUpperCase();
- if (currentStatus === 'ENACTED' || currentStatus === 'REJECTED' || currentStatus === 'DISCARDED') return p;
- const method = String(p.method || '').toUpperCase();
- if (method === 'DICTATORSHIP') {
- if (currentStatus === 'APPROVED') return p;
- const updated = { ...p, replaces: revId, status: 'APPROVED', updatedAt: nowISO() };
- await publishMsg(updated);
- return updated;
- }
- if (method === 'KARMATOCRACY') return p;
- const v = await services.votes.getVoteById(p.voteId);
- const votesMap = v.votes || {};
- const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
- const total = Number(v.totalVotes ?? v.total ?? sum);
- const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
- let ok = false;
- if (method === 'DEMOCRACY' || method === 'ANARCHY') ok = yes >= democracyThreshold(total);
- else if (method === 'MAJORITY') ok = yes >= majorityThreshold(total);
- else if (method === 'MINORITY') ok = yes >= minorityThreshold(total);
- const desiredStatus = ok ? 'APPROVED' : 'REJECTED';
- if (currentStatus === desiredStatus) return p;
- const updated = { ...p, replaces: revId, status: desiredStatus, updatedAt: nowISO() };
- await publishMsg(updated);
- return updated;
- }
- async function proposeCandidature({ candidateId, method }) {
- const m = String(method || '').toUpperCase();
- if (!METHODS.includes(m)) throw new Error('Invalid method');
- const target = await resolveTarget(candidateId);
- if (!target) throw new Error('Candidate not found');
- const term = await getCurrentTermBase();
- const since = currentCycleStart(term);
- const myAll = await listByType('parliamentCandidature');
- const mineThisCycle = myAll.filter(c => c.proposer === userId && new Date(c.createdAt) >= new Date(since));
- if (mineThisCycle.length >= 3) throw new Error('Candidate limit reached');
- const open = await listCandidaturesOpenRaw();
- const duplicate = open.find(c => c.targetType === target.type && c.targetId === target.id && new Date(c.createdAt) >= new Date(since));
- if (duplicate) throw new Error('Candidate already proposed this cycle');
- const content = {
- type: 'parliamentCandidature',
- targetType: target.type,
- targetId: target.id,
- targetTitle: target.title,
- method: m,
- votes: 0,
- voters: [],
- proposer: userId,
- status: 'OPEN',
- createdAt: nowISO()
- };
- return await publishMsg(content);
- }
- async function voteCandidature(candidatureMsgId) {
- const ssbClient = await openSsb();
- const open = await listCandidaturesOpenRaw();
- const already = open.some(c => ensureArray(c.voters).includes(userId));
- if (already) throw new Error('Already voted this cycle');
- const msg = await new Promise((resolve, reject) =>
- ssbClient.get(candidatureMsgId, (e, m) => (e || !m) ? reject(new Error('Candidate not found')) : resolve(m))
- );
- if (msg.content?.type !== 'parliamentCandidature') throw new Error('Candidate not found');
- const c = msg.content;
- if ((c.status || 'OPEN') !== 'OPEN') throw new Error('Closed');
- const updated = { ...c, replaces: candidatureMsgId, votes: Number(c.votes || 0) + 1, voters: [...ensureArray(c.voters), userId], updatedAt: nowISO() };
- return await publishMsg(updated);
- }
- async function createProposal({ title, description }) {
- let term = await getCurrentTermBase();
- if (!term) {
- await resolveElection();
- term = await getCurrentTermBase();
- }
- if (!term) throw new Error('No active government');
- const allowed = await canPropose();
- if (!allowed) throw new Error('You are not in the goverment, yet.');
- if (!title || !title.trim()) throw new Error('Title required');
- if (String(description || '').length > 1000) throw new Error('Description too long');
- const used = await countMyProposalsThisTerm(term);
- if (used >= 3) throw new Error('Proposal limit reached');
- const method = String(term.method || 'DEMOCRACY').toUpperCase();
- const deadline = moment().add(PROPOSAL_DAYS, 'days').toISOString();
- if (method === 'DICTATORSHIP' || method === 'KARMATOCRACY') {
- const proposal = { type: 'parliamentProposal', title, description: description || '', method, termId: term.id || term.startAt, proposer: userId, status: 'OPEN', deadline, createdAt: nowISO() };
- return await publishMsg(proposal);
- }
- const voteMsg = await services.votes.createVote(title, deadline, ['YES', 'NO', 'ABSTENTION'], [`gov:${term.id || term.startAt}`, `govMethod:${method}`, 'proposal']);
- const proposal = { type: 'parliamentProposal', title, description: description || '', method, voteId: voteMsg.key || voteMsg.id, termId: term.id || term.startAt, proposer: userId, status: 'OPEN', createdAt: nowISO() };
- return await publishMsg(proposal);
- }
- async function closeProposal(proposalId) {
- const ssbClient = await openSsb();
- const msg = await new Promise((resolve, reject) =>
- ssbClient.get(proposalId, (e, m) => (e || !m) ? reject(new Error('Proposal not found')) : resolve(m))
- );
- if (msg.content?.type !== 'parliamentProposal') throw new Error('Proposal not found');
- const p = msg.content;
- const currentStatus = String(p.status || 'OPEN').toUpperCase();
- if (currentStatus === 'ENACTED' || currentStatus === 'REJECTED' || currentStatus === 'DISCARDED') return p;
- const method = String(p.method || '').toUpperCase();
- if (method === 'DICTATORSHIP') {
- if (currentStatus === 'APPROVED') return p;
- const updated = { ...p, replaces: proposalId, status: 'APPROVED', updatedAt: nowISO() };
- await publishMsg(updated);
- return updated;
- }
- if (method === 'KARMATOCRACY') return p;
- const v = await services.votes.getVoteById(p.voteId);
- const votesMap = v.votes || {};
- const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
- const total = Number(v.totalVotes ?? v.total ?? sum);
- const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
- let ok = false;
- if (method === 'DEMOCRACY' || method === 'ANARCHY') ok = yes >= democracyThreshold(total);
- else if (method === 'MAJORITY') ok = yes >= majorityThreshold(total);
- else if (method === 'MINORITY') ok = yes >= minorityThreshold(total);
- const desiredStatus = ok ? 'APPROVED' : 'REJECTED';
- if (currentStatus === desiredStatus) return p;
- const updated = { ...p, replaces: proposalId, status: desiredStatus, updatedAt: nowISO() };
- await publishMsg(updated);
- return updated;
- }
- async function sweepProposals() {
- if (sweepInFlight) return sweepInFlight;
- sweepInFlight = (async () => {
- const term = await getCurrentTermBase();
- if (!term) return;
- await closeExpiredKarmatocracy(term);
- await closeExpiredDictatorship(term);
- const allProps = await listByType('parliamentProposal');
- const voteProps = allProps.filter(p => {
- const m = String(p.method || '').toUpperCase();
- return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId;
- });
- for (const p of voteProps) {
- try {
- const v = await services.votes.getVoteById(p.voteId);
- const votesMap = v.votes || {};
- const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
- const total = Number(v.totalVotes ?? v.total ?? sum);
- const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
- const deadline = v.deadline || v.endAt || v.expiresAt || null;
- const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
- if (closed) { try { await closeProposal(p.id); } catch {} ; continue; }
- if ((p.status || 'OPEN') === 'OPEN' && passesThreshold(p.method, total, yes)) {
- const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
- await publishMsg(updated);
- }
- } catch {}
- }
- await closeExpiredRevocationKarmatocracy(term);
- await closeExpiredRevocationDictatorship(term);
- const revs = await listByType('parliamentRevocation');
- const voteRevs = revs.filter(p => {
- const m = String(p.method || '').toUpperCase();
- return (m === 'DEMOCRACY' || m === 'ANARCHY' || m === 'MAJORITY' || m === 'MINORITY') && p.voteId;
- });
- for (const p of voteRevs) {
- try {
- const v = await services.votes.getVoteById(p.voteId);
- const votesMap = v.votes || {};
- const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
- const total = Number(v.totalVotes ?? v.total ?? sum);
- const yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
- const deadline = v.deadline || v.endAt || v.expiresAt || null;
- const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
- if (closed) { try { await closeRevocation(p.id); } catch {} ; continue; }
- if ((p.status || 'OPEN') === 'OPEN' && passesThreshold(p.method, total, yes)) {
- const updated = { ...stripId(p), replaces: p.id, status: 'APPROVED', updatedAt: nowISO() };
- await publishMsg(updated);
- }
- } catch {}
- }
- })().finally(() => { sweepInFlight = null; });
- return sweepInFlight;
- }
- async function getActorMeta({ targetType, targetId }) {
- return await actorMeta({ targetType, targetId });
- }
- async function listCandidatures(filter = 'OPEN') {
- if (filter === 'OPEN') return await listCandidaturesOpen();
- const all = await listByType('parliamentCandidature');
- return all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
- }
- async function listTerms(filter = 'all') {
- return await listTermsBase(filter);
- }
- async function getCurrentTerm() {
- return await getCurrentTermBase();
- }
- async function listLeaders() {
- const terms = await listTermsBase('all');
- const map = new Map();
- for (const t of terms) {
- if (!(t.powerType === 'tribe' || t.powerType === 'inhabitant')) continue;
- const k = `${t.powerType}:${t.powerId}`;
- 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 });
- const rec = map.get(k);
- rec.inPower += 1;
- const sum = await summarizePoliciesForTerm(t);
- rec.proposed += sum.proposed;
- rec.approved += sum.approved;
- rec.declined += sum.declined;
- rec.discarded += sum.discarded;
- rec.revocated += sum.revocated;
- }
- const cands = await listByType('parliamentCandidature');
- for (const c of cands) {
- const k = `${c.targetType}:${c.targetId}`;
- 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 });
- const rec = map.get(k);
- rec.presented = (rec.presented || 0) + 1;
- }
- const rows = [...map.values()].map(r => ({ ...r, presented: r.presented || 0, efficiency: (r.proposed > 0 ? r.approved / r.proposed : 0) }));
- rows.sort((a, b) => {
- if (b.approved !== a.approved) return b.approved - a.approved;
- if ((b.efficiency || 0) !== (a.efficiency || 0)) return (b.efficiency || 0) - (a.efficiency || 0);
- if (b.inPower !== a.inPower) return b.inPower - a.inPower;
- if (b.proposed !== a.proposed) return b.proposed - a.proposed;
- return String(a.powerId).localeCompare(String(b.powerId));
- });
- return rows;
- }
- async function listProposalsCurrent() {
- const all = await listByType('parliamentProposal');
- const rows = all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
- const out = [];
- for (const p of rows) {
- let deadline = p.deadline || null;
- let yes = 0;
- let total = 0;
- const baseStatus = String(p.status || 'OPEN').toUpperCase();
- let derivedStatus = baseStatus;
- if (p.voteId && services.votes?.getVoteById) {
- try {
- const v = await services.votes.getVoteById(p.voteId);
- const votesMap = v.votes || {};
- const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
- total = Number(v.totalVotes ?? v.total ?? sum);
- yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
- deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
- const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
- const reached = passesThreshold(p.method, total, yes);
- if (closed) derivedStatus = reached ? 'APPROVED' : 'REJECTED';
- else derivedStatus = 'OPEN';
- } catch {
- derivedStatus = baseStatus;
- }
- } else {
- if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) derivedStatus = 'DISCARDED';
- }
- if (derivedStatus === 'ENACTED' || derivedStatus === 'REJECTED' || derivedStatus === 'DISCARDED') continue;
- const needed = requiredVotes(p.method, total);
- const onTrack = passesThreshold(p.method, total, yes);
- out.push({ ...p, deadline, yes, total, needed, onTrack, derivedStatus });
- }
- return out;
- }
- async function listFutureLawsCurrent() {
- const term = await getCurrentTermBase();
- if (!term) return [];
- const termId = term.id || term.startAt;
- const all = await listByType('parliamentProposal');
- const rows = all
- .filter(p => p.termId === termId)
- .filter(p => p.status === 'APPROVED')
- .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
- const out = [];
- for (const p of rows) {
- let yes = 0;
- let total = 0;
- let deadline = p.deadline || null;
- let voteClosed = true;
- if (p.voteId && services.votes?.getVoteById) {
- try {
- const v = await services.votes.getVoteById(p.voteId);
- const votesMap = v.votes || {};
- const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
- total = Number(v.totalVotes ?? v.total ?? sum);
- yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
- deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
- voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
- if (!voteClosed) continue;
- } catch {}
- }
- const needed = requiredVotes(p.method, total);
- out.push({ ...p, deadline, yes, total, needed });
- }
- return out;
- }
- async function listRevocationsCurrent() {
- const all = await listByType('parliamentRevocation');
- const rows = all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
- const out = [];
- for (const p of rows) {
- let deadline = p.deadline || null;
- let yes = 0;
- let total = 0;
- const baseStatus = String(p.status || 'OPEN').toUpperCase();
- let derivedStatus = baseStatus;
- if (p.voteId && services.votes?.getVoteById) {
- try {
- const v = await services.votes.getVoteById(p.voteId);
- const votesMap = v.votes || {};
- const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
- total = Number(v.totalVotes ?? v.total ?? sum);
- yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
- deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
- const closed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
- const reached = passesThreshold(p.method, total, yes);
- if (closed) derivedStatus = reached ? 'APPROVED' : 'REJECTED';
- else derivedStatus = 'OPEN';
- } catch {
- derivedStatus = baseStatus;
- }
- } else {
- if (baseStatus === 'OPEN' && p.deadline && moment(p.deadline).isBefore(moment())) derivedStatus = 'DISCARDED';
- }
- if (derivedStatus === 'ENACTED' || derivedStatus === 'REJECTED' || derivedStatus === 'DISCARDED') continue;
- const needed = requiredVotes(p.method, total);
- const onTrack = passesThreshold(p.method, total, yes);
- out.push({ ...p, deadline, yes, total, needed, onTrack, derivedStatus });
- }
- return out;
- }
- async function listFutureRevocationsCurrent() {
- const term = await getCurrentTermBase();
- if (!term) return [];
- const termId = term.id || term.startAt;
- const all = await listByType('parliamentRevocation');
- const rows = all
- .filter(p => p.termId === termId)
- .filter(p => p.status === 'APPROVED')
- .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
- const out = [];
- for (const p of rows) {
- let yes = 0;
- let total = 0;
- let deadline = p.deadline || null;
- let voteClosed = true;
- if (p.voteId && services.votes?.getVoteById) {
- try {
- const v = await services.votes.getVoteById(p.voteId);
- const votesMap = v.votes || {};
- const sum = Object.values(votesMap).reduce((s, n) => s + Number(n || 0), 0);
- total = Number(v.totalVotes ?? v.total ?? sum);
- yes = Number(votesMap.YES ?? votesMap.Yes ?? votesMap.yes ?? 0);
- deadline = deadline || v.deadline || v.endAt || v.expiresAt || null;
- voteClosed = v.status === 'CLOSED' || (deadline && moment(deadline).isBefore(moment()));
- if (!voteClosed) continue;
- } catch {}
- }
- const needed = requiredVotes(p.method, total);
- out.push({ ...p, deadline, yes, total, needed });
- }
- return out;
- }
- async function countRevocationsEnacted() {
- const all = await listByType('parliamentRevocation');
- return all.filter(r => r.status === 'ENACTED').length;
- }
- async function voteSnapshot(voteId) {
- if (!voteId || !services.votes?.getVoteById) return null;
- try {
- const v = await services.votes.getVoteById(voteId);
- const vm = v?.votes || {};
- const sum = Object.values(vm).reduce((s, n) => s + Number(n || 0), 0);
- const total = Number(v.totalVotes ?? v.total ?? sum);
- return {
- YES: Number(vm.YES ?? vm.Yes ?? vm.yes ?? 0),
- NO: Number(vm.NO ?? vm.No ?? vm.no ?? 0),
- ABSTENTION: Number(vm.ABSTENTION ?? vm.Abstention ?? vm.abstention ?? 0),
- total
- };
- } catch {
- return null;
- }
- }
- async function enactApprovedChanges(expiringTerm) {
- if (!expiringTerm) return;
- const termId = expiringTerm.id || expiringTerm.startAt;
- const proposals = await listByType('parliamentProposal');
- const revocations = await listByType('parliamentRevocation');
- const approvedProps = proposals.filter(p => p.termId === termId && p.status === 'APPROVED');
- for (const p of approvedProps) {
- const snap = await voteSnapshot(p.voteId);
- const votesFinal =
- (p.votes && Object.keys(p.votes).length ? p.votes : null) ||
- snap ||
- { YES: 1, NO: 0, ABSTENTION: 0, total: 1 };
- const law = {
- type: 'parliamentLaw',
- question: p.title,
- description: p.description || '',
- method: p.method,
- proposer: p.proposer,
- termId: p.termId,
- voteId: p.voteId || null,
- votes: votesFinal,
- proposedAt: p.createdAt,
- proposalId: p.id,
- enactedAt: nowISO()
- };
- await publishMsg(law);
- const updated = { ...stripId(p), replaces: p.id, status: 'ENACTED', updatedAt: nowISO() };
- await publishMsg(updated);
- }
- const approvedRevs = revocations.filter(r => r.termId === termId && r.status === 'APPROVED');
- for (const r of approvedRevs) {
- const tomb = { type: 'tombstone', target: r.lawId, deletedAt: nowISO(), author: userId };
- await publishMsg(tomb);
- const snap = await voteSnapshot(r.voteId);
- const updated = {
- ...stripId(r),
- replaces: r.id,
- status: 'ENACTED',
- votes: (r.votes && Object.keys(r.votes).length ? r.votes : null) || snap || undefined,
- updatedAt: nowISO()
- };
- await publishMsg(updated);
- }
- }
- async function resolveElectionImpl() {
- const now = moment();
- const latestAny = await getLatestTermAny();
- if (latestAny && !isExpiredTerm(latestAny)) return latestAny;
- if (latestAny && isExpiredTerm(latestAny)) {
- try { await enactApprovedChanges(latestAny); } catch {}
- }
- const open = await listCandidaturesOpen();
- let chosen = null;
- let totalVotes = 0;
- let winnerVotes = 0;
- if (open.length) {
- const pick = await chooseWinnerFromCandidaturesAsync(open);
- chosen = pick && pick.chosen;
- totalVotes = (pick && pick.totalVotes) || 0;
- winnerVotes = (pick && pick.winnerVotes) || 0;
- }
- const startAt = now.toISOString();
- const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
- if (!chosen) {
- const termAnarchy = {
- type: 'parliamentTerm',
- method: 'ANARCHY',
- powerType: 'none',
- powerId: null,
- powerTitle: 'ANARCHY',
- winnerTribeId: null,
- winnerInhabitantId: null,
- winnerVotes: 0,
- totalVotes: 0,
- startAt,
- endAt,
- createdBy: userId,
- createdAt: nowISO()
- };
- const resAnarchy = await publishMsg(termAnarchy);
- try {
- await sleep(250);
- const canonical = await getCurrentTermBase();
- const myId = resAnarchy.key || resAnarchy.id;
- if (canonical && canonical.id && myId && canonical.id !== myId) {
- const tomb = { type: 'tombstone', target: myId, deletedAt: nowISO(), author: userId };
- await publishMsg(tomb);
- await archiveAllCandidatures();
- return canonical;
- }
- } catch {}
- await archiveAllCandidatures();
- return resAnarchy;
- }
- const term = {
- type: 'parliamentTerm',
- method: chosen.method,
- powerType: chosen.targetType,
- powerId: chosen.targetId,
- powerTitle: chosen.targetTitle,
- winnerTribeId: chosen.targetType === 'tribe' ? chosen.targetId : null,
- winnerInhabitantId: chosen.targetType === 'inhabitant' ? chosen.targetId : null,
- winnerVotes,
- totalVotes,
- startAt,
- endAt,
- createdBy: userId,
- createdAt: nowISO()
- };
- const res = await publishMsg(term);
- try {
- await sleep(250);
- const canonical = await getCurrentTermBase();
- const myId = res.key || res.id;
- if (canonical && canonical.id && myId && canonical.id !== myId) {
- const tomb = { type: 'tombstone', target: myId, deletedAt: nowISO(), author: userId };
- await publishMsg(tomb);
- await archiveAllCandidatures();
- return canonical;
- }
- } catch {}
- await archiveAllCandidatures();
- return res;
- }
- async function resolveElection() {
- if (electionInFlight) return electionInFlight;
- electionInFlight = resolveElectionImpl().finally(() => { electionInFlight = null; });
- return electionInFlight;
- }
- async function getGovernmentCard() {
- const term = await getCurrentTermBase();
- if (!term) return null;
- return await computeGovernmentCard({ ...term, id: term.id || term.startAt });
- }
- async function listLaws() {
- const items = await listByType('parliamentLaw');
- return items.sort((a, b) => new Date(b.enactedAt) - new Date(a.enactedAt));
- }
- async function listHistorical() {
- const list = await listTermsBase('expired');
- const out = [];
- for (const t of list) {
- const card = await computeGovernmentCard({ ...t, id: t.id || t.startAt });
- if (card) out.push(card);
- }
- return out;
- }
- async function canPropose() {
- const term = await getCurrentTermBase();
- if (!term) return true;
- if (String(term.method || '').toUpperCase() === 'ANARCHY') return true;
- if (term.powerType === 'inhabitant') return term.powerId === userId;
- if (term.powerType === 'tribe') {
- const tribe = services.tribes ? await services.tribes.getTribeById(term.powerId) : null;
- const members = ensureArray(tribe?.members);
- return members.includes(userId);
- }
- return false;
- }
- const tribeReadLog = async () => {
- const client = await openSsb();
- return new Promise((resolve, reject) => {
- pull(
- client.createLogStream({ limit: logLimit }),
- pull.collect((err, msgs) => err ? reject(err) : resolve(msgs || []))
- );
- });
- };
- const tribeListByType = async (type, tribeId) => {
- const msgs = await tribeReadLog();
- const tomb = new Set();
- const replaced = new Set();
- const items = new Map();
- for (const m of msgs) {
- const c = m.value?.content; if (!c) continue;
- if (c.type === 'tombstone' && c.target) { tomb.add(c.target); continue; }
- if (c.type !== type) continue;
- if (c.tribeId !== tribeId) continue;
- if (c.replaces) replaced.add(c.replaces);
- items.set(m.key, { ...c, id: m.key, _ts: m.value?.timestamp || 0 });
- }
- return [...items.values()].filter(it => !tomb.has(it.id) && !replaced.has(it.id));
- };
- const tribeGetCurrentTerm = async (tribeId) => {
- const terms = await tribeListByType('tribeParliamentTerm', tribeId);
- if (terms.length === 0) return null;
- const now = moment();
- const active = terms.find(t => moment(t.startAt).isSameOrBefore(now) && moment(t.endAt).isAfter(now));
- if (active) return active;
- terms.sort((a, b) => String(b.startAt).localeCompare(String(a.startAt)));
- return terms[0] || null;
- };
- const tribeListCandidatures = (tribeId) => tribeListByType('tribeParliamentCandidature', tribeId);
- const tribeListRules = (tribeId) => tribeListByType('tribeParliamentRule', tribeId);
- const tribePublishCandidature = async ({ tribeId, candidateId, method }) => {
- const m = String(method || '').toUpperCase();
- if (!METHODS.includes(m)) throw new Error('Invalid method');
- if (!tribeId) throw new Error('Missing tribeId');
- if (!candidateId) throw new Error('Missing candidateId');
- const term = await tribeGetCurrentTerm(tribeId);
- const since = term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString();
- const existing = await tribeListCandidatures(tribeId);
- const dupe = existing.find(c => c.candidateId === candidateId && new Date(c.createdAt) >= new Date(since) && (c.status || 'OPEN') === 'OPEN');
- if (dupe) throw new Error('Candidate already proposed this cycle');
- const client = await openSsb();
- const content = {
- type: 'tribeParliamentCandidature',
- tribeId, candidateId, method: m,
- votes: 0, voters: [], proposer: client.id,
- status: 'OPEN', createdAt: nowISO()
- };
- return new Promise((resolve, reject) => client.publish(content, (e, r) => e ? reject(e) : resolve(r)));
- };
- const tribeVoteCandidature = async ({ tribeId, candidatureId }) => {
- const client = await openSsb();
- const all = await tribeListCandidatures(tribeId);
- const alreadyThisCycle = all.some(c => Array.isArray(c.voters) && c.voters.includes(client.id));
- if (alreadyThisCycle) throw new Error('Already voted this cycle');
- const cand = all.find(c => c.id === candidatureId);
- if (!cand) throw new Error('Candidate not found');
- const updated = {
- type: 'tribeParliamentCandidature',
- tribeId, replaces: candidatureId,
- candidateId: cand.candidateId, method: cand.method,
- votes: Number(cand.votes || 0) + 1,
- voters: [...(cand.voters || []), client.id],
- proposer: cand.proposer, status: cand.status || 'OPEN',
- createdAt: cand.createdAt, updatedAt: nowISO()
- };
- return new Promise((resolve, reject) => client.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
- };
- const tribePublishRule = async ({ tribeId, title, body }) => {
- if (!title || !title.trim()) throw new Error('Title required');
- const client = await openSsb();
- const content = {
- type: 'tribeParliamentRule', tribeId,
- title: String(title).trim(), body: String(body || '').trim(),
- author: client.id, createdAt: nowISO()
- };
- return new Promise((resolve, reject) => client.publish(content, (e, r) => e ? reject(e) : resolve(r)));
- };
- const tribeDeleteRule = async (ruleId) => {
- const client = await openSsb();
- return new Promise((resolve, reject) => client.publish({ type: 'tombstone', target: ruleId, deletedAt: nowISO(), author: client.id }, (e, r) => e ? reject(e) : resolve(r)));
- };
- const tribeHasCandidatureInGlobalCycle = async (tribeId, globalTermStart) => {
- const msgs = await tribeReadLog();
- const cutoff = globalTermStart ? new Date(globalTermStart) : new Date(Date.now() - TERM_DAYS * 86400000);
- return msgs.some(m => {
- const c = m.value?.content; if (!c) return false;
- return c.type === 'parliamentCandidature' && c.targetType === 'tribe' && c.targetId === tribeId && (c.status || 'OPEN') === 'OPEN' && new Date(c.createdAt) >= cutoff;
- });
- };
- return {
- proposeCandidature,
- voteCandidature,
- resolveElection,
- getGovernmentCard,
- listLaws,
- listHistorical,
- canPropose,
- listProposalsCurrent,
- listFutureLawsCurrent,
- createProposal,
- closeProposal,
- listCandidatures,
- listTerms,
- getCurrentTerm,
- listLeaders,
- sweepProposals,
- getActorMeta,
- createRevocation,
- listRevocationsCurrent,
- listFutureRevocationsCurrent,
- closeRevocation,
- countRevocationsEnacted,
- tribe: {
- METHODS,
- TERM_DAYS,
- getCurrentTerm: tribeGetCurrentTerm,
- listCandidatures: tribeListCandidatures,
- listRules: tribeListRules,
- publishTribeCandidature: tribePublishCandidature,
- voteTribeCandidature: tribeVoteCandidature,
- publishTribeRule: tribePublishRule,
- deleteTribeRule: tribeDeleteRule,
- hasCandidatureInGlobalCycle: tribeHasCandidatureInGlobalCycle
- }
- };
- };
- function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
- function collapseOverlappingTerms(terms = []) {
- if (!terms.length) return [];
- const sorted = [...terms].sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
- const groups = [];
- for (const t of sorted) {
- const tStart = new Date(t.startAt).getTime();
- const tEnd = new Date(t.endAt).getTime();
- let placed = false;
- for (const g of groups) {
- if (tStart < g.maxEnd && tEnd > g.minStart) {
- g.items.push(t);
- if (tStart < g.minStart) g.minStart = tStart;
- if (tEnd > g.maxEnd) g.maxEnd = tEnd;
- placed = true;
- break;
- }
- }
- if (!placed) groups.push({ items: [t], minStart: tStart, maxEnd: tEnd });
- }
- const winners = groups.map(g => {
- g.items.sort((a, b) => {
- const aAn = String(a.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
- const bAn = String(b.method || '').toUpperCase() === 'ANARCHY' ? 1 : 0;
- if (aAn !== bAn) return aAn - bAn;
- const aC = new Date(a.createdAt || a.startAt).getTime();
- const bC = new Date(b.createdAt || b.startAt).getTime();
- if (aC !== bC) return aC - bC;
- const aS = new Date(a.startAt).getTime();
- const bS = new Date(b.startAt).getTime();
- if (aS !== bS) return aS - bS;
- return String(a.id || '').localeCompare(String(b.id || ''));
- });
- return g.items[0];
- });
- return winners.sort((a, b) => new Date(b.startAt) - new Date(a.startAt));
- }
|