OASIS_MOBILE/src/models/larp_model.js
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00

964 lines
40 KiB
JavaScript

const pull = require('../server/node_modules/pull-stream');
const { readTyped, requestScope } = require('./typed_log');
const fs = require('fs');
const path = require('path');
const HOUSES_PATH = path.join(__dirname, '..', 'client', 'assets', 'larp', 'houses.json');
let HOUSES = {};
try { HOUSES = JSON.parse(fs.readFileSync(HOUSES_PATH, 'utf8')); } catch (_) { HOUSES = {}; }
const HOUSE_KEYS = ['academia','solaris','arrakis','terraverde','unsystem','dogma','helix','quark','hermandad'];
const VALID_KEY = (k) => HOUSE_KEYS.includes(String(k || '').toLowerCase());
const TEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
const ACADEMIA_GRACE_MS = 10 * 60 * 1000;
const PROFILE_QUESTIONS = [
{
k: "larpProfileQ1",
q: "When you face a complex problem, what do you do first?",
options: [
{ k: "larpProfileQ1O1", t: "Talk to others to find consensus", w: { solaris: 3, hermandad: 1 } },
{ k: "larpProfileQ1O2", t: "Build a prototype to test", w: { arrakis: 3, hermandad: 1 } },
{ k: "larpProfileQ1O3", t: "Research the literature", w: { dogma: 3, terraverde: 1 } },
{ k: "larpProfileQ1O4", t: "Disrupt the system that creates it", w: { unsystem: 3, quark: 1 } }
]
},
{
k: "larpProfileQ2",
q: "What gives meaning to your daily work?",
options: [
{ k: "larpProfileQ2O1", t: "Defending the people I love", w: { quark: 3, hermandad: 1 } },
{ k: "larpProfileQ2O2", t: "Creating something tangible", w: { arrakis: 2, hermandad: 2 } },
{ k: "larpProfileQ2O3", t: "Healing or nurturing life", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ2O4", t: "Crafting words and ideas", w: { dogma: 2, solaris: 2 } },
{ k: "larpProfileQ2O5", t: "Making others laugh", w: { helix: 3, unsystem: 1 } }
]
},
{
k: "larpProfileQ3",
q: "When conflict arises in your group, you…",
options: [
{ k: "larpProfileQ3O1", t: "Lead the dialogue", w: { solaris: 3 } },
{ k: "larpProfileQ3O2", t: "Take a side and stand firm", w: { quark: 2, dogma: 1 } },
{ k: "larpProfileQ3O3", t: "Crack a joke to defuse", w: { helix: 3, unsystem: 1 } },
{ k: "larpProfileQ3O4", t: "Question whether the conflict is real",w: { unsystem: 3, dogma: 1 } },
{ k: "larpProfileQ3O5", t: "Look for root ecological causes", w: { terraverde: 2, dogma: 1 } }
]
},
{
k: "larpProfileQ4",
q: "Your favorite long-term project would be…",
options: [
{ k: "larpProfileQ4O1", t: "Building a city", w: { hermandad: 3, arrakis: 1 } },
{ k: "larpProfileQ4O2", t: "Restoring a forest", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ4O3", t: "Writing a constitution", w: { solaris: 2, dogma: 2 } },
{ k: "larpProfileQ4O4", t: "Organising a festival", w: { helix: 3, hermandad: 1 } },
{ k: "larpProfileQ4O5", t: "Setting up a defense network", w: { quark: 3, hermandad: 1 } },
{ k: "larpProfileQ4O6", t: "Designing a new machine", w: { arrakis: 3, quark: 1 } },
{ k: "larpProfileQ4O7", t: "Curating an archive", w: { dogma: 3, solaris: 1 } },
{ k: "larpProfileQ4O8", t: "Disrupting an unjust order", w: { unsystem: 3, quark: 1 } }
]
},
{
k: "larpProfileQ5",
q: "What makes a good leader?",
options: [
{ k: "larpProfileQ5O1", t: "Someone who listens and mediates", w: { solaris: 3, hermandad: 1 } },
{ k: "larpProfileQ5O2", t: "Someone who can fight and protect", w: { quark: 3, unsystem: 1 } },
{ k: "larpProfileQ5O3", t: "Someone who knows history", w: { dogma: 3, terraverde: 1 } },
{ k: "larpProfileQ5O4", t: "Someone who makes you smile", w: { helix: 3, hermandad: 1 } }
]
},
{
k: "larpProfileQ6",
q: "Your relationship with rules:",
options: [
{ k: "larpProfileQ6O1", t: "Rules emerge from dialogue and law", w: { solaris: 3 } },
{ k: "larpProfileQ6O2", t: "Rules should be followed strictly", w: { dogma: 2, quark: 2 } },
{ k: "larpProfileQ6O3", t: "Rules should be broken often", w: { unsystem: 3, helix: 1 } },
{ k: "larpProfileQ6O4", t: "Rules should serve life", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ6O5", t: "Rules build solid infrastructure", w: { hermandad: 2, arrakis: 2 } }
]
},
{
k: "larpProfileQ7",
q: "How do you handle information?",
options: [
{ k: "larpProfileQ7O1", t: "I curate and preserve it", w: { dogma: 3, hermandad: 1 } },
{ k: "larpProfileQ7O2", t: "I share it through stories", w: { helix: 2, dogma: 2 } },
{ k: "larpProfileQ7O3", t: "I question its origins", w: { unsystem: 3, dogma: 1 } },
{ k: "larpProfileQ7O4", t: "I extract the useful bits", w: { arrakis: 2, quark: 2 } },
{ k: "larpProfileQ7O5", t: "I use it to heal", w: { terraverde: 3 } }
]
},
{
k: "larpProfileQ8",
q: "Your idea of success is…",
options: [
{ k: "larpProfileQ8O1", t: "A working machine", w: { arrakis: 3, hermandad: 1 } },
{ k: "larpProfileQ8O2", t: "A peaceful community", w: { solaris: 2, terraverde: 2 } },
{ k: "larpProfileQ8O3", t: "A lively festival", w: { helix: 3, hermandad: 1 } },
{ k: "larpProfileQ8O4", t: "A safe family", w: { quark: 3, terraverde: 1 } },
{ k: "larpProfileQ8O5", t: "An unbroken archive", w: { dogma: 3, hermandad: 1 } },
{ k: "larpProfileQ8O6", t: "A cracked dogma", w: { unsystem: 3, dogma: 1 } },
{ k: "larpProfileQ8O7", t: "A thriving harvest", w: { terraverde: 3, hermandad: 1 } },
{ k: "larpProfileQ8O8", t: "A finished building", w: { hermandad: 3, arrakis: 1 } }
]
},
{
k: "larpProfileQ9",
q: "When you wake up, you want to…",
options: [
{ k: "larpProfileQ9O1", t: "Train your body", w: { quark: 3, helix: 1 } },
{ k: "larpProfileQ9O2", t: "Read or write", w: { dogma: 3, solaris: 1 } },
{ k: "larpProfileQ9O3", t: "Garden or cook", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ9O4", t: "Tinker with something", w: { arrakis: 3, hermandad: 1 } },
{ k: "larpProfileQ9O5", t: "Question authority", w: { unsystem: 3, dogma: 1 } },
{ k: "larpProfileQ9O6", t: "Plan a project", w: { hermandad: 3, solaris: 1 } },
{ k: "larpProfileQ9O7", t: "Talk to friends", w: { solaris: 2, helix: 2 } },
{ k: "larpProfileQ9O8", t: "Make art", w: { helix: 3, unsystem: 1 } }
]
},
{
k: "larpProfileQ10",
q: "Your weakness might be:",
options: [
{ k: "larpProfileQ10O1", t: "Talking too much", w: { solaris: 3, dogma: 1 } },
{ k: "larpProfileQ10O2", t: "Being too pragmatic", w: { arrakis: 3, hermandad: 1 } },
{ k: "larpProfileQ10O3", t: "Being too idealistic", w: { terraverde: 3, helix: 1 } },
{ k: "larpProfileQ10O4", t: "Being too disruptive", w: { unsystem: 3, quark: 1 } },
{ k: "larpProfileQ10O5", t: "Being too rigid", w: { dogma: 3, quark: 1 } },
{ k: "larpProfileQ10O6", t: "Being too lighthearted", w: { helix: 3, unsystem: 1 } },
{ k: "larpProfileQ10O7", t: "Being too cautious", w: { quark: 3, hermandad: 1 } },
{ k: "larpProfileQ10O8", t: "Being too ambitious", w: { hermandad: 3, arrakis: 1 } }
]
}
];
const TEST_QUESTIONS_COUNT = PROFILE_QUESTIONS.length;
const SOLAR_AGE_OFFSET = 10000000 - 2026;
function computeCycle(now = new Date()) {
const year = now.getFullYear();
const monthIdx = now.getMonth();
const start = new Date(year, 0, 1);
const dayOfYear = Math.floor((now - start) / 86400000) + 1;
const summerSolstice = new Date(year, 5, 21);
const winterSolstice = new Date(year, 11, 21);
const solsticeNum = now < summerSolstice ? 1 : (now < winterSolstice ? 2 : 1);
const houseKey = HOUSE_KEYS[monthIdx % HOUSE_KEYS.length];
const house = HOUSES[houseKey] || { short: houseKey.slice(0, 3), name: houseKey };
const solarAge = year + SOLAR_AGE_OFFSET;
const houseCycle = Math.floor(year - 2026 + 1);
return {
day: dayOfYear,
solstice: solsticeNum,
age: solarAge,
houseKey,
houseShort: house.short || houseKey.slice(0, 3),
cycle: houseCycle,
formatted: `${dayOfYear}.${solsticeNum}.${solarAge}.${house.short || houseKey.slice(0, 3)}.${houseCycle}`
};
}
function getGoverningHouseKey(now = new Date()) {
return HOUSE_KEYS[now.getMonth() % HOUSE_KEYS.length];
}
function getGoverningPeriodId(now = new Date()) {
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${getGoverningHouseKey(now)}:${now.getFullYear()}-${month}`;
}
module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const startedAt = Date.now();
let academiaRetry = null;
const followsSomeone = async (client, me) => {
const contacts = await readTyped(client, ['contact']).catch(() => []);
return contacts.some((m) => m && m.value && m.value.author === me && m.value.content && m.value.content.following === true);
};
const houseTribeTag = (houseKey) => {
const h = HOUSES[houseKey];
return `larp-${h && h.name ? h.name : houseKey}`;
};
async function findMyHouseTribe(houseKey) {
if (!tribesModel || !VALID_KEY(houseKey)) return null;
const client = await openSsb();
const me = client.id;
let list = [];
try { list = await tribesModel.listAll(); } catch (_) { return null; }
const tag = houseTribeTag(houseKey);
const candidates = list.filter(t => {
const tags = Array.isArray(t.tags) ? t.tags : [];
const members = Array.isArray(t.members) ? t.members : [];
return tags.includes(tag) && members.includes(me);
});
if (!candidates.length) return null;
candidates.sort((a, b) => (new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()) || String(a.id || '').localeCompare(String(b.id || '')));
return candidates[0];
}
async function findAnyHouseTribe(houseKey) {
if (!tribesModel || !VALID_KEY(houseKey)) return null;
let list = [];
try { list = await tribesModel.listAll(); } catch (_) { return null; }
const tag = houseTribeTag(houseKey);
const candidates = list.filter(t => {
const tags = Array.isArray(t.tags) ? t.tags : [];
return tags.includes(tag);
});
if (!candidates.length) return null;
candidates.sort((a, b) => (new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()) || String(a.id || '').localeCompare(String(b.id || '')));
return candidates[0];
}
const ANCHOR_TYPES = ['larpHouseTribeAnchor', 'larpHouseTribeAnchorTombstone', 'tombstone'];
const authorOfMsg = (client, key) => new Promise((resolve) => client.get(key, (err, v) => resolve(err || !v ? null : v.author || null)));
async function findEarliestHouseAnchor(houseKey) {
if (!VALID_KEY(houseKey)) return null;
const client = await openSsb();
const msgs = await readTyped(client, ANCHOR_TYPES);
const anchors = [];
const killedAnchorPairs = new Set();
const tombAuthors = new Map();
for (const m of msgs) {
const c = m && m.value && m.value.content;
if (!c || typeof c !== 'object') continue;
const author = m.value.author;
if (c.type === 'larpHouseTribeAnchor') {
if (c.house !== houseKey || typeof c.tribeRootId !== 'string') continue;
const tribeTs = Number(Date.parse(c.tribeCreatedAt || '')) || m.value.timestamp || 0;
anchors.push({ tribeRootId: c.tribeRootId, anchorAuthor: author, tribeTs });
} else if (c.type === 'larpHouseTribeAnchorTombstone') {
if (c.house !== houseKey || typeof c.tribeRootId !== 'string') continue;
killedAnchorPairs.add(`${c.tribeRootId}|${author}`);
} else if (c.type === 'tombstone' && typeof c.target === 'string') {
if (!tombAuthors.has(c.target)) tombAuthors.set(c.target, new Set());
tombAuthors.get(c.target).add(author);
}
}
const candidates = anchors.filter(a => !killedAnchorPairs.has(`${a.tribeRootId}|${a.anchorAuthor}`));
const deadTribes = new Set();
await Promise.all([...new Set(candidates.map(a => a.tribeRootId).filter(t => tombAuthors.has(t)))].map(async (t) => {
const tribeAuthor = await authorOfMsg(client, t);
if (tribeAuthor && tombAuthors.get(t).has(tribeAuthor)) deadTribes.add(t);
}));
const live = candidates.filter(a => !deadTribes.has(a.tribeRootId));
if (!live.length) return null;
live.sort((a, b) => a.tribeTs - b.tribeTs);
const first = live[0];
return { tribeRootId: first.tribeRootId, author: first.anchorAuthor, tribeTs: first.tribeTs };
}
async function findHouseAnchorByTribe(houseKey, tribeRootId) {
if (!VALID_KEY(houseKey) || !tribeRootId) return null;
const client = await openSsb();
const msgs = await readTyped(client, ANCHOR_TYPES);
for (const m of msgs) {
const c = m && m.value && m.value.content;
if (!c || c.type !== 'larpHouseTribeAnchor' || c.house !== houseKey || c.tribeRootId !== tribeRootId) continue;
return { author: m.value.author, ts: m.value.timestamp || 0 };
}
return null;
}
async function publishHouseTribeAnchor(houseKey, tribeRootId, tribeCreatedAt) {
if (!VALID_KEY(houseKey) || !tribeRootId) return null;
const client = await openSsb();
return new Promise((resolve) => {
client.publish({
type: 'larpHouseTribeAnchor',
house: houseKey,
tribeRootId,
tribeCreatedAt: tribeCreatedAt || new Date().toISOString(),
anchoredAt: new Date().toISOString()
}, (err, msg) => resolve(err ? null : msg));
});
}
async function listMyHouseTribes(houseKey) {
if (!tribesModel || !VALID_KEY(houseKey)) return [];
const client = await openSsb();
const me = client.id;
let list = [];
try { list = await tribesModel.listAll(); } catch (_) { return []; }
const tag = houseTribeTag(houseKey);
const out = [];
for (const t of list) {
const tags = Array.isArray(t.tags) ? t.tags : [];
const members = Array.isArray(t.members) ? t.members : [];
if (!tags.includes(tag) || !members.includes(me)) continue;
const rootId = await tribesModel.getRootId(t.id).catch(() => t.id);
const createdAtTs = Number(Date.parse(t.createdAt || '')) || 0;
out.push({ tribe: t, rootId, createdAtTs });
}
return out;
}
async function tombstoneMyTribe(houseKey, rootId, tribeId) {
try { await tribesModel.publishTombstone(tribeId); } catch (_) {}
if (houseKey !== 'academia') {
await publishHouseAnchorTombstone(houseKey, rootId).catch(() => {});
}
if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') {
try { tribeCrypto.dropKey(rootId); } catch (_) {}
}
}
async function ensureHouseTribe(houseKey) {
if (!tribesModel || !VALID_KEY(houseKey)) return null;
const client = await openSsb();
const me = client.id;
if (houseKey === 'academia') {
const canonical = await findAnyHouseTribe(houseKey).catch(() => null);
const canonRoot = canonical ? await tribesModel.getRootId(canonical.id).catch(() => canonical.id) : null;
const myTribes = await listMyHouseTribes(houseKey);
myTribes.sort((a, b) => (a.createdAtTs - b.createdAtTs) || String(a.rootId || '').localeCompare(String(b.rootId || '')));
const keepRoot = canonRoot || (myTribes[0] && myTribes[0].rootId);
for (const t of myTribes) {
if (t.rootId === keepRoot) continue;
if (t.tribe.author !== me) continue;
await tombstoneMyTribe(houseKey, t.rootId, t.tribe.id).catch(() => {});
}
const mineNow = await findMyHouseTribe(houseKey);
if (mineNow) return mineNow;
if (canonical) {
await redeemPendingAutoInvites().catch(() => {});
return await findMyHouseTribe(houseKey) || canonical;
}
if (Date.now() - startedAt < ACADEMIA_GRACE_MS && await followsSomeone(client, me)) {
if (!academiaRetry) {
academiaRetry = setTimeout(() => { academiaRetry = null; enqueue(runCatchup); }, ACADEMIA_GRACE_MS - (Date.now() - startedAt));
if (academiaRetry.unref) academiaRetry.unref();
}
return null;
}
const house = HOUSES[houseKey] || {};
const tag = houseTribeTag(houseKey);
try {
await tribesModel.createTribe(house.name || houseKey, house.description || '', house.image || null, '', [tag], false, 'open', null, 'PUBLIC', '');
} catch (_) {}
return await findMyHouseTribe(houseKey);
}
const anchor = await findEarliestHouseAnchor(houseKey).catch(() => null);
const myTribes = await listMyHouseTribes(houseKey);
myTribes.sort((a, b) => a.createdAtTs - b.createdAtTs);
let myCanonical = null;
if (anchor) {
myCanonical = myTribes.find(x => x.rootId === anchor.tribeRootId) || null;
}
if (!myCanonical && myTribes.length > 0) {
const myOldest = myTribes[0];
const myOldestTs = myOldest.createdAtTs;
if (!anchor) {
await publishHouseTribeAnchor(houseKey, myOldest.rootId, myOldest.tribe.createdAt).catch(() => {});
myCanonical = myOldest;
} else if (myOldestTs > 0 && anchor.tribeTs > 0 && myOldestTs < anchor.tribeTs) {
const existingAnchor = await findHouseAnchorByTribe(houseKey, myOldest.rootId);
if (!existingAnchor) {
await publishHouseTribeAnchor(houseKey, myOldest.rootId, myOldest.tribe.createdAt).catch(() => {});
}
myCanonical = myOldest;
}
}
for (const t of myTribes) {
if (myCanonical && t.rootId === myCanonical.rootId) continue;
if (t.tribe.author !== me) continue;
await tombstoneMyTribe(houseKey, t.rootId, t.tribe.id);
}
if (myCanonical) {
const existingAnchorForMine = await findHouseAnchorByTribe(houseKey, myCanonical.rootId);
if (!existingAnchorForMine) {
await publishHouseTribeAnchor(houseKey, myCanonical.rootId, myCanonical.tribe.createdAt).catch(() => {});
}
return myCanonical.tribe;
}
if (anchor) return null;
const existingAny = await findAnyHouseTribe(houseKey).catch(() => null);
if (existingAny) {
const rootId = await tribesModel.getRootId(existingAny.id).catch(() => existingAny.id);
const hasAnchor = await findHouseAnchorByTribe(houseKey, rootId);
if (!hasAnchor) await publishHouseTribeAnchor(houseKey, rootId, existingAny.createdAt).catch(() => {});
if (Array.isArray(existingAny.members) && existingAny.members.includes(me)) return existingAny;
await redeemPendingAutoInvites().catch(() => {});
return await findMyHouseTribe(houseKey);
}
const house = HOUSES[houseKey] || {};
const tag = houseTribeTag(houseKey);
try {
await tribesModel.createTribe(house.name || houseKey, house.description || '', house.image || null, '', [tag], true, 'open', null, 'PRIVATE', '');
} catch (_) {}
const created = await findMyHouseTribe(houseKey);
if (created) {
const rootId = await tribesModel.getRootId(created.id).catch(() => created.id);
await publishHouseTribeAnchor(houseKey, rootId, created.createdAt).catch(() => {});
}
return created;
}
async function publishHouseAnchorTombstone(houseKey, tribeRootId) {
if (!VALID_KEY(houseKey) || !tribeRootId) return null;
const client = await openSsb();
return new Promise((resolve) => {
client.publish({
type: 'larpHouseTribeAnchorTombstone',
house: houseKey,
tribeRootId,
tombstonedAt: new Date().toISOString()
}, (err, msg) => resolve(err ? null : msg));
});
}
async function leaveMyHouseTribe(houseKey) {
if (!tribesModel) return;
const client = await openSsb();
const me = client.id;
const myTribes = await listMyHouseTribes(houseKey);
for (const t of myTribes) {
const isSoloAuthor = t.tribe.author === me && Array.isArray(t.tribe.members) && t.tribe.members.length === 1 && t.tribe.members[0] === me;
try { await tribesModel.leaveTribe(t.tribe.id, { force: true }); } catch (_) {}
if (isSoloAuthor) {
if (houseKey !== 'academia') {
await publishHouseAnchorTombstone(houseKey, t.rootId).catch(() => {});
}
if (tribeCrypto && typeof tribeCrypto.dropKey === 'function') {
try { tribeCrypto.dropKey(t.rootId); } catch (_) {}
}
}
}
}
async function publishJoin(houseKey) {
if (!VALID_KEY(houseKey)) throw new Error('Invalid house key');
const client = await openSsb();
let previousHouse = null;
try { previousHouse = await getUserHouse(client.id); } catch (_) {}
await new Promise((resolve, reject) => {
client.publish({
type: 'larpJoinHouse',
house: houseKey,
joinedAt: new Date().toISOString()
}, (err, msg) => err ? reject(err) : resolve(msg));
});
forgetMemberships();
if (previousHouse && previousHouse !== houseKey) {
await leaveMyHouseTribe(previousHouse).catch(() => {});
}
await ensureHouseTribe(houseKey).catch(() => {});
await redeemPendingAutoInvites().catch(() => {});
await issueAutoInvitesForMyHouse().catch(() => {});
}
async function getUserHouse(feedId) {
const client = await openSsb();
const target = feedId || client.id;
return (await listAllMemberships()).get(target) || null;
}
async function readMemberships() {
const client = await openSsb();
const msgs = await readTyped(client, ['larpJoinHouse', 'larpLeaveLarp'], { limit: 0 });
const byAuthor = new Map();
for (const m of msgs) {
const author = m && m.value && m.value.author;
if (!author) continue;
const c = m.value.content;
if (!c) continue;
const ts = m.value.timestamp || 0;
const seq = Number(m.value.sequence) || 0;
const prev = byAuthor.get(author);
const newer = !prev || ts > prev.ts || (ts === prev.ts && seq > prev.seq);
if (c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
if (newer) byAuthor.set(author, { house: c.house, ts, seq });
} else if (c.type === 'larpLeaveLarp') {
if (newer) byAuthor.set(author, { house: null, ts, seq });
}
}
const result = new Map();
for (const [a, v] of byAuthor.entries()) {
if (v.house) result.set(a, v.house);
}
return result;
}
const forgetMemberships = () => {
const store = requestScope.getStore();
if (store) delete store.larpMemberships;
};
async function listAllMemberships() {
const store = requestScope.getStore();
if (!store) return readMemberships();
if (!store.larpMemberships) store.larpMemberships = readMemberships();
return store.larpMemberships;
}
async function publishLeaveLarp() {
const client = await openSsb();
let previousHouse = null;
try { previousHouse = await getUserHouse(client.id); } catch (_) {}
await new Promise((resolve, reject) => {
client.publish({
type: 'larpLeaveLarp',
leftAt: new Date().toISOString()
}, (err, msg) => err ? reject(err) : resolve(msg));
});
forgetMemberships();
if (previousHouse) {
await leaveMyHouseTribe(previousHouse).catch(() => {});
}
}
async function listHousesWithCounts() {
const memberships = await listAllMemberships();
const counts = Object.fromEntries(HOUSE_KEYS.map(k => [k, 0]));
for (const house of memberships.values()) {
if (counts[house] !== undefined) counts[house] += 1;
}
return HOUSE_KEYS.map(key => ({
key,
...HOUSES[key],
memberCount: counts[key] || 0
}));
}
async function getMembersOfHouse(houseKey) {
if (!VALID_KEY(houseKey)) return [];
const memberships = await listAllMemberships();
const out = [];
for (const [author, house] of memberships.entries()) {
if (house === houseKey) out.push(author);
}
return out;
}
async function publishHousePost({ house, text }) {
if (!VALID_KEY(house)) throw new Error('Invalid house key');
const client = await openSsb();
const clean = String(text || '').trim().slice(0, 4000);
if (!clean) throw new Error('Empty post');
return new Promise((resolve, reject) => {
client.publish({
type: 'larpHousePost',
house,
text: clean,
createdAt: new Date().toISOString()
}, (err, msg) => err ? reject(err) : resolve(msg));
});
}
function wallIsPublic(houseKey, { isGoverning = false } = {}) {
return houseKey === 'academia' || isGoverning === true;
}
async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) {
if (!VALID_KEY(houseKey)) return [];
const viewerIsMember = viewerHouse === houseKey;
if (!viewerIsMember && !wallIsPublic(houseKey, { isGoverning })) return [];
const client = await openSsb();
const memberships = await listAllMemberships();
return new Promise((resolve) => {
const posts = [];
const tombs = new Map();
pull(
client.createLogStream({ reverse: true }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (c && c.type === 'tombstone' && typeof c.target === 'string') {
if (!tombs.has(c.target)) tombs.set(c.target, new Set());
tombs.get(c.target).add(m.value.author);
return;
}
if (!c || c.type !== 'larpHousePost') return;
if (c.house !== houseKey) return;
const author = m.value.author;
const memberHouse = memberships.get(author) || 'academia';
if (memberHouse !== houseKey) return;
posts.push({
id: m.key,
author,
text: String(c.text || ''),
createdAt: c.createdAt || new Date(m.value.timestamp || 0).toISOString(),
ts: m.value.timestamp || 0
});
}, () => {
const shown = posts.filter(post => !(tombs.get(post.id) && tombs.get(post.id).has(post.author)));
shown.sort((a, b) => b.ts - a.ts);
resolve(shown);
})
);
});
}
async function getLastTestAttempt(feedId) {
const client = await openSsb();
const target = feedId || client.id;
return new Promise((resolve) => {
let latest = null;
pull(
client.createUserStream({ id: target, reverse: true }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (!c || c.type !== 'larpTestAttempt') return;
if (!VALID_KEY(c.house)) return;
const ts = m.value.timestamp || 0;
if (!latest || ts > latest.ts) latest = { house: c.house, ts, passed: c.passed === true, score: c.score || 0 };
}, () => resolve(latest))
);
});
}
async function canTakeTest(feedId) {
const last = await getLastTestAttempt(feedId);
if (!last) return { allowed: true, nextAt: 0, last: null };
const elapsed = Date.now() - last.ts;
if (elapsed >= TEST_COOLDOWN_MS) return { allowed: true, nextAt: 0, last };
return { allowed: false, nextAt: last.ts + TEST_COOLDOWN_MS, last };
}
function getProfileTest() {
return PROFILE_QUESTIONS.map(q => ({
key: q.k,
question: q.q,
options: q.options.map(o => ({ key: o.k, text: o.t }))
}));
}
function scoreProfileAnswers(answers, memberCounts = {}) {
const scores = Object.fromEntries(HOUSE_KEYS.filter(k => k !== 'academia').map(k => [k, 0]));
PROFILE_QUESTIONS.forEach((q, i) => {
const choice = Number(answers && answers[i]);
if (!Number.isInteger(choice) || choice < 0 || choice >= q.options.length) return;
const weights = q.options[choice].w || {};
for (const [house, weight] of Object.entries(weights)) {
if (scores[house] === undefined) continue;
scores[house] += Number(weight) || 0;
}
});
const ranking = Object.entries(scores).sort((a, b) => {
if (b[1] !== a[1]) return b[1] - a[1];
const ma = memberCounts[a[0]] || 0;
const mb = memberCounts[b[0]] || 0;
if (ma !== mb) return ma - mb;
return a[0].localeCompare(b[0]);
});
const bestHouse = ranking[0] ? ranking[0][0] : null;
const bestScore = ranking[0] ? ranking[0][1] : 0;
return { scores, ranking, bestHouse, bestScore };
}
async function submitProfileTest({ answers }) {
const client = await openSsb();
const can = await canTakeTest(client.id);
if (!can.allowed) return { ok: false, reason: 'cooldown', nextAt: can.nextAt };
const housesWithCounts = await listHousesWithCounts();
const memberCounts = Object.fromEntries(housesWithCounts.map(h => [h.key, h.memberCount || 0]));
const { scores, ranking, bestHouse, bestScore } = scoreProfileAnswers(answers, memberCounts);
const target = bestHouse || 'academia';
await new Promise((resolve, reject) => {
client.publish({
type: 'larpTestAttempt',
house: target,
passed: true,
attemptedAt: new Date().toISOString()
}, (err) => err ? reject(err) : resolve());
});
await publishJoin(target);
return { ok: true, passed: true, house: target, score: bestScore, scores, ranking };
}
async function issueAutoInvitesForMyHouse() {
if (!tribesModel) return;
const client = await openSsb();
const me = client.id;
const myHouse = await getUserHouse(me).catch(() => null);
if (!VALID_KEY(myHouse) || myHouse === 'academia') return;
const anchor = await findEarliestHouseAnchor(myHouse).catch(() => null);
if (!anchor) return;
let canonicalTribe;
try { canonicalTribe = await tribesModel.getTribeById(anchor.tribeRootId); } catch (_) { return; }
if (!canonicalTribe) return;
const tribeMembers = Array.isArray(canonicalTribe.members) ? canonicalTribe.members : [];
if (!tribeMembers.includes(me)) return;
const houseMembers = await getMembersOfHouse(myHouse).catch(() => []);
const missing = houseMembers.filter(id => id && id !== me && !tribeMembers.includes(id));
if (!missing.length) return;
const sent = await listMyAutoInviteRecipients(myHouse, anchor.tribeRootId).catch(() => new Set());
for (const newMember of missing) {
if (sent.has(newMember)) continue;
try {
const code = await tribesModel.generateInvite(canonicalTribe.id);
await new Promise((resolve) => {
client.publish({
type: 'larpAutoInvite',
house: myHouse,
tribeRootId: anchor.tribeRootId,
to: newMember,
code,
sentAt: new Date().toISOString(),
recps: [newMember, me]
}, () => resolve());
});
} catch (_) {}
}
}
async function listMyAutoInviteRecipients(houseKey, tribeRootId) {
const client = await openSsb();
const me = client.id;
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
return new Promise((resolve) => {
const out = new Set();
pull(
client.createUserStream({ id: me }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
if (typeof c !== 'string' || !c.endsWith('.box')) return;
let decoded;
try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
if (!decoded) return;
if (typeof decoded === 'string') {
try { decoded = JSON.parse(decoded); } catch (_) { return; }
}
if (!decoded || decoded.type !== 'larpAutoInvite') return;
if (decoded.house !== houseKey) return;
if (decoded.tribeRootId !== tribeRootId) return;
if (typeof decoded.to === 'string' && decoded.to !== me) out.add(decoded.to);
}, () => resolve(out))
);
});
}
async function alreadyInCanonical(houseKey) {
if (!VALID_KEY(houseKey)) return false;
const anchor = await findEarliestHouseAnchor(houseKey).catch(() => null);
if (!anchor) return false;
try {
const canonical = await tribesModel.getTribeById(anchor.tribeRootId);
const client = await openSsb();
return !!(canonical && Array.isArray(canonical.members) && canonical.members.includes(client.id));
} catch (_) { return false; }
}
async function redeemPendingAutoInvites() {
if (!tribesModel) return;
const client = await openSsb();
const me = client.id;
const myHouse = await getUserHouse(me).catch(() => null);
if (!VALID_KEY(myHouse) || myHouse === 'academia') return;
if (await alreadyInCanonical(myHouse)) return;
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
const codes = [];
const sealedOnly = client.private && typeof client.private.read === 'function';
await new Promise((resolve) => {
pull(
sealedOnly ? client.private.read({ reverse: true }) : client.createLogStream({ reverse: true, limit: 2000 }),
pull.drain((m) => {
const c = m && m.value && m.value.content;
let decoded;
if (typeof c === 'string') {
if (!c.endsWith('.box')) return;
try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
} else {
if (!m.value.private) return;
decoded = c;
}
if (!decoded) return;
if (typeof decoded === 'string') {
try { decoded = JSON.parse(decoded); } catch (_) { return; }
}
if (!decoded || decoded.type !== 'larpAutoInvite') return;
if (!VALID_KEY(decoded.house) || typeof decoded.code !== 'string') return;
if (decoded.house !== myHouse) return;
if (m.value.author === me) return;
codes.push(decoded.code);
}, () => resolve())
);
});
for (const code of codes) {
try { await tribesModel.joinByInvite(code); } catch (_) {}
}
}
let liveSubscriberStarted = false;
let initRan = false;
let processingChain = Promise.resolve();
function enqueue(fn) {
processingChain = processingChain.then(fn).catch(() => {});
return processingChain;
}
async function runCatchup() {
try {
const client = await openSsb();
const me = client.id;
const myHouse = await getUserHouse(me).catch(() => null);
if (VALID_KEY(myHouse)) {
await ensureHouseTribe(myHouse).catch(() => {});
}
await redeemPendingAutoInvites().catch(() => {});
await issueAutoInvitesForMyHouse().catch(() => {});
} catch (_) {}
}
async function handleLiveMessage(m) {
const c = m && m.value && m.value.content;
if (!c) return;
const client = await openSsb();
const me = client.id;
const ssbKeys = require('../server/node_modules/ssb-keys');
const config = require('../server/ssb_config');
try {
if (typeof c === 'object' && c.type === 'larpJoinHouse' && VALID_KEY(c.house)) {
if (m.value.author === me) return;
const myHouse = await getUserHouse(me).catch(() => null);
if (myHouse !== c.house) return;
await issueAutoInvitesForMyHouse().catch(() => {});
return;
}
if (typeof c === 'object' && c.type === 'tribe' && Array.isArray(c.tags) && c.tags.includes(houseTribeTag('academia'))) {
if (m.value.author === me) return;
const myHouse = await getUserHouse(me).catch(() => null);
if (myHouse !== 'academia') return;
await ensureHouseTribe('academia').catch(() => {});
return;
}
if (typeof c === 'object' && c.type === 'larpHouseTribeAnchor' && VALID_KEY(c.house)) {
if (m.value.author === me) return;
const myHouse = await getUserHouse(me).catch(() => null);
if (myHouse !== c.house) return;
await ensureHouseTribe(c.house).catch(() => {});
await redeemPendingAutoInvites().catch(() => {});
await issueAutoInvitesForMyHouse().catch(() => {});
return;
}
if (typeof c === 'object' && c.type === 'larpHouseTribeAnchorTombstone' && VALID_KEY(c.house)) {
if (m.value.author === me) return;
const myHouse = await getUserHouse(me).catch(() => null);
if (myHouse !== c.house) return;
await ensureHouseTribe(c.house).catch(() => {});
await redeemPendingAutoInvites().catch(() => {});
return;
}
if (typeof c === 'string' && c.endsWith('.box')) {
if (m.value.author === me) return;
let decoded;
try { decoded = ssbKeys.unbox(c, config.keys); } catch (_) { return; }
if (!decoded) return;
if (typeof decoded === 'string') {
try { decoded = JSON.parse(decoded); } catch (_) { return; }
}
if (!decoded || decoded.type !== 'larpAutoInvite') return;
if (!VALID_KEY(decoded.house) || typeof decoded.code !== 'string') return;
const myHouse = await getUserHouse(me).catch(() => null);
if (decoded.house !== myHouse) return;
if (await alreadyInCanonical(myHouse)) return;
try { await tribesModel.joinByInvite(decoded.code); } catch (_) {}
}
} catch (_) {}
}
async function init() {
if (initRan) return;
initRan = true;
if (!liveSubscriberStarted) {
liveSubscriberStarted = true;
try {
const client = await openSsb();
pull(
client.createLogStream({ live: true, old: false }),
pull.drain((m) => { enqueue(() => handleLiveMessage(m)); }, () => { liveSubscriberStarted = false; })
);
} catch (_) { liveSubscriberStarted = false; }
}
enqueue(runCatchup);
}
async function createHouseInvite(houseKey) {
if (!VALID_KEY(houseKey)) throw new Error('Invalid house key');
if (houseKey === 'academia') throw new Error('ACADEMIA does not issue invites');
const client = await openSsb();
const myHouse = await getUserHouse(client.id);
if (myHouse !== houseKey) throw new Error('Only members can issue invites');
const tribe = await ensureHouseTribe(houseKey);
if (!tribe) throw new Error('Could not resolve house tribe');
if (!tribesModel) throw new Error('tribesModel unavailable');
const code = await tribesModel.generateInvite(tribe.id);
return { code, house: houseKey, tribeId: tribe.id };
}
async function redeemHouseInvite(rawCode) {
const code = String(rawCode || '').trim();
if (!code) return { ok: false };
if (!tribesModel) return { ok: false };
const client = await openSsb();
const myHouse = await getUserHouse(client.id);
if (myHouse && myHouse !== 'academia') return { ok: false };
let rootId;
try { rootId = await tribesModel.joinByInvite(code); } catch (_) { return { ok: false }; }
if (!rootId) return { ok: false };
let tribe = null;
try { tribe = await tribesModel.getTribeById(rootId); } catch (_) { tribe = null; }
const tags = (tribe && Array.isArray(tribe.tags)) ? tribe.tags : [];
const houseTag = tags.find(t => typeof t === 'string' && t.startsWith('larp-'));
if (!houseTag) return { ok: false };
const suffix = houseTag.slice('larp-'.length);
const houseKey = HOUSE_KEYS.find(k => (HOUSES[k] && HOUSES[k].name === suffix) || k === suffix);
if (!houseKey || houseKey === 'academia') return { ok: false };
await publishJoin(houseKey);
return { ok: true, house: houseKey, tribeId: rootId };
}
return {
HOUSES,
HOUSE_KEYS,
wallIsPublic,
TEST_COOLDOWN_MS,
TEST_QUESTIONS_COUNT,
PROFILE_QUESTIONS,
computeCycle,
getGoverningHouseKey,
getGoverningPeriodId,
publishJoin,
publishLeaveLarp,
getUserHouse,
listAllMemberships,
listHousesWithCounts,
getMembersOfHouse,
publishHousePost,
listHousePosts,
getLastTestAttempt,
canTakeTest,
getProfileTest,
scoreProfileAnswers,
submitProfileTest,
createHouseInvite,
redeemHouseInvite,
findMyHouseTribe,
ensureHouseTribe,
leaveMyHouseTribe,
issueAutoInvitesForMyHouse,
redeemPendingAutoInvites,
init,
getHouse: (key) => HOUSES[key] || null
};
};