Oasis 0.9.5 con Karvan para escritorio

Rama de desarrollo experimental sobre el Oasis de epsylon, sin afiliacion con el
proyecto original. Base: 0.9.5.

Trae el modulo Karvan —salas efimeras en memoria con autodestruccion por TTL, chat
que funciona sin JavaScript y llamadas de audio y video sobre WebRTC— con el enlace
en el menu lateral por el mismo patron que el resto de modulos: renderKarvanLink
calcado de renderPollsLink, entrada en modules_view y en la lista de /modules.

Los estilos del modulo van en su propia hoja, styles/karvan.css, siguiendo el patron
de highlight.css: asi se ve igual con cualquier tema, sin depender del tema movil.

Sin servidores ICE de terceros: no hay STUN de fabrica, porque un STUN aprende la IP
publica y el momento de cada llamada. Solo candidatos host salvo que se configure un
TURN propio en oasis-config.json, con credenciales efimeras por el mecanismo REST de
coturn.

El codigo propio vive separado —views/fork/, backend/fork_routes.js,
models/karvan_model.js y translations/fork/— de modo que sobre los ficheros de
upstream solo hay enganches de una linea y cada version nueva se integra sin
arrastrar nada.

Respecto al arbol de Android: fuera el wrapper y main.js, que es su arranque; el
tema vuelve a Dark-SNH y se restauran los modulos que en movil se recortan por peso.

Verificado arrancando sin OASIS_MOBILE: nueve rutas OK, menu lateral de upstream con
27 grupos y el enlace de Karvan, cero rastro de la interfaz movil (ni hexagonos, ni
topbar, ni barra inferior), y una sala creada y servida.
This commit is contained in:
SITO 2026-08-19 00:35:08 +02:00
commit ddd2787b73
286 changed files with 157145 additions and 0 deletions

9743
src/backend/backend.js Normal file

File diff suppressed because it is too large Load diff

308
src/backend/blobHandler.js Normal file
View file

@ -0,0 +1,308 @@
const pull = require('../server/node_modules/pull-stream');
const FileType = require("../server/node_modules/file-type");
const promisesFs = require('fs').promises;
const ssb = require("../client/gui");
const config = require("../server/SSB_server").config;
const cooler = ssb({ offline: config.offline });
let sharp;
try {
sharp = require("sharp");
} catch (e) {
}
const stripImageMetadata = async (buffer) => {
if (typeof sharp !== "function") return buffer;
try {
return await sharp(buffer).rotate().toBuffer();
} catch {
return buffer;
}
};
const PDF_METADATA_KEYS = [
'/Title', '/Author', '/Subject', '/Keywords',
'/Creator', '/Producer', '/CreationDate', '/ModDate'
];
const stripPdfMetadata = (buffer) => {
try {
let str = buffer.toString('binary');
for (const key of PDF_METADATA_KEYS) {
const keyBytes = key;
const regex = new RegExp(
keyBytes.replace(/\//g, '\\/') + '\\s*\\([^)]*\\)',
'g'
);
str = str.replace(regex, keyBytes + ' ()');
const hexRegex = new RegExp(
keyBytes.replace(/\//g, '\\/') + '\\s*<[^>]*>',
'g'
);
str = str.replace(hexRegex, keyBytes + ' <>');
}
return Buffer.from(str, 'binary');
} catch {
return buffer;
}
};
const MAX_BLOB_SIZE = 50 * 1024 * 1024;
class FileTooLargeError extends Error {
constructor(fileName, fileSize) {
super(`File too large: ${fileName} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`);
this.name = 'FileTooLargeError';
this.fileName = fileName;
this.fileSize = fileSize;
}
}
const OGG_VIDEO_MARKERS = ['theora', 'VP80', 'OggDS', 'Dirac'];
const OGG_AUDIO_MARKERS = ['vorbis', 'OpusHead', 'FLAC', 'Speex'];
const oggMime = (buffer) => {
const head = buffer.slice(0, 65536);
for (const marker of OGG_VIDEO_MARKERS) if (head.includes(Buffer.from(marker))) return 'video/ogg';
for (const marker of OGG_AUDIO_MARKERS) if (head.includes(Buffer.from(marker))) return 'audio/ogg';
return 'video/ogg';
};
const storeUploadedFile = async function (blobUpload) {
if (!blobUpload || !blobUpload.filepath) return null;
let data = await promisesFs.readFile(blobUpload.filepath);
if (data.length === 0) return null;
if (data.length > MAX_BLOB_SIZE) {
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || 'file', data.length);
}
const EXTENSION_MIME_MAP = {
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
'.ogv': 'video/ogg', '.oga': 'audio/ogg', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
'.mp3': 'audio/mpeg', '.wav': 'audio/wav',
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
'.svg': 'image/svg+xml', '.bmp': 'image/bmp',
'.torrent': 'application/x-bittorrent'
};
const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' };
try {
const fileType = await FileType.fromBuffer(data);
blob.mime = (fileType && fileType.mime) ? fileType.mime : null;
} catch {
blob.mime = null;
}
if (blob.mime === 'application/ogg') blob.mime = oggMime(data);
const GENERIC_MIMES = new Set(['application/octet-stream', 'video/x-matroska']);
if ((!blob.mime || GENERIC_MIMES.has(blob.mime)) && blob.name) {
const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
blob.mime = EXTENSION_MIME_MAP[ext] || blob.mime || 'application/octet-stream';
}
if (!blob.mime) {
blob.mime = 'application/octet-stream';
}
if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
data = await stripImageMetadata(data);
} else if (blob.mime === 'application/pdf') {
data = stripPdfMetadata(data);
}
const ssbClient = await cooler.open();
blob.id = await new Promise((resolve, reject) => {
pull(
pull.values([data]),
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
);
});
if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`;
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`;
if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`;
return `\n[${blob.name}](${blob.id})`;
};
const handleBlobUpload = async function (ctx, fileFieldName) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null;
const entry = ctx.request.files[fileFieldName];
const first = Array.isArray(entry) ? entry[0] : entry;
return storeUploadedFile(first);
};
const handleBlobUploads = async function (ctx, fileFieldName, max = 8) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return [];
const entry = ctx.request.files[fileFieldName];
const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max));
const out = [];
for (const file of files) {
const stored = await storeUploadedFile(file);
if (stored) out.push(stored);
}
return out;
};
function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
return new Promise((resolve, reject) => {
let done = false;
const finishOk = () => {
if (done) return;
done = true;
clearTimeout(timer);
resolve();
};
const finishErr = (err) => {
if (done) return;
done = true;
clearTimeout(timer);
reject(err);
};
const timer = setTimeout(() => {
finishErr(new Error(`Timeout waiting for blob ${blobId}`));
}, timeoutMs);
if (!ssbClient.blobs || typeof ssbClient.blobs.has !== 'function') {
return finishErr(new Error('ssb.blobs.has is not available'));
}
ssbClient.blobs.has(blobId, (err, has) => {
if (err) return finishErr(err);
if (has) return finishOk();
if (typeof ssbClient.blobs.want !== 'function') {
return finishErr(new Error('ssb.blobs.want is not available'));
}
ssbClient.blobs.want(blobId, (err2) => {
if (err2) return finishErr(err2);
finishOk();
});
});
});
}
const serveBlob = async function (ctx) {
const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
const raw = decodeURIComponent(encodedParam);
if (!raw) {
ctx.status = 400;
ctx.body = 'Invalid blob id';
return;
}
const blobId = raw.startsWith('&') ? raw : `&${raw}`;
const ssbClient = await cooler.open();
try {
await waitForBlob(ssbClient, blobId, 8000);
} catch (err) {
ctx.status = 504;
ctx.body = 'Blob not available';
return;
}
let buffer;
try {
buffer = await new Promise((resolve, reject) => {
pull(
ssbClient.blobs.get(blobId),
pull.collect((err, chunks) => {
if (err) return reject(err);
resolve(Buffer.concat(chunks));
})
);
});
} catch (err) {
ctx.status = 500;
ctx.body = 'Error reading blob';
return;
}
const size = buffer.length;
let mime = 'application/octet-stream';
try {
const ft = await FileType.fromBuffer(buffer);
if (ft && ft.mime) mime = ft.mime;
} catch {}
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
const head = buffer.slice(0, 128).toString('ascii');
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
}
if (mime === 'application/octet-stream' || mime === 'text/plain' || mime === 'application/xml' || mime === 'text/xml') {
let head = buffer.slice(0, 512).toString('utf8');
if (head.charCodeAt(0) === 0xFEFF) head = head.slice(1);
const trimmed = head.replace(/^\s+/, '').toLowerCase();
if (trimmed.startsWith('<?xml') || trimmed.startsWith('<svg')) {
if (trimmed.includes('<svg')) mime = 'image/svg+xml';
}
}
if (mime === 'application/ogg') mime = oggMime(buffer);
const isSvg = mime === 'image/svg+xml';
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
const disposition = isSvg ? 'attachment' : 'inline';
ctx.type = mime;
ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
const range = ctx.headers.range;
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
let start = match[1] ? parseInt(match[1], 10) : 0;
let end = match[2] ? parseInt(match[2], 10) : size - 1;
if (Number.isNaN(start) || start < 0) start = 0;
if (Number.isNaN(end) || end >= size) end = size - 1;
if (start > end || start >= size) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
const chunk = buffer.slice(start, end + 1);
ctx.status = 206;
ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(chunk.length));
ctx.body = chunk;
} else {
ctx.status = 200;
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(size));
ctx.body = buffer;
}
};
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, oggMime, FileTooLargeError };

View file

@ -0,0 +1,66 @@
const { mergeDuplicatesBy, norm } = require('./dedupe');
const identitySignature = (x) => (x && x.author && x.createdAt) ? norm(x.author) + '|' + norm(x.createdAt) : null;
const openInviteOf = (item) => {
if (!item || !Array.isArray(item.invites)) return null;
const pub = item.invites.find((inv) => inv && typeof inv === 'object' && inv.public === true && inv.code);
return pub ? { code: pub.code } : null;
};
const collabContent = (opts = {}) => {
const membersField = opts.membersField || 'members';
const undecField = opts.undecField || 'undecryptable';
const contentFields = Array.isArray(opts.contentFields) ? opts.contentFields : [];
const listFields = Array.isArray(opts.listFields) ? opts.listFields : [];
const arr = (v) => (Array.isArray(v) ? v : []);
const freshestMembers = (base, dup) => {
const bt = new Date(base.updatedAt || base.createdAt || 0).getTime();
const dt = new Date(dup.updatedAt || dup.createdAt || 0).getTime();
const fresh = dt >= bt ? dup : base;
return arr(fresh[membersField]).length ? fresh[membersField]
: (arr(base[membersField]).length ? base[membersField] : arr(dup[membersField]));
};
const mergePair = (base, dup) => {
const out = { ...base, [membersField]: freshestMembers(base, dup) };
for (const f of contentFields) out[f] = base[f] || dup[f];
for (const f of listFields) out[f] = arr(base[f]).length ? base[f] : arr(dup[f]);
out[undecField] = !!(base[undecField] && dup[undecField]);
return out;
};
const orderForMerge = (a, b) => {
const c = new Date(a.createdAt || 0) - new Date(b.createdAt || 0);
if (c !== 0) return c;
return ((a[undecField] || !a.title) ? 1 : 0) - ((b[undecField] || !b.title) ? 1 : 0);
};
const collapse = (items) =>
mergeDuplicatesBy(arr(items).slice().sort(orderForMerge), identitySignature, mergePair);
const fold = (base, all) => {
const sig = identitySignature(base);
if (!sig) return base;
const group = arr(all).filter((x) => identitySignature(x) === sig);
if (group.length <= 1) return base;
const canonical = collapse(group)[0];
const out = { ...base, [membersField]: canonical[membersField] };
for (const f of contentFields) out[f] = base[f] || canonical[f];
for (const f of listFields) out[f] = arr(base[f]).length ? base[f] : arr(canonical[f]);
return out;
};
const isVisible = (item, viewerId) => {
if (!item[undecField]) return true;
return item.author === viewerId || arr(item[membersField]).includes(viewerId);
};
const visibleThenCollapsed = (items, viewerId) =>
collapse(arr(items).filter((x) => isVisible(x, viewerId)));
return { identitySignature, mergePair, collapse, fold, isVisible, visibleThenCollapsed };
};
module.exports = { collabContent, identitySignature, openInviteOf };

280
src/backend/contentPdf.js Normal file
View file

@ -0,0 +1,280 @@
const { buildDocumentPdf } = require('./pdfDocument');
const fmtDate = v => {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? String(v) : d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
};
const fmtDay = v => {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10);
};
const asList = v => (Array.isArray(v) ? v : []);
const txt = v => (v == null ? '' : String(v));
const humanLabel = (key) => String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, c => c.toUpperCase());
const pushMeta = (out, item) => {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'METADATA' });
if (item.id) out.push({ kind: 'kv', label: 'Content ID', value: item.id });
const author = item.author || item.organizer || item.createdBy || item.from || '';
if (author) out.push({ kind: 'kv', label: 'Author', value: author });
if (item.createdAt) out.push({ kind: 'kv', label: 'Created At', value: fmtDate(item.createdAt) });
if (item.updatedAt) out.push({ kind: 'kv', label: 'Updated At', value: fmtDate(item.updatedAt) });
};
const pushOpinions = (out, item) => {
const op = item.opinions && typeof item.opinions === 'object' ? item.opinions : {};
const entries = Object.entries(op).filter(([, n]) => Number(n) > 0);
if (!entries.length) return;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'OPINIONS' });
for (const [cat, n] of entries) out.push({ kind: 'kv', label: humanLabel(cat), value: String(n) });
};
const pushTags = (out, item) => {
const tags = asList(item.tags).filter(Boolean);
if (tags.length) out.push({ kind: 'kv', label: 'Tags', value: tags.join(', ') });
};
const reportSections = (report) => {
const out = [];
out.push({ kind: 'title', text: `Report: ${txt(report.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'CLASSIFICATION' });
out.push({ kind: 'kv', label: 'Category', value: txt(report.category).toUpperCase() });
out.push({ kind: 'kv', label: 'Severity', value: txt(report.severity).toUpperCase() });
out.push({ kind: 'kv', label: 'Status', value: txt(report.status).toUpperCase() });
pushTags(out, report);
if (txt(report.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: report.description });
}
const tpl = report.template && typeof report.template === 'object' ? report.template : {};
const tplEntries = Object.entries(tpl).filter(([, v]) => txt(v).trim());
if (tplEntries.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DETAILS' });
for (const [k, v] of tplEntries) out.push({ kind: 'kv', label: humanLabel(k), value: v });
}
const confirmations = asList(report.confirmations);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'CONFIRMATIONS' });
out.push({ kind: 'kv', label: 'Confirmed', value: String(confirmations.length) });
for (const c of confirmations) out.push({ kind: 'kv', label: 'Confirmed by', value: c });
pushOpinions(out, report);
pushMeta(out, report);
return out;
};
const voteSections = (vote) => {
const out = [];
out.push({ kind: 'title', text: `Votation: ${txt(vote.question) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'TERMS' });
out.push({ kind: 'kv', label: 'Status', value: txt(vote.status).toUpperCase() });
if (vote.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(vote.deadline) });
pushTags(out, vote);
const votes = vote.votes && typeof vote.votes === 'object' ? vote.votes : {};
const total = Number(vote.totalVotes) || asList(vote.voters).length;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'RESULTS' });
out.push({ kind: 'kv', label: 'Total votes', value: String(total) });
for (const [choice, n] of Object.entries(votes)) {
const count = Number(n) || 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
out.push({ kind: 'kv', label: choice, value: `${count} (${pct}%)` });
}
pushOpinions(out, vote);
pushMeta(out, vote);
return out;
};
const eventSections = (event) => {
const out = [];
out.push({ kind: 'title', text: `Event: ${txt(event.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SCHEDULE' });
out.push({ kind: 'kv', label: 'Date', value: fmtDate(event.date) });
out.push({ kind: 'kv', label: 'Status', value: txt(event.status).toUpperCase() });
if (event.location) out.push({ kind: 'kv', label: 'Location', value: event.location });
if (event.mapUrl) out.push({ kind: 'kv', label: 'Map', value: event.mapUrl });
if (Number(event.price) > 0) out.push({ kind: 'kv', label: 'Price', value: `${Number(event.price)} ECO` });
out.push({ kind: 'kv', label: 'Privacy', value: txt(event.isPublic).toUpperCase() });
if (event.url) out.push({ kind: 'kv', label: 'Url', value: event.url });
pushTags(out, event);
if (txt(event.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: event.description });
}
const attendees = asList(event.attendees);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'ATTENDEES' });
out.push({ kind: 'kv', label: 'Total', value: String(attendees.length) });
for (const a of attendees) out.push({ kind: 'kv', label: 'Attendee', value: a });
pushOpinions(out, event);
pushMeta(out, event);
return out;
};
const taskSections = (task) => {
const out = [];
out.push({ kind: 'title', text: `Task: ${txt(task.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SCHEDULE' });
out.push({ kind: 'kv', label: 'Status', value: txt(task.status).toUpperCase() });
out.push({ kind: 'kv', label: 'Priority', value: txt(task.priority).toUpperCase() });
if (task.startTime) out.push({ kind: 'kv', label: 'Starts', value: fmtDate(task.startTime) });
if (task.endTime) out.push({ kind: 'kv', label: 'Ends', value: fmtDate(task.endTime) });
if (task.location) out.push({ kind: 'kv', label: 'Location', value: task.location });
out.push({ kind: 'kv', label: 'Privacy', value: txt(task.isPublic).toUpperCase() });
pushTags(out, task);
if (txt(task.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: task.description });
}
const assignees = asList(task.assignees);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'ASSIGNEES' });
out.push({ kind: 'kv', label: 'Total', value: String(assignees.length) });
for (const a of assignees) out.push({ kind: 'kv', label: 'Assignee', value: a });
pushOpinions(out, task);
pushMeta(out, task);
return out;
};
const calendarSections = (calendar, extra = {}) => {
const dates = asList(extra.dates);
const notesByDate = extra.notesByDate && typeof extra.notesByDate === 'object' ? extra.notesByDate : {};
const out = [];
out.push({ kind: 'title', text: `Calendar: ${txt(calendar.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SUMMARY' });
out.push({ kind: 'kv', label: 'Status', value: calendar.isClosed ? 'CLOSED' : txt(calendar.status).toUpperCase() });
if (calendar.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(calendar.deadline) });
if (calendar.mapUrl) out.push({ kind: 'kv', label: 'Map', value: calendar.mapUrl });
out.push({ kind: 'kv', label: 'Participants', value: String(asList(calendar.participants).length) });
out.push({ kind: 'kv', label: 'Dates', value: String(dates.length) });
const noteTotal = Object.values(notesByDate).reduce((n, arr) => n + asList(arr).length, 0);
out.push({ kind: 'kv', label: 'Notes', value: String(noteTotal) });
pushTags(out, calendar);
const participants = asList(calendar.participants);
if (participants.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'PARTICIPANTS' });
for (const p of participants) out.push({ kind: 'kv', label: 'Participant', value: p });
}
if (dates.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DATES' });
for (const d of dates) {
const day = fmtDay(d && d.date) || txt(d && d.date);
out.push({ kind: 'kv', label: day, value: txt(d && d.label) });
for (const note of asList(notesByDate[d && d.key])) {
const noteText = txt(note && note.text);
if (noteText) out.push({ kind: 'text', text: ` - ${noteText}` });
}
}
}
pushMeta(out, calendar);
return out;
};
const cvSections = (cv) => {
const out = [];
out.push({ kind: 'title', text: `Curriculum: ${txt(cv.name) || txt(cv.author) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'PROFILE' });
if (cv.location) out.push({ kind: 'kv', label: 'Location', value: cv.location });
if (cv.status) out.push({ kind: 'kv', label: 'Status', value: txt(cv.status).toUpperCase() });
if (cv.preferences) out.push({ kind: 'kv', label: 'Preferences', value: txt(cv.preferences).toUpperCase() });
if (cv.languages) out.push({ kind: 'kv', label: 'Languages', value: cv.languages });
if (txt(cv.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: cv.description });
}
const blocks = [
['PERSONAL', cv.personalExperiences, cv.personalSkills],
['EDUCATION', cv.educationExperiences, cv.educationalSkills],
['PROFESSIONAL', cv.professionalExperiences, cv.professionalSkills],
['OASIS', cv.oasisExperiences, cv.oasisSkills]
];
for (const [heading, experiences, skills] of blocks) {
const skillList = asList(skills).filter(Boolean);
const body = txt(experiences).trim();
if (!body && !skillList.length) continue;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: heading });
if (body) out.push({ kind: 'text', text: body });
if (skillList.length) out.push({ kind: 'kv', label: 'Skills', value: skillList.join(', ') });
}
pushMeta(out, cv);
return out;
};
const BUILDERS = {
reports: { title: 'OASIS - Report', sections: reportSections, name: item => item.title },
votes: { title: 'OASIS - Votation', sections: voteSections, name: item => item.question },
events: { title: 'OASIS - Event', sections: eventSections, name: item => item.title },
tasks: { title: 'OASIS - Task', sections: taskSections, name: item => item.title },
calendars: { title: 'OASIS - Calendar', sections: calendarSections, name: item => item.title },
cv: { title: 'OASIS - Curriculum', sections: cvSections, name: item => item.name || item.author }
};
const isSupported = kind => Object.prototype.hasOwnProperty.call(BUILDERS, kind);
const pdfFilename = (kind, item) => {
const b = BUILDERS[kind];
const raw = b ? txt(b.name(item || {})) : '';
const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
return `oasis-${kind}-${slug || 'document'}.pdf`;
};
const buildContentPdf = (kind, item, extra = {}, viewerId = null) => {
const b = BUILDERS[kind];
if (!b) throw new Error('Unsupported pdf kind');
return buildDocumentPdf({
title: b.title,
issuedToLabel: 'Issued to',
issuedTo: viewerId || null,
sections: b.sections(item || {}, extra)
});
};
module.exports = { buildContentPdf, pdfFilename, isSupported };

View file

@ -0,0 +1,153 @@
const fs = require("fs");
const path = require("path");
const TEMPLATE = path.join(__dirname, "../configs/content_favorites.json");
const storePath = () => {
try {
return require("../server/ssb_config").statePath("content_favorites.json") || TEMPLATE;
} catch (_) { return TEMPLATE; }
};
const DEFAULT = {
audios: [],
blogs: [],
bookmarks: [],
calendars: [],
chats: [],
documents: [],
events: [],
forum: [],
housing: [],
images: [],
jobs: [],
logs: [],
maps: [],
pads: [],
polls: [],
projects: [],
reports: [],
shops: [],
market: [],
shopProducts: [],
tasks: [],
torrents: [],
transfers: [],
videos: [],
votes: []
};
const safeArr = (v) => (Array.isArray(v) ? v : []);
let queue = Promise.resolve();
const withLock = (fn) => {
queue = queue.then(fn, fn);
return queue;
};
const normalize = (raw) => {
const out = {};
for (const k of Object.keys(DEFAULT)) {
const list = safeArr(raw?.[k]).map((x) => String(x || "").trim()).filter(Boolean);
out[k] = Array.from(new Set(list));
}
return out;
};
const ensureFile = async () => {
try {
await fs.promises.access(storePath());
} catch (e) {
const dir = path.dirname(storePath());
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(storePath(), JSON.stringify(DEFAULT, null, 2), "utf8");
}
};
const readAll = async () => {
await ensureFile();
try {
const txt = await fs.promises.readFile(storePath(), "utf8");
return normalize(JSON.parse(txt || "{}"));
} catch (e) {
const fixed = normalize(DEFAULT);
await fs.promises.writeFile(storePath(), JSON.stringify(fixed, null, 2), "utf8");
return fixed;
}
};
const writeAll = async (data) => {
const dir = path.dirname(storePath());
const tmp = path.join(dir, `.content_favorites.${process.pid}.${Date.now()}.tmp`);
const txt = JSON.stringify(normalize(data), null, 2);
await fs.promises.writeFile(tmp, txt, "utf8");
await fs.promises.rename(tmp, storePath());
};
const assertKind = (kind) => {
const k = String(kind || "").trim();
if (!Object.prototype.hasOwnProperty.call(DEFAULT, k)) throw new Error("Invalid favorites kind");
return k;
};
const lastArg = (args, n) => args[args.length - n];
const kindFromArgs = (args) => {
const k = String(lastArg(args, 1) || "").trim();
return assertKind(k);
};
const idFromArgs = (args) => String(lastArg(args, 1) || "").trim();
exports.getFavoriteSet = async (kind) => {
const k = assertKind(kind);
const data = await readAll();
return new Set(safeArr(data[k]).map(String));
};
exports.getFavoriteIndex = async () => {
const data = await readAll();
const index = new Map();
for (const kind of Object.keys(DEFAULT)) {
for (const id of safeArr(data[kind])) index.set(String(id), kind);
}
return index;
};
exports.addFavorite = async (kind, id) =>
withLock(async () => {
const k = assertKind(kind);
const favId = String(id || "").trim();
if (!favId) return;
const data = await readAll();
const set = new Set(safeArr(data[k]).map(String));
set.add(favId);
data[k] = Array.from(set);
await writeAll(data);
});
exports.removeFavorite = async (kind, id) =>
withLock(async () => {
const k = assertKind(kind);
const favId = String(id || "").trim();
if (!favId) return;
const data = await readAll();
const set = new Set(safeArr(data[k]).map(String));
set.delete(favId);
data[k] = Array.from(set);
await writeAll(data);
});
exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args));
exports.addToFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.addFavorite(kind, id);
};
exports.removeFromFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.removeFavorite(kind, id);
};
exports.storePath = storePath;

52
src/backend/dedupe.js Normal file
View file

@ -0,0 +1,52 @@
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
const dedupeBy = (items, keyFn) => {
const seen = new Set();
const out = [];
for (const it of (Array.isArray(items) ? items : [])) {
let k = null;
try { k = keyFn(it); } catch (_) { k = null; }
if (k == null || k === '') { out.push(it); continue; }
if (seen.has(k)) continue;
seen.add(k);
out.push(it);
}
return out;
};
const mergeDuplicatesBy = (items, keyFn, mergeFn) => {
const slots = new Map();
const out = [];
for (const it of (Array.isArray(items) ? items : [])) {
let k = null;
try { k = keyFn(it); } catch (_) { k = null; }
if (k == null || k === '') { out.push(it); continue; }
if (!slots.has(k)) {
const slot = { i: out.length, v: it };
slots.set(k, slot);
out.push(it);
} else {
const slot = slots.get(k);
slot.v = mergeFn(slot.v, it);
out[slot.i] = slot.v;
}
}
return out;
};
const dedupeByPreferring = (items, keyFn, scoreFn) => {
const best = new Map();
const order = [];
for (const it of (Array.isArray(items) ? items : [])) {
let k = null;
try { k = keyFn(it); } catch (_) { k = null; }
if (k == null || k === '') { order.push({ solo: it }); continue; }
let s = 0;
try { s = Number(scoreFn(it)) || 0; } catch (_) { s = 0; }
if (!best.has(k)) { const slot = { v: it, s }; best.set(k, slot); order.push({ slot }); }
else { const slot = best.get(k); if (s > slot.s) { slot.v = it; slot.s = s; } }
}
return order.map(o => (o.solo !== undefined ? o.solo : o.slot.v));
};
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm };

View file

@ -0,0 +1,74 @@
const crypto = require('crypto');
const ALG = 'aes-256-gcm';
const IV_LEN = 12;
const TAG_LEN = 16;
const KEY_LEN = 32;
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
const generateFileKey = () => crypto.randomBytes(KEY_LEN);
const keyToHex = (key) => Buffer.isBuffer(key) ? key.toString('hex') : String(key);
const keyFromHex = (hex) => {
const buf = Buffer.from(String(hex), 'hex');
if (buf.length !== KEY_LEN) throw new Error('Invalid file key');
return buf;
};
const encryptChunk = (plaintext, key) => {
const iv = crypto.randomBytes(IV_LEN);
const cipher = crypto.createCipheriv(ALG, key, iv);
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, ct]);
};
const decryptChunk = (payload, key) => {
if (!Buffer.isBuffer(payload) || payload.length < IV_LEN + TAG_LEN) throw new Error('Corrupt chunk');
const iv = payload.subarray(0, IV_LEN);
const tag = payload.subarray(IV_LEN, IV_LEN + TAG_LEN);
const ct = payload.subarray(IV_LEN + TAG_LEN);
const decipher = crypto.createDecipheriv(ALG, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]);
};
const sha256Hex = (buffer) => crypto.createHash('sha256').update(buffer).digest('hex');
const splitBuffer = (buffer, chunkSize = DEFAULT_CHUNK_SIZE) => {
const size = Math.max(1, Number(chunkSize) || DEFAULT_CHUNK_SIZE);
const out = [];
for (let off = 0; off < buffer.length; off += size) {
out.push(buffer.subarray(off, Math.min(off + size, buffer.length)));
}
if (!out.length) out.push(Buffer.alloc(0));
return out;
};
const buildManifest = ({ filename, mime, size, chunkSize, chunkHashes, plainSha256 }) => ({
v: 1,
alg: ALG,
filename: String(filename || 'file'),
mime: String(mime || 'application/octet-stream'),
size: Number(size) || 0,
chunkSize: Number(chunkSize) || DEFAULT_CHUNK_SIZE,
chunks: Array.isArray(chunkHashes) ? chunkHashes.slice() : [],
plainSha256: plainSha256 || null
});
const encryptManifest = (manifest, key) => encryptChunk(Buffer.from(JSON.stringify(manifest), 'utf8'), key);
const decryptManifest = (payload, key) => {
const json = decryptChunk(payload, key).toString('utf8');
const m = JSON.parse(json);
if (!m || m.v !== 1 || !Array.isArray(m.chunks)) throw new Error('Invalid manifest');
return m;
};
module.exports = {
ALG, IV_LEN, TAG_LEN, KEY_LEN, DEFAULT_CHUNK_SIZE,
generateFileKey, keyToHex, keyFromHex,
encryptChunk, decryptChunk, sha256Hex, splitBuffer,
buildManifest, encryptManifest, decryptManifest
};

208
src/backend/fork_routes.js Normal file
View file

@ -0,0 +1,208 @@
"use strict";
// Rutas propias de la rama experimental (modulo Karvan: salas efimeras en RAM,
// mensajes temporales y senalizacion WebRTC).
//
// Viven fuera de backend.js a proposito: ese fichero concentra las 577 rutas de
// upstream en una sola cadena fluida y se mueve +905/-298 lineas por release, asi
// que cualquier ruta anadida en medio entra en conflicto en cada integracion.
// Aqui se montan en un router propio que se inserta en la cadena de middleware
// antes del router de upstream; lo que no casa cae a next() y sigue su curso.
//
// Las dependencias se inyectan en lugar de reconstruirse: cooler abre la conexion
// al sbot y duplicarla abriria un segundo cliente muxrpc.
const koaRouter = require("../server/node_modules/@koa/router");
const { koaBody } = require("../server/node_modules/koa-body");
const { stripDangerousTags } = require("./sanitizeHtml");
const { buildIceServers } = require("./turnCredentials");
const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC)
const { karvanView, karvanRoomView } = require("../views/karvan_view");
module.exports = ({ cooler, pull, pmModel, checkMod, getViewerId,
getConfig, saveConfig, legacyModel, onboardingModel,
isLoopbackRequest, sendErrorPage }) => {
// FORK_IA_UX Fase 2 (cross-device): relay de MENSAJES de Karvan por privados SSB. Funciona vía un pub
// (el privado se replica al otro móvil); los SIGNAL de WebRTC NO se relayan (serían demasiados/lentos por el log).
// No toca el arranque del SSB. Ingesta por stream EN VIVO (no polling) → eficiente.
const karvanSeenRelay = new Set(); // mids ya inyectados (evita duplicados/bucles)
let karvanRelaySub = false; // ¿suscriptor del log en vivo montado?
async function karvanPublishRelay(roomId, mid, from, text) {
try {
const feeds = karvanModel.getRemoteFeeds(roomId);
if (!feeds.length) return;
const ssbClient = await cooler.open();
const recps = [...new Set([ssbClient.id, ...feeds])].slice(0, 8);
await new Promise((res, rej) => ssbClient.private.publish(
{ type: 'karvan-relay', roomId, mid: String(mid || '').slice(0, 60), from: String(from || '?').slice(0, 80), text: String(text || '').slice(0, 2000), private: true },
recps, (e, m) => e ? rej(e) : res(m)));
} catch (e) {}
}
async function karvanStartRelay() {
if (karvanRelaySub) return;
karvanRelaySub = true;
try {
const ssbClient = await cooler.open();
const me = ssbClient.id;
pull(
ssbClient.createLogStream({ old: false, live: true }),
pull.drain((m) => {
try {
if (!m || !m.value) return;
let dec; try { dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.timestamp }); } catch (_) { return; }
const c = dec && dec.value && dec.value.content;
if (!c || c.type !== 'karvan-relay' || !c.roomId || !c.mid) return;
if (dec.value.author === me) return; // no reinyectar lo propio (evita bucle A→B→A)
if (karvanSeenRelay.has(c.mid)) return;
if (!karvanModel.getRoom(c.roomId)) return; // solo si tenemos esa sala localmente
karvanSeenRelay.add(c.mid);
if (karvanSeenRelay.size > 5000) karvanSeenRelay.clear();
karvanModel.postMessage(c.roomId, { from: c.from, text: c.text, mid: c.mid });
} catch (e) {}
})
);
} catch (e) { karvanRelaySub = false; }
}
// ¿quién me invitó a esta sala? (para registrar su feed en el espejo local) — reutiliza el lector de privados
async function karvanInviteAuthor(roomId) {
try {
const msgs = await pmModel.listAllPrivate();
for (const m of (msgs || [])) {
const c = m && m.value && m.value.content;
if (c && c.subject === 'KARVAN_INVITE' && typeof c.text === 'string' && c.text.indexOf('/karvan/' + roomId) !== -1) return m.value.author;
}
} catch (e) {}
return null;
}
const forkRouter = new koaRouter();
forkRouter
// Configuracion ICE para las llamadas. Solo loopback: la credencial TURN va
// firmada con el secreto del nodo y no debe salir de la maquina.
.get('/karvan/ice', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = { error: 'forbidden' }; return; }
let who = ''; try { who = getViewerId() || ''; } catch (e) {}
let rtc = null; try { rtc = (getConfig() || {}).rtc || null; } catch (e) {}
ctx.body = buildIceServers(rtc, who);
})
// ===== FORK_IA_UX: módulo KARVAN (mensajes temporales RAM + WebRTC data-channel) =====
.get('/karvan', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
ctx.body = karvanView(karvanModel.listRooms(), { selfId });
})
.post('/karvan/create', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
const b = ctx.request.body || {};
const room = karvanModel.createRoom({
title: stripDangerousTags(String(b.title || '').trim()).slice(0, 80),
ttlMinutes: b.ttl,
});
ctx.redirect('/karvan/' + room.id);
})
.get('/karvan/:id', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; }
let room = karvanModel.getRoom(ctx.params.id);
// unirse por enlace/invitación crea un espejo local, pero SOLO en una navegación real (documento):
// un <img>/subrecurso de una web maliciosa NO debe poder crear/expulsar salas por GET (CSRF). El navegador
// controla sec-fetch-dest/accept, así que son señales fiables.
let justAdopted = false;
if (!room) {
const isNav = ctx.get('sec-fetch-dest') === 'document' || (ctx.get('accept') || '').includes('text/html');
if (isNav) { room = karvanModel.adoptRoom({ id: ctx.params.id }); justAdopted = !!room; }
}
if (!room) { ctx.redirect('/karvan'); return; }
if (justAdopted) { // al unirte por enlace, registra el feed de quien te invitó → relay cross-device
const author = await karvanInviteAuthor(ctx.params.id);
if (author) { karvanModel.addRemoteFeed(ctx.params.id, author); karvanStartRelay(); }
}
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
const msgs = karvanModel.listMessages(room.id, 0) || [];
ctx.body = karvanRoomView(room, msgs, { selfId, invite: ctx.query.invite || '' });
})
.post('/karvan/:id/invite', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const id = ctx.params.id;
const room = karvanModel.getRoom(id);
if (!room) { ctx.redirect('/karvan'); return; }
const to = String((ctx.request.body || {}).to || '').trim();
if (!/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(to)) { ctx.redirect('/karvan/' + id + '?invite=bad'); return; }
try {
// reutiliza el patrón de invitación por mensaje privado de Oasis (como el módulo industry):
// le llega al INBOX del contacto con un enlace a la sala. Sin código SSB nuevo.
await pmModel.sendMessage([to], 'KARVAN_INVITE',
'You have been invited to an ephemeral Karvan room' + (room.title ? ' ("' + room.title + '")' : '') + ' -> /karvan/' + id);
karvanModel.addRemoteFeed(id, to); karvanStartRelay(); // relay cross-device hacia el invitado
ctx.redirect('/karvan/' + id + '?invite=ok');
} catch (e) { ctx.redirect('/karvan/' + id + '?invite=fail'); }
})
.post('/karvan/:id/msg', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const b = ctx.request.body || {};
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
const msg = karvanModel.postMessage(ctx.params.id, {
from: String(b.from || selfId || '?').slice(0, 80),
text: stripDangerousTags(String(b.text || '')).slice(0, 2000),
mid: b.mid,
});
if (!msg) { ctx.status = 404; ctx.body = { error: 'no room' }; return; }
karvanPublishRelay(ctx.params.id, msg.mid || (msg.from + '-' + msg.ts + '-' + msg.seq), msg.from, msg.text); // → feeds remotos (si hay)
const wantsJson = (ctx.get('accept') || '').indexOf('application/json') !== -1
|| (ctx.get('content-type') || '').indexOf('application/json') !== -1;
if (wantsJson) ctx.body = { ok: true, seq: msg.seq };
else ctx.redirect('/karvan/' + ctx.params.id);
})
.get('/karvan/:id/msgs', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const msgs = karvanModel.listMessages(ctx.params.id, ctx.query.since || 0);
ctx.body = { messages: msgs || [] };
})
.post('/karvan/:id/signal', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const b = ctx.request.body || {};
karvanModel.postSignal(ctx.params.id, { from: b.from, to: b.to, payload: b.payload });
ctx.body = { ok: true };
})
.get('/karvan/:id/signal', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const r = karvanModel.getSignals(ctx.params.id, { forId: ctx.query.for || '', since: ctx.query.since || 0 });
ctx.body = r || { signals: [], members: [] };
})
.get("/settings/bottombar", async (ctx) => {
ctx.body = require("../views/main_views").bottomBarPickerView();
})
.post("/settings/bottombar", koaBody(), async (ctx) => {
const cfg = getConfig();
const MAX = 4;
const body = ctx.request.body || {};
const action = String(body.action || '').trim();
const key = String(body.key || '').trim();
const catalog = require("../views/main_views").PINNABLE_MODULES || [];
let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : [];
if (action === 'add') {
if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key);
} else if (action === 'remove') {
pins = pins.filter(k => k !== key);
} else if (action === 'clear') {
pins = [];
}
cfg.bottomBarPins = pins.slice(0, MAX);
saveConfig(cfg);
ctx.redirect('/settings/bottombar');
})
.get('/legacy/export', async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const pw = ctx.query && ctx.query.password;
if (!pw || pw.length < 32) return ctx.redirect('/legacy');
try {
const { filename, data } = await legacyModel.exportData({ password: pw });
ctx.set('Content-Type', 'application/octet-stream');
ctx.set('Content-Disposition', `attachment; filename="${filename}"`);
ctx.set('Content-Length', String(data.length));
ctx.body = data;
try { onboardingModel.markStep('backup'); } catch (_) {}
} catch (error) { sendErrorPage(ctx, error.message); }
});
return forkRouter.routes();
};

206
src/backend/logsPdf.js Normal file
View file

@ -0,0 +1,206 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const { escapePdf } = require('./pdfDocument');
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
const splitSegments = (line) => {
const segs = [];
let last = 0;
const re = new RegExp(linkPattern.source, 'g');
let m;
while ((m = re.exec(line)) !== null) {
if (m.index > last) segs.push({ t: line.slice(last, m.index), l: false });
segs.push({ t: m[0], l: true });
last = m.index + m[0].length;
}
if (last < line.length) segs.push({ t: line.slice(last), l: false });
return segs;
};
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt || '').split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
function buildLogsPdf(entries, oasisId, opts = {}) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 22;
const bodyBottom = footerH + 10;
const lineH = 12;
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const allLines = [];
for (const e of entries) {
const ts = new Date(e.ts);
const when = ts.toISOString().replace('T', ' ').slice(0, 19);
allLines.push({ kind: 'header', text: `[${when}]:` });
allLines.push({ kind: 'blank', text: '' });
for (const l of wrap(e.text, 82)) allLines.push({ kind: 'text', text: l });
allLines.push({ kind: 'blank', text: '' });
}
if (!allLines.length) allLines.push({ kind: 'text', text: '(no entries)' });
const pages = [];
for (let i = 0; i < allLines.length; i += maxBodyLines) {
pages.push(allLines.slice(i, i + maxBodyLines));
}
if (!pages.length) pages.push([{ kind: 'text', text: '(no entries)' }]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const colorSpace = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${colorSpace} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
const logoX = marginX;
const logoY = pageH - headerH + 15;
parts.push(`q\n${logoW} 0 0 ${logoH} ${logoX} ${logoY} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Experience logs')}) Tj\nET`);
const inhabitantPrefix = 'Inhabitant: ';
const inhabitantPrefixW = inhabitantPrefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(inhabitantPrefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + inhabitantPrefixW} ${titleY - 16} Td\n(${escapePdf(String(oasisId || ''))}) Tj\nET`);
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
const charW = 6;
for (const ln of pg) {
if (ln.kind === 'header') {
parts.push(`BT\n/F2 10 Tf\n1 0.647 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.text) {
const segs = splitSegments(ln.text);
let x = marginX;
for (const s of segs) {
if (!s.t) continue;
const color = s.l ? '0 0 1 rg' : '0 0 0 rg';
parts.push(`BT\n/F1 10 Tf\n${color}\n${x} ${y} Td\n(${escapePdf(s.t)}) Tj\nET`);
x += s.t.length * charW;
}
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) {
xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildLogsPdf };

View file

@ -0,0 +1,126 @@
const fs = require("fs");
const path = require("path");
const FILE = path.join(__dirname, "../configs/media-favorites.json");
const DEFAULT = {
audios: [],
bookmarks: [],
calendars: [],
chats: [],
documents: [],
images: [],
maps: [],
pads: [],
shops: [],
market: [],
shopProducts: [],
torrents: [],
videos: []
};
const safeArr = (v) => (Array.isArray(v) ? v : []);
let queue = Promise.resolve();
const withLock = (fn) => {
queue = queue.then(fn, fn);
return queue;
};
const normalize = (raw) => {
const out = {};
for (const k of Object.keys(DEFAULT)) {
const list = safeArr(raw?.[k]).map((x) => String(x || "").trim()).filter(Boolean);
out[k] = Array.from(new Set(list));
}
return out;
};
const ensureFile = async () => {
try {
await fs.promises.access(FILE);
} catch (e) {
const dir = path.dirname(FILE);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(FILE, JSON.stringify(DEFAULT, null, 2), "utf8");
}
};
const readAll = async () => {
await ensureFile();
try {
const txt = await fs.promises.readFile(FILE, "utf8");
return normalize(JSON.parse(txt || "{}"));
} catch (e) {
const fixed = normalize(DEFAULT);
await fs.promises.writeFile(FILE, JSON.stringify(fixed, null, 2), "utf8");
return fixed;
}
};
const writeAll = async (data) => {
const dir = path.dirname(FILE);
const tmp = path.join(dir, `.media-favorites.${process.pid}.${Date.now()}.tmp`);
const txt = JSON.stringify(normalize(data), null, 2);
await fs.promises.writeFile(tmp, txt, "utf8");
await fs.promises.rename(tmp, FILE);
};
const assertKind = (kind) => {
const k = String(kind || "").trim();
if (!Object.prototype.hasOwnProperty.call(DEFAULT, k)) throw new Error("Invalid favorites kind");
return k;
};
const lastArg = (args, n) => args[args.length - n];
const kindFromArgs = (args) => {
const k = String(lastArg(args, 1) || "").trim();
return assertKind(k);
};
const idFromArgs = (args) => String(lastArg(args, 1) || "").trim();
exports.getFavoriteSet = async (kind) => {
const k = assertKind(kind);
const data = await readAll();
return new Set(safeArr(data[k]).map(String));
};
exports.addFavorite = async (kind, id) =>
withLock(async () => {
const k = assertKind(kind);
const favId = String(id || "").trim();
if (!favId) return;
const data = await readAll();
const set = new Set(safeArr(data[k]).map(String));
set.add(favId);
data[k] = Array.from(set);
await writeAll(data);
});
exports.removeFavorite = async (kind, id) =>
withLock(async () => {
const k = assertKind(kind);
const favId = String(id || "").trim();
if (!favId) return;
const data = await readAll();
const set = new Set(safeArr(data[k]).map(String));
set.delete(favId);
data[k] = Array.from(set);
await writeAll(data);
});
exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args));
exports.addToFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.addFavorite(kind, id);
};
exports.removeFromFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.removeFavorite(kind, id);
};

20
src/backend/nameCache.js Normal file
View file

@ -0,0 +1,20 @@
const cache = new Map()
const get = (feedId) => {
if (!feedId) return null
const entry = cache.get(String(feedId))
return entry ? entry.name : null
}
const set = (feedId, name, ts) => {
if (!feedId || typeof name !== 'string' || !name) return
const id = String(feedId)
const t = Number(ts) || 0
const prev = cache.get(id)
if (!prev || (prev.ts || 0) <= t) cache.set(id, { name, ts: t })
}
const has = (feedId) => !!feedId && cache.has(String(feedId))
const size = () => cache.size
module.exports = { get, set, has, size }

View file

@ -0,0 +1,50 @@
const positive = [
"interesting",
"necessary",
"useful",
"informative",
"wellResearched",
"accurate",
"insightful",
"actionable",
"creative",
"inspiring",
"love",
"funny",
"clear",
"uplifting"
];
const constructive = [
"unnecessary",
"rejected",
"needsSources",
"wrong",
"lowQuality",
"confusing",
"misleading",
"offTopic",
"duplicate",
"clickbait",
"propaganda"
];
const moderation = [
"spam",
"troll",
"adultOnly",
"nsfw",
"violent",
"toxic",
"harassment",
"hate",
"scam",
"triggering"
];
const all = [...positive, ...constructive, ...moderation];
all.positive = positive;
all.constructive = constructive;
all.moderation = moderation;
module.exports = all;

199
src/backend/pdfDocument.js Normal file
View file

@ -0,0 +1,199 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const WIN_ANSI_HIGH = {
'\u20AC': '\x80', '\u201A': '\x82', '\u0192': '\x83', '\u201E': '\x84',
'\u2026': '\x85', '\u2020': '\x86', '\u2021': '\x87', '\u02C6': '\x88',
'\u2030': '\x89', '\u0160': '\x8A', '\u2039': '\x8B', '\u0152': '\x8C',
'\u017D': '\x8E', '\u2018': '\x91', '\u2019': '\x92', '\u201C': '\x93',
'\u201D': '\x94', '\u2022': '\x95', '\u2013': '\x96', '\u2014': '\x97',
'\u02DC': '\x98', '\u2122': '\x99', '\u0161': '\x9A', '\u203A': '\x9B',
'\u0153': '\x9C', '\u017E': '\x9E', '\u0178': '\x9F'
};
const escapePdf = s => String(s == null ? '' : s)
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\u0152\u0153\u0160\u0161\u0178\u017D\u017E\u0192\u02C6\u02DC\u2013\u2014\u2018\u2019\u201A\u201C\u201D\u201E\u2020\u2021\u2022\u2026\u2030\u2039\u203A\u20AC\u2122]/g,
(c) => WIN_ANSI_HIGH[c] || '?')
.replace(/[^\x20-\xFF\x80-\x9F]/g, '?')
.replace(/\\/g, '\\\\')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)');
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt == null ? '' : txt).split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
const flattenSections = (sections) => {
const lines = [];
for (const s of Array.isArray(sections) ? sections : []) {
if (!s) continue;
if (s.kind === 'kv') {
const txt = `${s.label}: ${s.value == null ? '' : s.value}`;
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
} else if (s.kind === 'text') {
for (const w of wrap(s.text, 82)) lines.push({ kind: 'kv', text: w });
} else {
lines.push({ kind: s.kind, text: s.text });
}
}
return lines;
};
function buildDocumentPdf({ title, issuedToLabel, issuedTo, sections } = {}) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 24;
const bodyBottom = footerH + 10;
const lineH = 14;
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const lines = flattenSections(sections);
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
const pages = [];
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
if (!pages.length) pages.push([]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf(title || 'OASIS')}) Tj\nET`);
if (issuedTo) {
const prefix = `${issuedToLabel || 'Issued to'}: `;
const prefixW = prefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(issuedTo))}) Tj\nET`);
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
for (const ln of pg) {
if (ln.kind === 'title') {
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'subtitle') {
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'section') {
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
} else if (ln.kind === 'kv') {
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildDocumentPdf, escapePdf, wrap };

50
src/backend/pm_policy.js Normal file
View file

@ -0,0 +1,50 @@
const fs = require('fs');
const ANNOUNCE_SEEN_FILE = 'oasis-political-seen';
const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
if (recipientId && viewerId && recipientId === viewerId) return true;
if (pmVisibility !== 'mutuals') return true;
return isMutual(relationship);
};
const announceSeenPath = () => {
try { return require('../server/ssb_config').statePath(ANNOUNCE_SEEN_FILE); } catch (_) { return null; }
};
const readAnnounceSeen = () => {
const file = announceSeenPath();
if (!file) return {};
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) { return {}; }
};
const writeAnnounceSeen = (state) => {
const file = announceSeenPath();
if (!file) return false;
try { fs.writeFileSync(file, JSON.stringify(state || {})); return true; } catch (_) { return false; }
};
const everAnnounced = (announced, subject) => {
for (const entry of (announced || [])) {
if (String(entry).startsWith(`${subject}|`)) return true;
}
return false;
};
const decideAnnouncement = ({ subject, ref, announced = new Set(), seen = {} } = {}) => {
if (!subject || !ref) return { send: false, remember: false };
const key = String(ref);
if (announced.has(`${subject}|${key}`)) return { send: false, remember: false };
if (seen[subject] === key) return { send: false, remember: false };
if (!everAnnounced(announced, subject) && seen[subject] === undefined) {
return { send: false, remember: true };
}
return { send: true, remember: true };
};
module.exports = { isRecipientAllowed, isMutual, announceSeenPath, readAnnounceSeen, writeAnnounceSeen, decideAnnouncement };

View file

@ -0,0 +1,139 @@
const i18nBase = require("../client/assets/translations/i18n");
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
function renderTextPreview(text, maxLength = 220) {
if (!text) return ''
let preview = String(text)
preview = preview
.replace(/```[\s\S]*?```/g, '')
.replace(/^>.*$/gm, '')
.replace(/^#{1,6}\s*/gm, '')
.replace(/^- /gm, '')
.replace(/^\d+\. /gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[.*?\]\(.*?\)/g, '')
.replace(/\n+/g, ' ')
.trim()
if (preview.length > maxLength) {
preview = preview.slice(0, maxLength) + '...'
}
return preview
}
function renderTextWithStyles(text) {
if (!text) return ''
const i18n = getI18n()
let html = String(text)
html = html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
html = html
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
.replace(/^---$/gim, '<hr/>')
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
html = html
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
.replace(/`([^`]+)`/gim, '<code>$1</code>')
html = html
.replace(/!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
`<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />`
)
.replace(/\[video:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></video>`
)
.replace(/\[audio:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></audio>`
)
.replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
)
html = html
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
)
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
)
const escAttr = (s) => String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
html = html
.replace(/#(\w+)/g, (_, tag) =>
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
)
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
`<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
)
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
`<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
)
const lines = html.split('\n')
let result = ''
let inUL = false
let inOL = false
for (let line of lines) {
if (/^- /.test(line)) {
if (!inUL) {
result += '<ul>'
inUL = true
}
result += `<li>${line.replace(/^- /, '')}</li>`
continue
}
if (/^\d+\. /.test(line)) {
if (!inOL) {
result += '<ol>'
inOL = true
}
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
continue
}
if (inUL) {
result += '</ul>'
inUL = false
}
if (inOL) {
result += '</ol>'
inOL = false
}
result += line + '<br>'
}
if (inUL) result += '</ul>'
if (inOL) result += '</ol>'
return result
}
module.exports = { renderTextWithStyles, renderTextPreview }

92
src/backend/renderUrl.js Normal file
View file

@ -0,0 +1,92 @@
const { a, img, video, audio } = require("../server/node_modules/hyperaxe");
const i18nBase = require("../client/assets/translations/i18n");
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
function renderUrl(text) {
if (typeof text !== 'string') return [text];
const blobImageRegex = /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobVideoRegex = /\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobAudioRegex = /\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobPdfRegex = /\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const mdMentionRegex = /\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g;
const rawMentionRegex = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
const urlRegex = /\b(?:https?:\/\/|www\.)[^\s]+/g;
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi;
const allMatches = [];
for (const m of text.matchAll(blobImageRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-image', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobVideoRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-video', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobAudioRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-audio', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobPdfRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-pdf', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(mdMentionRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'md-mention', name: m[1], feedId: m[2] });
}
for (const m of text.matchAll(rawMentionRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'raw-mention', feedId: m[1] });
}
for (const m of text.matchAll(urlRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'url', text: m[0] });
}
for (const m of text.matchAll(emailRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'email', text: m[0] });
}
allMatches.sort((a, b) => a.index - b.index);
const filtered = [];
let lastEnd = 0;
for (const m of allMatches) {
if (m.index < lastEnd) continue;
filtered.push(m);
lastEnd = m.index + m.length;
}
const result = [];
let cursor = 0;
for (const m of filtered) {
if (cursor < m.index) {
result.push(text.slice(cursor, m.index));
}
if (m.type === 'blob-image') {
result.push(img({ src: `/blob/${encodeURIComponent(m.blob)}`, alt: m.name || '', class: 'post-image' }));
} else if (m.type === 'blob-video') {
result.push(video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(m.blob)}` }));
} else if (m.type === 'blob-audio') {
result.push(audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(m.blob)}` }));
} else if (m.type === 'blob-pdf') {
const i18n = getI18n();
const label = m.name || i18n.pdfFallbackLabel || 'PDF';
result.push(a({ href: `/blob/${encodeURIComponent(m.blob)}`, class: 'post-pdf', target: '_blank' }, label));
} else if (m.type === 'md-mention') {
const feedWithAt = '@' + m.feedId;
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.name));
} else if (m.type === 'raw-mention') {
const feedWithAt = '@' + m.feedId;
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...'));
} else if (m.type === 'url') {
const href = m.text.startsWith('http') ? m.text : `https://${m.text}`;
result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
} else if (m.type === 'email') {
result.push(a({ href: `mailto:${m.text}` }, m.text));
}
cursor = m.index + m.length;
}
if (cursor < text.length) {
result.push(text.slice(cursor));
}
return result;
}
module.exports = { renderUrl };

View file

@ -0,0 +1,45 @@
"use strict";
const { JSDOM } = require('../server/node_modules/jsdom');
const DOMPurify = require('../server/node_modules/dompurify');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const stripDangerousTags = (input) => {
if (typeof input !== 'string') return '';
return purify.sanitize(input, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: [
'p', 'br',
'b', 'strong', 'i', 'em', 'u',
'ul', 'ol', 'li',
'blockquote',
'code', 'pre'
],
ALLOWED_ATTR: [],
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script'],
FORBID_ATTR: ['style']
});
};
const sanitizeHtml = (input) => {
if (typeof input !== 'string') return '';
return purify.sanitize(input, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: [
'p', 'br', 'hr',
'b', 'strong', 'i', 'em', 'u', 's', 'del',
'ul', 'ol', 'li',
'blockquote', 'code', 'pre',
'a', 'span', 'div',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'img', 'video', 'audio',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
],
ALLOWED_ATTR: ['href', 'class', 'target', 'rel', 'src', 'alt', 'title', 'controls'],
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script', 'style', 'link', 'meta'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onsubmit', 'onchange', 'style']
});
};
module.exports = { stripDangerousTags, sanitizeHtml };

View file

@ -0,0 +1,228 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const { escapePdf } = require('./pdfDocument');
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt || '').split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
function buildSmartContractPdf({ transfer, block, viewerId }) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 24;
const bodyBottom = footerH + 10;
const lineH = 14;
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const t = transfer || {};
const b = block || {};
const fmt = v => (v === undefined || v === null) ? '' : String(v);
const fmtAmount = () => {
const cat = String(t.category || 'ECONOMIC').toUpperCase();
const unit = cat === 'TIME' ? 'h' : cat === 'TRUST' ? 'trust' : 'ECO';
return `${Number(t.amount || 0).toFixed(6)} ${unit}`;
};
const fmtDate = v => v ? new Date(v).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '';
const confirmedBy = Array.isArray(t.confirmedBy) ? t.confirmedBy : [];
const required = t.from === t.to ? 1 : 2;
const confirmedCount = confirmedBy.length;
const tags = Array.isArray(t.tags) ? t.tags.join(', ') : '';
const sections = [];
sections.push({ kind: 'title', text: `Concept: ${t.concept || '-'}` });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'OASIS IDs' });
sections.push({ kind: 'kv', label: 'From', value: fmt(t.from) });
sections.push({ kind: 'kv', label: 'To', value: fmt(t.to) });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'TERMS' });
sections.push({ kind: 'kv', label: 'Category', value: String(t.category || 'ECONOMIC').toUpperCase() });
if (String(t.category || '').toUpperCase() !== 'TRUST') sections.push({ kind: 'kv', label: 'Amount', value: fmtAmount() });
sections.push({ kind: 'kv', label: 'Status', value: fmt(t.status) });
if (t.deadline) sections.push({ kind: 'kv', label: 'Deadline', value: fmtDate(t.deadline) });
if (tags) sections.push({ kind: 'kv', label: 'Tags', value: tags });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'CONFIRMATIONS' });
sections.push({ kind: 'kv', label: 'Required', value: String(required) });
sections.push({ kind: 'kv', label: 'Confirmed', value: String(confirmedCount) });
if (confirmedBy.length) {
for (const f of confirmedBy) sections.push({ kind: 'kv', label: 'Signed by', value: fmt(f) });
}
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'BLOCK VERIFICATION' });
if (b && b.id) {
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(b.id) });
if (b.ts) sections.push({ kind: 'kv', label: 'Block Timestamp', value: fmtDate(b.ts) });
if (b.type) sections.push({ kind: 'kv', label: 'Block Type', value: String(b.type).toUpperCase() });
if (b.author) sections.push({ kind: 'kv', label: 'Block Author', value: fmt(b.author) });
if (b.size) sections.push({ kind: 'kv', label: 'Block Size', value: `${b.size} bytes` });
} else {
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(t.id) });
}
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'METADATA' });
sections.push({ kind: 'kv', label: 'Transfer ID', value: fmt(t.id) });
if (t.createdAt) sections.push({ kind: 'kv', label: 'Created At', value: fmtDate(t.createdAt) });
if (t.updatedAt) sections.push({ kind: 'kv', label: 'Updated At', value: fmtDate(t.updatedAt) });
const lines = [];
for (const s of sections) {
if (s.kind === 'kv') {
const txt = `${s.label}: ${s.value}`;
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
} else {
lines.push(s);
}
}
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
const pages = [];
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
if (!pages.length) pages.push([]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Smart Contract')}) Tj\nET`);
const prefix = 'Issued to: ';
const prefixW = prefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(viewerId || ''))}) Tj\nET`);
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
for (const ln of pg) {
if (ln.kind === 'title') {
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'subtitle') {
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'section') {
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
} else if (ln.kind === 'kv') {
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildSmartContractPdf };

View file

@ -0,0 +1,50 @@
"use strict";
// Credenciales efimeras para un TURN propio (coturn con use-auth-secret).
//
// Es el mecanismo REST de coturn: el servidor no guarda usuarios, valida que
// credential == base64(HMAC-SHA1(static-auth-secret, username))
// y que el timestamp del username no haya caducado. Asi las credenciales se
// generan sin hablar con coturn y caducan solas.
//
// Portado del turn.js de karvan-web, cambiando sessionId por el feed id: lo que
// identifica a quien llama en Oasis es su feed, no una sesion anonima.
const crypto = require("crypto");
const DEFAULT_TTL = 3600;
// username = <caduca en unix> ":" <identificador>
function mintTurnCredentials({ urls, secret, ttlSec = DEFAULT_TTL }, who, now = Date.now()) {
if (!secret || !Array.isArray(urls) || !urls.length) return null;
const expiry = Math.floor(now / 1000) + ttlSec;
const username = expiry + ":" + String(who || "anon").slice(0, 64);
const credential = crypto.createHmac("sha1", secret).update(username).digest("base64");
return { urls, username, credential, ttl: ttlSec, expiry };
}
// Lee la configuracion de rtc y devuelve lo que el navegador espera en iceServers.
// Formas admitidas en oasis-config.json:
// "rtc": { "stun": ["stun:mi.pub:3478"] }
// "rtc": { "turn": { "urls": [...], "secret": "...", "ttlSec": 3600 }, "relayOnly": true }
// "rtc": { "turn": { "urls": [...], "username": "...", "credential": "..." } } (estatico)
function buildIceServers(rtc, who, now = Date.now()) {
const out = [];
if (!rtc || typeof rtc !== "object") return { iceServers: out, relayOnly: false };
if (Array.isArray(rtc.stun)) {
for (const u of rtc.stun) if (typeof u === "string" && u) out.push({ urls: u });
}
const t = rtc.turn;
if (t && Array.isArray(t.urls) && t.urls.length) {
if (t.secret) {
const c = mintTurnCredentials(t, who, now);
if (c) out.push({ urls: c.urls, username: c.username, credential: c.credential });
} else if (t.username && t.credential) {
out.push({ urls: t.urls, username: t.username, credential: t.credential });
}
}
return { iceServers: out, relayOnly: !!rtc.relayOnly && out.some(s => s.credential) };
}
module.exports = { mintTurnCredentials, buildIceServers };

121
src/backend/updater.js Normal file
View file

@ -0,0 +1,121 @@
const fetch = require('../server/node_modules/node-fetch');
const { existsSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
const { join } = require('path');
const localpackage = join(__dirname, '../server/package.json');
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json'; // Official SNH-Oasis
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json'; // Mirror SNH-Oasis
let printed = false;
async function extractVersionFromText(text) {
try {
const versionMatch = text.match(/"version":\s*"([^"]+)"/);
if (versionMatch) {
return versionMatch[1];
} else {
throw new Error('Version not found in the response.');
}
} catch (error) {
console.error("Error extracting version:", error.message);
return null;
}
}
async function diffVersion(body, callback) {
try {
const remoteData = JSON.parse(body);
const remoteVersion = remoteData.version;
const localData = JSON.parse(readFileSync(localpackage, 'utf8'));
const localVersion = localData.version;
const updateFlagPath = join(__dirname, "../server/.update_required");
if (remoteVersion !== localVersion) {
writeFileSync(updateFlagPath, JSON.stringify({ required: true }));
callback("required");
} else {
if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath);
callback(""); // no updates required
}
} catch (error) {
console.error("Error comparing versions:", error.message);
callback("error");
}
}
async function checkMirror(callback) {
try {
const response = await fetch(remoteUrl2, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Referer': 'https://raw.githubusercontent.com',
'Origin': 'https://raw.githubusercontent.com'
}
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.text();
callback(null, data);
} catch (error) {
console.error("\noasis@version: no updates requested.\n");
callback(error);
}
}
exports.getRemoteVersion = async () => {
if (existsSync('../../.git')) {
try {
const response = await fetch(remoteUrl, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Referer': 'https://code.03c8.net',
'Origin': 'https://code.03c8.net'
}
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.text();
diffVersion(data, (status) => {
if (status === "required" && !printed) {
printed = true;
console.log("\noasis@version: new code updates are available!\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n");
} else if (status === "") {
console.log("\noasis@version: no updates requested.\n");
}
});
} catch (error) {
checkMirror((err, data) => {
if (err) {
console.error("\noasis@version: no updates requested.\n");
} else {
diffVersion(data, (status) => {
if (status === "required" && !printed) {
printed = true;
console.log("\noasis@version: new code updates are available!\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n");
} else {
console.log("oasis@version: no updates requested.\n");
}
});
}
});
}
}
};

116
src/backend/vote_tally.js Normal file
View file

@ -0,0 +1,116 @@
const buildVoteTally = (messages) => {
const nodes = new Map();
const ballots = [];
for (const m of messages) {
const v = m && m.value;
const c = v && v.content;
if (!c) continue;
if (c.type === 'votesVote') { if (c.target) ballots.push({ target: c.target, author: v.author, choice: c.choice }); continue; }
if (c.type !== 'votes') continue;
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts: v.timestamp || m.timestamp || 0 });
}
const prev = new Map();
for (const [k, n] of nodes) {
const t = n.content.replaces;
if (typeof t === 'string' && nodes.has(t)) prev.set(k, t);
}
const childrenOf = new Map();
for (const [k, p] of prev) {
if (!childrenOf.has(p)) childrenOf.set(p, []);
childrenOf.get(p).push(k);
}
const rootOf = (k) => { let x = k, g = 0; while (prev.has(x) && g++ < 100000) x = prev.get(x); return x; };
const groups = new Map();
for (const k of nodes.keys()) { const r = rootOf(k); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(k); }
const tallyByKey = new Map();
for (const [root, keys] of groups.entries()) {
const rootNode = nodes.get(root);
if (!rootNode) continue;
const options = Array.isArray(rootNode.content.options) ? rootNode.content.options : [];
let votesMap = Object.assign({}, rootNode.content.votes || {});
let voters = Array.isArray(rootNode.content.voters) ? rootNode.content.voters.slice() : [];
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
let cur = root;
let g = 0;
let creatorChoice = null;
let creatorCounted = false;
while (g++ < 100000) {
const kids = (childrenOf.get(cur) || []).map(k => nodes.get(k)).filter(Boolean).sort((a, b) => a.ts - b.ts);
if (!kids.length) break;
let advanced = false;
for (const kid of kids) {
const kVotes = Object.assign({}, kid.content.votes || {});
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters.slice() : [];
for (const o of options) if (!(o in kVotes)) kVotes[o] = 0;
if (kid.author === rootNode.author) {
if (!creatorCounted && kVoters.includes(rootNode.author) && !voters.includes(rootNode.author)) {
creatorCounted = true;
let inc = null;
let incCount = 0;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
if (d >= 1) { inc = o; incCount++; }
}
if (incCount === 1) creatorChoice = inc;
}
votesMap = kVotes;
voters = kVoters;
cur = kid.key;
advanced = true;
break;
}
if (voters.includes(kid.author)) continue;
if (kVoters.length !== voters.length + 1) continue;
const added = kVoters.filter(x => !voters.includes(x));
if (added.length !== 1 || added[0] !== kid.author) continue;
let deltas = 0;
let deltaOk = true;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
if (d === 0) continue;
if (d === 1) { deltas++; continue; }
deltaOk = false;
break;
}
if (!deltaOk || deltas !== 1) continue;
votesMap = kVotes;
voters = kVoters;
cur = kid.key;
advanced = true;
break;
}
if (!advanced) break;
}
if (voters.includes(rootNode.author)) {
voters = voters.filter(x => x !== rootNode.author);
if (creatorChoice && (Number(votesMap[creatorChoice]) || 0) > 0) votesMap[creatorChoice] = votesMap[creatorChoice] - 1;
}
const voterSet = new Set(voters);
let totalVotes = voters.length;
const chainKeys = new Set(keys);
for (const b of ballots) {
if (!chainKeys.has(b.target)) continue;
if (b.author === rootNode.author) continue;
if (!b.author || voterSet.has(b.author)) continue;
if (!options.includes(b.choice)) continue;
voterSet.add(b.author);
voters.push(b.author);
votesMap[b.choice] = (votesMap[b.choice] || 0) + 1;
totalVotes += 1;
}
const tally = { votes: votesMap, voters, totalVotes };
for (const k of keys) tallyByKey.set(k, tally);
}
return tallyByKey;
};
module.exports = { buildVoteTally };

View file

@ -0,0 +1,17 @@
const fs = require("fs");
const path = require("path");
const STORAGE_DIR = path.join(__dirname, "..", "configs");
const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
function ensure() {
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
}
function readAll() { ensure(); return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); }
function writeAll(m) { fs.writeFileSync(ADDR_PATH, JSON.stringify(m, null, 2)); }
async function getAddress(userId) { const m = readAll(); return m[userId] || null; }
async function setAddress(userId, address) { const m = readAll(); m[userId] = address; writeAll(m); return true; }
module.exports = { getAddress, setAddress };

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<title>oasis favicon</title>
<text x="0" y="14">🏝️</text>
</svg>

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View file

@ -0,0 +1,92 @@
{
"academia": {
"name": "ACADEMIA",
"short": "aca",
"motto": "Nosce te ipsum…",
"roles": "Educators, teachers, coordinators",
"function": "Education, understanding, talent discovery, selection process",
"description": "All inhabitants start here. ACADEMIA receives newcomers, provides information on the simulation mechanics, presents each house's objectives and runs the selection process.",
"month": 1,
"image": "/assets/larp/images/academia.jpg"
},
"solaris": {
"name": "SolarIS",
"short": "sol",
"motto": "The word makes us human. Violence, beasts.",
"roles": "Rulers, lawyers, diplomats",
"function": "Governance, law, dialogue, politics, communication",
"description": "Holds the political voice. Mediates disputes, drafts norms, builds coalitions through dialogue and persuasion.",
"month": 2,
"image": "/assets/larp/images/solaris.jpg"
},
"arrakis": {
"name": "ARRakis",
"short": "arr",
"motto": "How long do you say that it must be resolved?",
"roles": "Engineers, scientists, technicians",
"function": "Technology, invention, problem-solving, building and maintaining",
"description": "The makers. They keep the machinery running, prototype, repair, automate; favour pragmatic solutions over endless debate.",
"month": 3,
"image": "/assets/larp/images/arrakis.jpg"
},
"terraverde": {
"name": "TERRA.VErDE",
"short": "ter",
"motto": "Love nature, universe and being, because we are all one.",
"roles": "Farmers, ecologists, doctors, nutritionists",
"function": "Healthcare, food, nature, resources, climate management",
"description": "Stewards of the living systems. Manage food, health and the relationship with the biosphere; long timescales, regenerative principles.",
"month": 4,
"image": "/assets/larp/images/terraverde.jpg"
},
"unsystem": {
"name": "UNSYSTem",
"short": "uns",
"motto": "We do not make statements!",
"roles": "Chaos agents, trolls, punks",
"function": "Organization of disorder, tactical chaos",
"description": "Holds the right to question, sabotage, provoke. Resists capture by any other house and keeps the system uncomfortable.",
"month": 5,
"image": "/assets/larp/images/unsystem.jpg"
},
"dogma": {
"name": "DogmA",
"short": "dog",
"motto": "The question is not when to do it, the question is how.",
"roles": "Thinkers, journalists, philosophers",
"function": "Information control, knowledge, philosophy, memory, AI",
"description": "The archive and the discourse. Curates knowledge, drafts narratives, governs how information is produced and propagated.",
"month": 6,
"image": "/assets/larp/images/dogma.jpg"
},
"helix": {
"name": "HeliX",
"short": "hlx",
"motto": "If you do not smile, it's not my revolution.",
"roles": "Clowns, influencers, musicians, priests",
"function": "Entertainment, culture, humor, celebration, human relationships",
"description": "Carries culture, ritual and joy. Holds the social fabric together through art, festival and shared emotion.",
"month": 7,
"image": "/assets/larp/images/helix.jpg"
},
"quark": {
"name": "QuarK",
"short": "quk",
"motto": "Without security, there is no freedom!",
"roles": "Athletes, soldiers, street people",
"function": "Protection, security, defense, survival, family",
"description": "The guard. Trains, defends, organises mutual aid; demands clear loyalties and clear lines.",
"month": 8,
"image": "/assets/larp/images/quark.jpg"
},
"hermandad": {
"name": "HERmanDAD",
"short": "hrm",
"motto": "Grow. Build. Expand!",
"roles": "Architects, builders, investors, industrialists",
"function": "Construction, development, sustainability, logistics, production",
"description": "The builders. Plan large works, organise production lines and supply chains; measure success in things shipped and capacity created.",
"month": 9,
"image": "/assets/larp/images/hermandad.jpg"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,67 @@
.hljs-comment,
.hljs-quote {
color: var(--base03);
}
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: var(--base08);
}
.hljs-number,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: var(--base09);
}
.hljs-attribute {
color: var(--base0A);
}
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: var(--base0B);
}
.hljs-title,
.hljs-section {
color: var(--base0D);
}
.hljs-keyword,
.hljs-selector-tag {
color: var(--base0E);
}
.hljs {
display: block;
overflow-x: auto;
color: var(--base05);
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-string, .hljs-link {
background: #FFA500;
user-select: text;
}

View file

@ -0,0 +1,88 @@
/* Estilos del modulo Karvan (salas efimeras y llamadas).
*
* Viven aparte del tema para que el modulo se vea igual en cualquier tema y
* tambien en escritorio, donde OasisMobile.css no se carga. Mismo patron que
* highlight.css, que tampoco pertenece a ningun tema concreto.
*/
/* --- Reply a mensaje en chats (server-side ?replyTo=, sin JS) --- */
.chat-message-meta .chat-reply-btn { margin-left: auto; padding: 0 4px; color: #FFD700; text-decoration: none; font-size: 15px; opacity: .85; }
.chat-message-self .chat-message-meta .chat-reply-btn { margin-left: 8px; }
.chat-quote {
display: block; border-left: 3px solid #FFD700; padding: 3px 8px; margin: 0 0 5px;
background: rgba(255,215,0,.08); border-radius: 6px; font-size: 12px; line-height: 1.3;
}
.chat-quote-author, .chat-quote-author a { color: #FFD700; font-weight: 700; margin-right: 6px; }
.chat-quote-text { color: #FFDD44; }
.chat-replying-to {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
border-left: 3px solid #FFD700; background: rgba(255,215,0,.10);
padding: 6px 10px; margin-bottom: 8px; border-radius: 8px; font-size: 12px;
}
.chat-replying-label { color: #FFD700; font-weight: 700; }
.chat-reply-cancel { margin-left: auto; color: #FFD700; text-decoration: none; font-weight: 700; font-size: 14px; }
/* ==========================================================================
FORK_IA_UX · Módulo KARVAN (mensajes temporales + WebRTC) chat estilo burbujas
========================================================================== */
.karvan-intro h2 { color: #FFD700; margin: 0 0 6px; }
.karvan-sub { color: #FFDD44; font-size: 13px; }
.karvan-create input[type="text"] { width: 100%; box-sizing: border-box; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
.karvan-ttl { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 10px 0; }
.karvan-ttl-lbl { color: #FFDD44; font-size: 13px; }
.karvan-ttl-opt { display: inline-flex; align-items: center; gap: 4px; color: #FFD700; font-size: 13px; }
.karvan-create button, .karvan-compose button { padding: 10px 16px; border: 1px solid #FFD700;
border-radius: 10px; background: #FFD700; color: #121212; font-weight: 700; cursor: pointer; }
.karvan-rooms { list-style: none; margin: 8px 0 0; padding: 0; }
.karvan-rooms li { margin: 0 0 8px; }
.karvan-room-item { display: flex; justify-content: space-between; align-items: center; gap: 10px;
padding: 12px 14px; background: #161616; border: 1px solid #333; border-radius: 12px;
color: #FFD700; text-decoration: none; min-height: 48px; box-sizing: border-box; }
.karvan-room-item .kr-title { font-weight: 600; }
.karvan-room-item .kr-meta { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-empty { color: #FFDD44; }
/* sala */
.karvan-room-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.karvan-back { color: #FFD700; text-decoration: none; font-weight: 600; }
.karvan-room-title { flex: 1 1 auto; color: #FFD700; font-weight: 700; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.karvan-expire { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-live { display: inline-flex; align-items: center; gap: 6px; color: #FFDD44; font-size: 12px; margin-bottom: 8px; }
.karvan-live .kl-dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.karvan-live.on .kl-dot { background: #35d07f; }
.karvan-live.on .kl-txt { color: #35d07f; }
.karvan-msgs { list-style: none; margin: 0; padding: 6px 0; display: flex; flex-direction: column; gap: 6px;
max-height: 58vh; overflow-y: auto; }
.karvan-msg { max-width: 82%; align-self: flex-start; background: #1c1c1c; border: 1px solid #333;
border-radius: 14px; padding: 7px 11px; box-sizing: border-box; }
.karvan-msg-self { align-self: flex-end; background: #2a2412; border-color: #FFD700; }
.karvan-msg-live { border-left: 2px solid #35d07f; }
.karvan-msg .km-from { display: block; font-size: 10px; color: #FFDD44; margin-bottom: 2px; }
.karvan-msg .km-text { color: #FFD700; word-break: break-word; }
.karvan-compose { display: flex; gap: 8px; margin-top: 10px; }
.karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
/* Fase 2: invitar a un contacto por feed id */
.karvan-invite { display: flex; gap: 8px; margin-top: 8px; }
.karvan-invite input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 9px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; font-family: monospace; font-size: 12px; }
.karvan-invite button { flex: 0 0 auto; padding: 9px 14px; border: 1px solid #FFD700; background: #161616; color: #FFD700; border-radius: 10px; cursor: pointer; }
.karvan-invite-msg { font-size: 12px; margin: 6px 0 0; }
.karvan-invite-msg.ok { color: #35d07f; }
.karvan-invite-msg.err { color: #ff8a8a; }
/* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
background-color: transparent; box-shadow: none; border-radius: 0; padding: 0; margin: 0; /* anular div{#222}
.karvan-call { margin: 10px 0; }
.karvan-videos { display: none; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; margin-bottom: 8px; }
.karvan-call.active .karvan-videos { display: grid; }
.kc-video { width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 10px; border: 1px solid #333; object-fit: cover; display: block; }
.kc-local { transform: scaleX(-1); }
.kc-remote { display: contents; }
.karvan-call-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.kc-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; border-radius: 999px;
border: 1px solid #333; background: #161616; color: #FFD700; line-height: 1; cursor: pointer; }
.kc-btn.on { background: #FFD700; color: #121212; border-color: #FFD700; }
.kc-btn-start { font-weight: 600; }
.kc-btn-stop { background: #241414; border-color: #5a2a2a; }
.kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; }

View file

@ -0,0 +1,944 @@
html {
-webkit-text-size-adjust: 100%;
box-sizing: border-box;
overflow-x: hidden !important;
}
*, *::before, *::after {
box-sizing: inherit;
}
body {
font-size: 15px;
overflow-x: hidden;
max-width: 100vw;
}
img,
video,
canvas,
iframe,
table {
max-width: 100% !important;
}
pre,
code {
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.header {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
padding: 4px !important;
gap: 4px !important;
overflow: visible !important;
}
.header-content {
display: flex !important;
flex-wrap: wrap !important;
width: 100% !important;
max-width: 100% !important;
padding: 2px !important;
gap: 4px !important;
overflow: visible !important;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
align-items: stretch !important;
gap: 6px !important;
}
.top-bar-left nav ul,
.top-bar-mid nav ul,
.top-bar-right nav ul {
display: flex !important;
flex-wrap: wrap !important;
gap: 6px !important;
padding: 0 !important;
margin: 0 !important;
overflow: visible !important;
}
.top-bar-left nav ul li,
.top-bar-mid nav ul li,
.top-bar-right nav ul li {
margin: 0 !important;
}
.top-bar-left nav ul li a,
.top-bar-mid nav ul li a,
.top-bar-right nav ul li a {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 6px 8px !important;
font-size: 0.9rem !important;
white-space: nowrap !important;
}
.search-input,
.activity-search-input {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
height: 40px !important;
font-size: 16px !important;
}
.main-content {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
gap: 12px !important;
}
.sidebar-left,
.sidebar-right,
.main-column {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
padding: 10px !important;
border-left: none !important;
border-right: none !important;
}
.sidebar-left {
order: 1 !important;
}
.main-column {
order: 3 !important;
}
.sidebar-right {
order: 2 !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex !important;
flex-direction: column !important;
}
.oasis-nav-header {
font-size: 0.85rem !important;
}
.oasis-nav-list li a {
font-size: 0.9rem !important;
padding: 8px 12px !important;
}
button,
input[type="submit"],
input[type="button"],
.filter-btn,
.create-button,
.edit-btn,
.delete-btn,
.join-btn,
.leave-btn,
.buy-btn {
min-height: 44px !important;
font-size: 16px !important;
white-space: normal !important;
text-align: center !important;
}
.feed-row,
.comment-body-row,
table {
display: block !important;
width: 100% !important;
overflow-x: auto !important;
}
textarea,
input:not([type="checkbox"]):not([type="radio"]),
select {
width: 100% !important;
max-width: 100% !important;
font-size: 16px !important;
}
input[type="checkbox"]:not(.oasis-nav-toggle):not(.oasis-nav-side-toggle),
input[type="radio"] {
width: auto !important;
max-width: none !important;
flex: none !important;
}
.gallery {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 8px !important;
}
footer,
.footer {
display: block !important;
width: 100% !important;
max-width: 100% !important;
padding: 12px !important;
overflow-x: auto !important;
}
footer div {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
}
h1 { font-size: 1.35em !important; }
h2 { font-size: 1.2em !important; }
h3 { font-size: 1em !important; }
.small,
.time {
font-size: 0.8rem !important;
}
.created-at {
display: block !important;
width: 100% !important;
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
line-height: 1.5 !important;
font-size: 0.8rem !important;
}
.header-content .created-at {
display: block !important;
flex: 1 1 100% !important;
min-width: 0 !important;
max-width: 100% !important;
}
.post-meta,
.feed-post-meta,
.feed-row .small,
.feed-row .time,
.feed-row .created-at {
display: block !important;
width: 100% !important;
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
line-height: 1.4 !important;
}
.post-meta,
.feed-post-meta {
flex-direction: column !important;
gap: 4px !important;
}
.mode-buttons,
.mode-buttons-cols,
.mode-buttons-row,
.filter-group,
.activity-filter-grid,
.activity-sub-filter,
.filters .ui-toolbar,
.filter-group,
.inhabitant-action,
.mode-buttons {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: center !important;
width: 100% !important;
max-width: 100% !important;
gap: 6px !important;
overflow-x: hidden !important;
scroll-snap-type: none !important;
grid-template-columns: none !important;
}
.mode-buttons .column,
.mode-buttons > div,
.activity-filter-col {
display: contents !important;
}
.mode-buttons form,
.filter-group form,
.inhabitant-action form,
.activity-filter-col form {
width: auto !important;
flex: 0 1 auto !important;
scroll-snap-align: none !important;
margin: 0 !important;
}
.inhabitant-action .filter-btn {
min-height: 0 !important;
padding: 7px 12px !important;
font-size: 13px !important;
white-space: nowrap !important;
}
.profile-sensors-box {
display: flex !important;
flex-direction: column !important;
align-items: flex-start !important;
gap: 6px !important;
width: 100% !important;
max-width: 100% !important;
}
.profile-sensors-box > * {
max-width: 100% !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.inhabitant-activity-group {
display: flex !important;
flex-wrap: wrap !important;
gap: 6px !important;
}
.activity-filter-grid .filter-btn,
.activity-filter-col .filter-btn,
.activity-sub-filter .filter-btn {
min-height: 0 !important;
padding: 7px 12px !important;
font-size: 13px !important;
white-space: nowrap !important;
}
.mode-buttons .filter-btn,
.mode-buttons button,
.filter-group .filter-btn,
.filter-group button,
.activity-filter-grid .filter-btn,
.activity-filter-grid button,
.activity-sub-filter .filter-btn,
.filters .filter-btn,
.filters button {
width: auto !important;
flex: 0 0 auto !important;
white-space: nowrap !important;
min-width: 0 !important;
}
.inhabitant-card {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
overflow: visible !important;
}
.inhabitant-left {
width: 100% !important;
text-align: center !important;
flex: 0 0 auto !important;
height: auto !important;
}
.inhabitant-details {
width: 100% !important;
}
.inhabitant-left > a:first-of-type,
.inhabitant-left > a:first-child {
display: inline-block !important;
width: 150px !important;
height: 150px !important;
max-width: 150px !important;
margin: 0 auto 12px !important;
}
.inhabitant-photo,
.inhabitant-photo-details {
width: 150px !important;
height: 150px !important;
max-width: 150px !important;
object-fit: cover !important;
border-radius: 50% !important;
display: block !important;
margin: 0 auto !important;
}
.inhabitants-list,
.inhabitants-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.tribe-card {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
}
.tribe-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
grid-template-columns: 1fr !important;
}
.tribes-list,
.tribes-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.tribe-card-image {
width: 100% !important;
max-width: 100% !important;
}
.tribe-section-nav {
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: center !important;
gap: 6px !important;
overflow-x: visible !important;
width: 100% !important;
max-width: 100% !important;
}
.tribe-section-group {
display: contents !important;
}
.tribe-section-btn {
font-size: 12px !important;
padding: 7px 12px !important;
white-space: nowrap !important;
flex: 0 0 auto !important;
}
.tribe-details {
flex-direction: column !important;
}
.tribe-side {
width: 100% !important;
}
.tribe-main {
width: 100% !important;
}
.tribe-content-grid {
grid-template-columns: 1fr !important;
}
.tribe-inhabitants-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-media-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-overview-grid {
grid-template-columns: 1fr !important;
}
.tribe-mode-buttons {
flex-wrap: wrap !important;
}
.tribe-card-hero-image {
height: 180px !important;
}
.tribe-votation-option {
flex-direction: column !important;
align-items: flex-start !important;
}
.tribe-content-header {
flex-direction: column !important;
gap: 8px !important;
}
.tribe-content-filters {
flex-wrap: wrap !important;
}
.tribe-media-filters {
flex-wrap: wrap !important;
}
.forum-card {
flex-direction: column !important;
}
.forum-score-col,
.forum-main-col,
.root-vote-col {
width: 100% !important;
}
.forum-header-row {
flex-direction: column !important;
gap: 4px !important;
}
.forum-meta {
flex-wrap: wrap !important;
gap: 8px !important;
}
.forum-thread-header {
flex-direction: column !important;
}
.forum-comment {
margin-left: 0;
padding-left: 8px;
}
.comment-body-row {
flex-direction: column;
}
.comment-vote-col,
.comment-text-col {
width: 100%;
}
.forum-score-box,
.forum-score-form {
flex-direction: row;
justify-content: center;
}
.new-message-form textarea,
.comment-textarea {
width: 100%;
}
[style*="grid-template-columns: repeat(6"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns: repeat(3"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(6"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(3"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns: repeat(auto-fit"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(auto-fit"] {
grid-template-columns: 1fr !important;
}
[style*="width:50%"] {
width: 100% !important;
}
.inhabitant-karma-ubi .ubi-line{display:block;font-size:0.9em}
.pad-editor-container{flex-direction:column !important}
.pad-editor-area,.pad-members-list{width:100% !important;min-width:unset !important}
.chat-main-split{flex-direction:column !important}
.chat-messages-column,.chat-participants-column{width:100% !important;min-width:unset !important;flex:unset !important}
.games-grid{grid-template-columns:1fr !important}
.game-card{width:100% !important}
.main-column [class*="-row"],
.main-column [class*="-card"],
.main-column [class*="-item"],
.main-column [class*="-entry"],
.main-column .card,
.main-column article {
flex-wrap: wrap !important;
min-width: 0 !important;
}
.main-column [class*="-row"] > *,
.main-column [class*="-card"] > * {
min-width: 0 !important;
}
button:not([class]),
input[type="submit"]:not([class]),
input[type="button"]:not([class]),
.filter-btn,
a.filter-btn,
.conn-actions button,
.peers-conn-actions button {
background-color: #FFA500 !important;
color: #1a1a1a !important;
border: 1px solid #FFA500 !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 14px !important;
min-height: 42px !important;
padding: 9px 14px !important;
text-align: center !important;
white-space: normal !important;
max-width: 100% !important;
overflow-wrap: anywhere !important;
}
.filter-btn.active {
background-color: #FFD700 !important;
border-color: #FFD700 !important;
}
.audios-search,
.videos-search,
.images-search {
width: 100% !important;
max-width: 100% !important;
margin: 8px 0 !important;
}
.filter-box {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
gap: 8px !important;
align-items: stretch !important;
}
.filter-box__input {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
height: 42px !important;
font-size: 16px !important;
box-sizing: border-box !important;
}
.filter-box__controls {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
width: 100% !important;
gap: 8px !important;
align-items: stretch !important;
}
.filter-box__controls .transfer-range {
display: flex !important;
flex-direction: row !important;
flex: 1 1 100% !important;
width: 100% !important;
gap: 8px !important;
}
.filter-box__number {
flex: 1 1 0 !important;
min-width: 0 !important;
width: auto !important;
height: 42px !important;
font-size: 15px !important;
box-sizing: border-box !important;
}
.filter-box__select {
flex: 1 1 auto !important;
min-width: 0 !important;
height: 42px !important;
font-size: 15px !important;
}
.filter-box__button {
flex: 0 0 auto !important;
width: auto !important;
min-height: 42px !important;
white-space: nowrap !important;
background-color: #FFA500 !important;
color: #1a1a1a !important;
border: 1px solid #FFA500 !important;
border-radius: 8px !important;
font-weight: 600 !important;
padding: 9px 16px !important;
}
.filters .ui-toolbar--filters {
justify-content: center !important;
align-items: center !important;
gap: 6px !important;
}
.main-column button,
.main-column input,
.main-column select,
.main-column textarea,
.main-column .btn,
.main-column .create-button,
.main-column .update-btn,
.main-column .delete-btn,
.main-column .edit-btn,
.main-column .join-btn,
.main-column .leave-btn,
.main-column .buy-btn,
.main-column .submit-button {
max-width: 100% !important;
box-sizing: border-box !important;
overflow-wrap: anywhere !important;
}
.main-column button,
.main-column .btn,
.main-column .create-button,
.main-column .update-btn,
.main-column .delete-btn,
.main-column .submit-button {
white-space: normal !important;
}
.main-column form {
max-width: 100% !important;
min-width: 0 !important;
}
.card-field {
display: flex !important;
flex-wrap: wrap !important;
min-width: 0 !important;
max-width: 100% !important;
gap: 4px !important;
}
.card-field .card-value,
.card-field .card-label {
min-width: 0 !important;
max-width: 100% !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
word-break: break-word !important;
}
.card-value .user-link,
.card-value a {
white-space: normal !important;
max-width: 100% !important;
overflow-wrap: anywhere !important;
}
.market-grid,
.shop-products-grid,
.jobs-grid {
display: grid !important;
grid-template-columns: 1fr !important;
gap: 12px !important;
width: 100% !important;
max-width: 100% !important;
}
.market-card,
.market-item {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
}
.cycle-info {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 8px !important;
}
.kpi {
min-width: 0 !important;
padding: 8px !important;
}
.kpi__label {
letter-spacing: 0 !important;
word-break: normal !important;
overflow-wrap: break-word !important;
font-size: 11px !important;
}
.kpi__value {
word-break: break-word !important;
overflow-wrap: anywhere !important;
font-size: 13px !important;
}
.table-wrap {
display: block !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
}
.table-wrap table,
.table-wrap .table {
display: table !important;
width: 100% !important;
max-width: 100% !important;
table-layout: fixed !important;
}
.table-wrap th,
.table-wrap td {
white-space: normal !important;
word-break: normal !important;
overflow-wrap: break-word !important;
font-size: 10px !important;
padding: 4px 3px !important;
text-align: center !important;
}
.search-filters-row {
display: block !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
}
.search-filters-table,
.search-filters-table tbody,
.search-filters-table tr,
.search-filters-table td {
display: block !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
}
.search-filters-table td {
padding: 4px 0 !important;
}
.search-filters-table input,
.search-filters-table select,
.search-oasis-id {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box !important;
margin-left: 0 !important;
}
.oasis-id-copy {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
font-family: monospace !important;
font-size: 12px !important;
text-align: center !important;
padding: 8px !important;
border: 1px solid #FFA500 !important;
border-radius: 8px !important;
background: rgba(255, 165, 0, 0.08) !important;
color: inherit !important;
-webkit-user-select: all !important;
user-select: all !important;
}
.peer-key,
.user-link.peer-key {
-webkit-user-select: all !important;
user-select: all !important;
word-break: break-all !important;
}
.create-button,
button.create-button,
a.create-button {
background-color: #ffcc00 !important;
border-color: #e6b800 !important;
color: #000 !important;
}
.peers-list .block-info-table,
.peers-technical-block .block-info-table,
.gov-overview {
display: block !important;
width: 100% !important;
max-width: 100% !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.peers-list .block-info-table tbody,
.peers-technical-block .block-info-table tbody,
.gov-overview tbody {
display: block !important;
width: 100% !important;
}
.gov-overview thead { display: none !important; }
.peers-list .block-info-table tr:first-child,
.peers-technical-block .block-info-table tr:first-child {
display: none !important;
}
.peers-list .block-info-table tr,
.peers-technical-block .block-info-table tr,
.gov-overview tbody tr {
display: block !important;
width: 100% !important;
box-sizing: border-box !important;
border: 1px solid rgba(255, 165, 0, 0.35) !important;
border-radius: 8px !important;
margin: 0 0 8px 0 !important;
padding: 6px 10px !important;
background: rgba(255, 165, 0, 0.04) !important;
}
.peers-list .block-info-table td,
.peers-technical-block .block-info-table td,
.gov-overview tbody td {
display: flex !important;
justify-content: space-between !important;
align-items: baseline !important;
gap: 10px !important;
width: 100% !important;
border: 0 !important;
padding: 3px 0 !important;
font-size: 12px !important;
text-align: right !important;
word-break: break-all !important;
overflow-wrap: anywhere !important;
}
.peers-list .block-info-table td::before,
.peers-technical-block .block-info-table td::before,
.gov-overview tbody td::before {
content: attr(data-label);
flex: 0 0 auto !important;
font-weight: 700 !important;
text-transform: uppercase;
font-size: 10px !important;
letter-spacing: .03em;
opacity: .7 !important;
text-align: left !important;
white-space: nowrap !important;
word-break: normal !important;
}
.peers-technical-block .block-info-table td[data-label=""] { justify-content: center !important; }
.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
.peers-list .block-info-table td .peer-key,
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; }
.relationship-status .relationship-actions { flex-direction: column !important; align-items: stretch !important; gap: 8px !important; }
.relationship-status .relationship-actions form,
.relationship-status .relationship-actions button { width: 100% !important; }
.profile-sensors-box { align-items: center !important; text-align: center !important; }
.profile-sensors-box > * { margin-left: auto !important; margin-right: auto !important; justify-content: center !important; }
.tribe-feed .refeed-column,
.tribe-feed-full .refeed-column { align-self: center !important; align-items: center !important; justify-content: center !important; }
.tribe-feed .card-footer,
.tribe-feed-full .card-footer { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
.invite-qr-card { display: flex !important; justify-content: center !important; border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; margin: 0 auto !important; }
.invite-qr-img { display: block; margin: 0 auto; }
.stats-kpi { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
.stats-kpi-label,
.stats-kpi-value { width: 100% !important; text-align: center !important; }
.main-column audio,
.main-column video { width: 100% !important; max-width: 100% !important; }
.main-column section:has(.chat-messages-list) { border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; }
.tribe-details:has(.chat-messages-list) { display: block !important; border: none !important; background: transparent !important; padding: 6px !important; margin: 0 !important; box-shadow: none !important; }
.chat-full-width { border: none !important; background: transparent !important; padding: 0 !important; }
.chat-messages-list { padding: 0 !important; }
.chat-message { max-width: 92% !important; }
.chat-message.chat-poll { max-width: 97% !important; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,372 @@
body {
background-color: #F9F9F9 !important;
color: #2C2C2C !important;
font-family: 'Roboto', sans-serif !important;
}
.main-column {
background-color: #FFFFFF !important;
border: 1px solid #E0E0E0 !important;
}
button, input[type="submit"], input[type="button"] {
background-color: #FF6F00 !important;
color: #FFFFFF !important;
border: none !important;
border-radius: 6px !important;
padding: 10px 20px !important;
cursor: pointer !important;
font-weight: 600 !important;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #FF8F00 !important;
}
input, textarea, select {
background-color: #FFFFFF !important;
color: #2C2C2C !important;
border: 1px solid #E0E0E0 !important;
border-radius: 4px !important;
padding: 8px !important;
font-size: 16px !important;
}
a {
color: #007BFF !important;
text-decoration: none !important;
}
a:hover {
text-decoration: underline !important;
}
p {
color: black !important;
text-decoration: none !important;
}
.created-at, .about-time, .time {
font-size: 0.9rem;
color: black;
}
table {
background-color: #FFFFFF !important;
color: #2C2C2C !important;
width: 100% !important;
border-collapse: collapse !important;
}
table th {
background-color: #F8F8F8 !important;
padding: 12px 15px !important;
text-align: left !important;
font-weight: 600 !important;
}
table tr:nth-child(even) {
background-color: #FAFAFA !important;
}
table td {
padding: 12px 15px !important;
}
.profile {
background-color: #FFFFFF !important;
padding: 20px !important;
border-radius: 8px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
}
.profile .name {
color: #FF6F00 !important;
font-size: 20px !important;
font-weight: 700 !important;
}
.avatar {
border: 3px solid #FF6F00 !important;
border-radius: 50% !important;
width: 60px !important;
height: 60px !important;
}
article, section {
background-color: #FFFFFF !important;
color: #2C2C2C !important;
padding: 20px !important;
border-radius: 8px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
}
.post-preview img {
border-radius: 8px !important;
max-width: 100% !important;
height: auto !important;
}
div {
background-color: #FFFFFF !important;
border: 1px solid #E0E0E0 !important;
}
::-webkit-scrollbar-thumb {
background-color: #B0B0B0 !important;
}
::-webkit-scrollbar-track {
background-color: #F9F9F9 !important;
}
.action-container {
background-color: #FFFFFF !important;
border-color: #E0E0E0 !important;
color: #2C2C2C !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
}
footer {
background-color: #FFFFFF !important;
border-top: 1px solid #E0E0E0 !important;
padding: 15px 0 !important;
}
footer a {
background-color: #007BFF !important;
color: #FFFFFF !important;
padding: 10px 20px !important;
border-radius: 6px !important;
text-decoration: none !important;
font-weight: 600 !important;
}
footer a:hover {
background-color: #0056b3 !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex !important;
flex-direction: column !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex;
flex-direction: column;
margin: 0;
}
.sidebar-left nav ul li,
.sidebar-right nav ul li {
width: 100%;
display: block;
}
.sidebar-left nav ul li a,
.sidebar-right nav ul li a,
.header nav ul li a {
display: block;
width: 100%;
padding: 12px 16px;
font-size: 15px;
font-weight: 500;
border-radius: 6px;
background-color: #ffffff !important;
color: #2C2C2C !important;
border: 1px solid #D0D0D0 !important;
text-align: left;
box-sizing: border-box;
}
.sidebar-left nav ul li a:hover,
.sidebar-right nav ul li a:hover,
.header nav ul li a:hover {
background-color: #f0f0f0 !important;
}
.filter-btn,
.create-button,
.edit-btn,
.delete-btn,
.join-btn,
.leave-btn,
.buy-btn {
background-color: #FF6F00 !important;
color: #FFFFFF !important;
border: none !important;
}
.filter-btn:hover,
.create-button:hover {
background-color: #FF8F00 !important;
color: #FFFFFF !important;
}
.card {
color: #4A4A4A;
box-shadow: 0 4px 30px 0 rgba(0, 0, 0, 0.1);
background-color: #F4F4F4;
}
.card-label {
color: #2D2D2D;
}
.card-footer {
color: #6C6C6C;
background: none;
}
.card-field {
background: none;
}
.card-tags a.tag-link {
color: #181818;
background: #D94F4F;
}
.card-tags a.tag-link:hover {
background: #D94F4F;
color: #111;
cursor: pointer;
}
a.user-link {
background-color: #FFD600;
color: #FFFFFF;
border-color: #FFD600;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
a.user-link:hover {
background-color: #FFD600;
border-color: #FFD600;
color: #FFFFFF;
cursor: pointer;
}
a.user-link:focus {
background-color: #9A2F2F;
border-color: #9A2F2F;
color: #FFFFFF;
}
.date-link {
background-color: #2F3C32;
color: #fff;
}
.date-link:hover {
background-color: #3E4A3D;
color: #fff;
}
.activitySpreadInhabitant2 {
background-color: #3E4A3D;
color: #fff;
}
.activityVotePost {
background-color: #3B5C42;
color: #fff;
}
.update-banner {
background-color: #FFF3E0 !important;
border-bottom-color: #FFE0B2 !important;
color: #E65100 !important;
}
.update-banner-link {
color: #FF6F00 !important;
}
.snh-invite-code {
color: #007BFF !important;
}
.carbon-bar-track {
background: #e0e0e0 !important;
}
.carbon-bar-max {
background: #cc7700 !important;
}
.stats-kpi-label { color: #2D2D2D !important; }
.stats-kpi-value { color: #007BFF !important; }
.carbon-bar-note, .carbon-bar-formula { color: #007BFF !important; }
.graphos-node-label { fill: #2C2C2C !important; }
.graphos-node-label-me { fill: #cc7700 !important; }
.data-best-label { color: #cc7700 !important; }
.data-best-card { background: #FFF3E0 !important; border: 1px solid rgba(204, 119, 0, .4) !important; }
.graphos-legend { color: #2C2C2C !important; }
.graphos-edge { stroke: #BBB !important; }
.graphos-edge-discovered { stroke: #b8860b !important; }
.graphos-edge-unknown { stroke: #999 !important; }
.graphos-node-circle-discovered { fill: #ffd700 !important; stroke: #b8860b !important; }
.graphos-legend-dot.graphos-node-circle-discovered { background: #ffd700 !important; border-color: #b8860b !important; }
/* Blockexplorer */
.blockchain-view { background-color: #F4F4F4 !important; color: #2C2C2C !important; }
.block { background: #FFFFFF !important; box-shadow: 0 2px 12px rgba(0,0,0,0.06) !important; }
.block:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.10) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #2D2D2D !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #007BFF !important; }
.block-content-preview, .block-content { background: #F8F8F8 !important; color: #2C2C2C !important; }
.block-author { color: #FF6F00 !important; background: rgba(255,111,0,0.08) !important; }
.block-author:hover { color: #FF8F00 !important; }
.block-url { color: #007BFF !important; }
.block-row--details .block-url { background: #F0F0F0 !important; }
.block-row--details .block-url:hover { background: #E0E0E0 !important; color: #FF6F00 !important; }
.btn-singleview { background: #F0F0F0 !important; color: #FF6F00 !important; }
.btn-singleview:hover { background: #E0E0E0 !important; color: #FF8F00 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #FF6F00 !important; color: #FFFFFF !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
.btn-back { background: #FF6F00 !important; color: #FFFFFF !important; }
.btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
.block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; }
.block-diagram { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
.block-diagram-ruler { color: #2D2D2D !important; background: #F8F8F8 !important; border-bottom-color: #E0E0E0 !important; }
.block-diagram-ruler span { color: #2D2D2D !important; }
.block-diagram-cell { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
.bd-label { color: #2D2D2D !important; }
.bd-value { color: #007BFF !important; }
.deleted-label { color: #D32F2F !important; }
/* Tribes */
.tribe-card { background: #FFFFFF !important; border-color: #E0E0E0 !important; }
.tribe-card:hover { border-color: #007BFF !important; }
.tribe-card-title { color: #2D2D2D !important; }
.tribe-card-description { color: #555 !important; }
.tribe-info-table td { border-color: #E0E0E0 !important; }
.tribe-info-label { color: #2D2D2D !important; background: #FFFFFF !important; }
.tribe-info-value { color: #007BFF !important; background: #FFFFFF !important; }
.tribe-info-empty { color: #999 !important; }
.tribe-card-members { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
.tribe-members-count { color: #FF6F00 !important; }
.tribe-card-actions { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
.tribe-action-btn { border-color: #FF6F00 !important; color: #FF6F00 !important; }
.tribe-action-btn:hover { background: #FF6F00 !important; color: #fff !important; }
.tribe-subtribe-link { background: #F0F0F0 !important; border-color: #E0E0E0 !important; color: #FF6F00 !important; }
.tribe-thumb-link { border-color: #E0E0E0 !important; }
.tribe-thumb-link:hover { border-color: #FF6F00 !important; }
.tribe-subtribe-link:hover { background: #E0E0E0 !important; }
.tribe-parent-image { border-color: #E0E0E0 !important; }
.tribe-parent-box { background: #FFFFFF !important; }
.chat-message { background: #FFFFFF !important; border: 1px solid #E0E0E0 !important; }
.chat-bubble-row-self .chat-message { background: #FFF3E0 !important; border-color: rgba(204, 119, 0, .35) !important; }
.chat-message-author { background: #FDF6EC !important; }
.chat-bubble-sender { color: #1565C0 !important; }
.chat-bubble-sender-owner { color: #cc7700 !important; }
.chat-bubble-time { color: #999 !important; }
.chat-day-chip { background: #F0F0F0 !important; color: #666 !important; border-color: #DDD !important; }
.chat-topics { background: #FAFAFA !important; border-color: #E0E0E0 !important; }
.chat-topic:hover { background: #F0F0F0 !important; }
.chat-topic-active { background: #FFF3E0 !important; }
.chat-topic-title { color: #2D2D2D !important; }
.chat-workspace-empty { border-color: #DDD !important; }

View file

@ -0,0 +1,364 @@
body {
background-color: #121212;
color: #FFD700;
}
header, footer {
background-color: #1F1F1F;
}
.sidebar-left, .sidebar-right {
background-color: #1A1A1A;
border: 1px solid #333;
}
.main-column {
background-color: #1C1C1C;
}
button, input[type="submit"], input[type="button"] {
background-color: #444;
color: #FFD700;
border: 1px solid #444;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #333;
border-color: #666;
}
input, textarea, select {
background-color: #333;
color: #FFD700;
border: 1px solid #555;
}
a {
color: #FFD700;
}
a:hover {
color: #FFDD44;
}
table {
background-color: #222;
color: #FFD700;
}
table th {
background-color: #333;
}
table tr:nth-child(even) {
background-color: #2A2A2A;
}
nav ul li a:hover {
color: #FFDD44;
text-decoration: underline;
}
.profile {
background-color: #222;
padding: 15px;
border-radius: 8px;
}
.profile .name {
color: #FFD700;
}
.avatar {
border: 3px solid #FFD700;
}
article, section {
background-color: #1C1C1C;
color: #FFD700;
}
.article img {
border: 3px solid #FFD700;
}
.post-preview img {
border: 3px solid #FFD700;
}
.post-preview .image-container {
max-width: 100%;
overflow: hidden;
display: block;
margin: 0 auto;
}
div {
background-color: #1A1A1A;
border: 1px solid #333;
}
div .header-content {
width: 100%;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #444;
border-radius: 10px;
}
::-webkit-scrollbar-track {
background-color: #222;
}
.action-container {
background-color: #1A1A1A;
border: 1px solid #333;
padding: 10px;
border-radius: 8px;
color: #FFD700;
}
footer {
background-color: #1F1F1F;
padding: 10px 0;
}
footer a {
background-color: #444;
color: #FFD700;
text-align: center;
padding: 8px 16px;
border-radius: 5px;
text-decoration: none;
}
footer a:hover {
background-color: #333;
color: #FFDD44;
}
.card {
border-radius: 16px;
padding: 0px 24px 10px 24px;
margin-bottom: 16px;
color: #FFD600;
font-family: inherit;
box-shadow: 0 2px 20px 0 #FFD60024;
}
.card-section {
border:none;
padding: 10px 0 0 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0px;
margin-top: 8px;
padding-top: 0px;
border: none;
}
.card-label {
color: #ffa300;
font-weight: bold;
letter-spacing: 1.5px;
line-height: 1.2;
margin-bottom: 0;
}
.card-footer {
margin-top: 6px;
font-weight: 500;
color: #ff9900;
font-size: 1.07em;
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
padding-top: 0;
margin-bottom: 6;
}
.card-body {
margin-top: 0;
margin-bottom: 4;
padding: 0;
}
.card-field {
display: flex;
align-items: baseline;
padding: 0;
margin-bottom: 0;
border: none;
background: none;
}
.card-tags {
margin: 5px 0 3px 0;
display: flex;
flex-wrap: wrap;
gap: 9px;
}
.card-tags a.tag-link {
text-decoration: none;
color: #181818;
background: #FFD600;
padding: 5px 13px 4px 13px;
border-radius: 7px;
font-size: .98em;
border: none;
font-weight: bold;
}
.card-tags a.tag-link:hover {
background: #ffe86a;
color: #111;
cursor: pointer;
}
a.user-link {
background-color: #FFA500;
color: #000;
padding: 8px 16px;
border-radius: 5px;
text-align: center;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
font-size: 0.8em;
}
a.user-link:hover {
background-color: #FFD700;
border-color: #FFD700;
color: #000;
cursor: pointer;
}
a.user-link:focus {
background-color: #007B9F;
border-color: #007B9F;
color: #fff;
}
.date-link {
background-color: #444;
color: #FFD600;
padding: 8px 16px;
border-radius: 5px;
margin-left: 8px;
}
.date-link:hover {
background-color: #555;
color: #FFD700;
}
.activitySpreadInhabitant2 {
background-color: #007B9F;
color: #fff;
padding: 8px 16px;
border-radius: 5px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.activityVotePost {
background-color: #557d3b;
color: #fff;
padding: 8px 16px;
border-radius: 5px;
font-weight: bold;
text-decoration: none;
display: inline-block;
border: 2px solid transparent;
}
.update-banner {
background-color: #1a1400;
border-bottom-color: #3a2e00;
color: #FFD700;
}
.update-banner-link {
color: #FFD700;
}
.oasis-footer-center a {
color: #FFA500;
}
.oasis-footer-center a:hover {
color: #FFD700;
}
.snh-invite-code {
color: #FFA500 !important;
}
/* Blockexplorer */
.stats-kpi-label { color: #ffa300 !important; }
.stats-kpi-value { color: #FFD700 !important; }
.carbon-bar-note, .carbon-bar-formula { color: #FFD700 !important; }
.graphos-node-label { fill: #ddd !important; }
.graphos-node-label-me { fill: #ffa500 !important; }
.graphos-legend { color: #ddd !important; }
.blockchain-view { background-color: #191b20 !important; color: #FFD700 !important; }
.block { background: #23242a !important; }
.block:hover { box-shadow: 0 8px 32px rgba(35,40,50,0.18) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #ffa300 !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFD700 !important; }
.block-content-preview, .block-content { background: #222326 !important; color: #FFD700 !important; }
.block-author { color: #FFD700 !important; background: rgba(255,163,0,0.08) !important; }
.block-author:hover { color: #FFDD44 !important; }
.block-url { color: #FFD700 !important; }
.block-row--details .block-url { background: #1f2023 !important; }
.block-row--details .block-url:hover { background: #292b36 !important; color: #ffa300 !important; }
.btn-singleview { background: #1e1f23 !important; color: #ffa300 !important; }
.btn-singleview:hover { background: #2d2e34 !important; color: #FFD700 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #ffa300 !important; color: #000000 !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #ffb733 !important; color: #000000 !important; }
.btn-back { background: #21232b !important; color: #ffa300 !important; }
.btn-back:hover { background: #292b36 !important; color: #FFD700 !important; }
.block-info-table td, .pm-info-table td { border-color: #444 !important; }
.block-diagram { border-color: #555 !important; background: #191b20 !important; }
.block-diagram-ruler { color: #ffa300 !important; background: #111 !important; border-bottom-color: #555 !important; }
.block-diagram-ruler span { color: #ffa300 !important; }
.block-diagram-cell { border-color: #555 !important; background: #1e1f23 !important; }
.bd-label { color: #ffa300 !important; }
.bd-value { color: #FFD700 !important; }
/* Tribes */
.tribe-card { background: #23242a !important; border-color: #444 !important; }
.tribe-card:hover { border-color: #ffa300 !important; }
.tribe-card-description { color: #cfd3e1 !important; }
.tribe-info-table td { border-color: #444 !important; }
.tribe-info-label { color: #ffa300 !important; background: #1e1f23 !important; }
.tribe-info-value { color: #FFD700 !important; background: #1e1f23 !important; }
.tribe-info-empty { color: #9aa3b2 !important; }
.tribe-card-members { border-color: #444 !important; background: #1e1f23 !important; }
.tribe-members-count { color: #ffa300 !important; }
.tribe-card-actions { border-color: #444 !important; background: #1e1f23 !important; }
.tribe-action-btn { border-color: #ffa300 !important; color: #ffa300 !important; }
.tribe-action-btn:hover { background: #ffa300 !important; color: #000 !important; }
.tribe-subtribe-link { background: #191b20 !important; border-color: #444 !important; color: #ffa300 !important; }
.tribe-thumb-link { border-color: #444 !important; }
.tribe-thumb-link:hover { border-color: #ffa300 !important; }
.tribe-subtribe-link:hover { background: #333 !important; }
.tribe-parent-image { border-color: #ffa300 !important; }
.tribe-parent-box { background: #1e1f23 !important; }

View file

@ -0,0 +1,382 @@
body {
background-color: #000000 !important;
color: #00FF00 !important;
font-family: 'Courier New', monospace;
}
header, footer {
background-color: #000000;
color: #00FF00;
padding: 20px;
text-align: center;
font-size: 18px;
}
footer {
border-top: 2px solid #00FF00;
}
.sidebar-left, .sidebar-right {
background-color: #000000;
color: #00FF00;
border-right: 2px solid #00FF00;
padding: 15px;
margin-bottom: 10px;
}
.main-column {
background-color: #000000;
color: #00FF00;
padding: 20px;
border-left: 2px solid #00FF00;
margin-bottom: 20px;
}
button, input[type="submit"], input[type="button"] {
background-color: #000000;
color: #00FF00;
border: 2px solid #00FF00;
border-radius: 8px;
padding: 10px 20px;
cursor: pointer;
font-family: 'Courier New', monospace;
text-transform: none;
font-size: 16px;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #00FF00;
color: #000000;
border-color: #00FF00;
box-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
}
input, textarea, select {
background-color: #1A1A1A;
color: #00FF00;
border: 1px solid #00FF00;
border-radius: 5px;
padding: 10px;
font-family: 'Courier New', monospace;
font-size: 16px;
}
a {
color: #00FF00;
text-decoration: none;
font-weight: normal;
font-size: 16px;
}
table {
background-color: #000000;
color: #00FF00;
width: 100%;
border: 1px solid #00FF00;
font-size: 16px;
}
table th {
background-color: #00FF00;
color: #000000;
}
table tr:nth-child(even) {
background-color: #1A1A1A;
}
nav ul {
background-color: #000000;
padding: 0;
margin: 0;
}
nav ul li {
display: inline-block;
margin-right: 10px;
}
nav ul li a {
color: #00FF00;
padding: 10px;
display: inline-block;
font-family: 'Courier New', monospace;
text-transform: none;
font-size: 16px;
}
.profile {
background-color: #1A1A1A;
color: #00FF00;
padding: 20px;
border-radius: 8px;
border: 1px solid #00FF00;
box-shadow: 0 2px 5px rgba(0,255,0,0.5);
}
.profile .name {
color: #00FF00;
font-size: 18px;
font-weight: bold;
}
.avatar {
border: 4px solid #00FF00;
border-radius: 50%;
width: 80px;
height: 80px;
margin-bottom: 10px;
}
article, section {
background-color: #1A1A1A;
color: #00FF00;
padding: 20px;
border-radius: 10px;
border: 1px solid #00FF00;
box-shadow: 0 4px 10px rgba(0, 255, 0, 0.5);
}
.post-preview {
background-color: #00FF00;
padding: 15px;
border-radius: 8px;
color: #000000;
}
.post-preview img {
border-radius: 8px;
max-width: 100%;
}
::-webkit-scrollbar-thumb {
background-color: #00FF00;
}
::-webkit-scrollbar-track {
background-color: #000000;
}
.action-container {
background-color: #1A1A1A;
border-color: #00FF00;
color: #00FF00;
}
footer a {
background-color: #00FF00;
color: #000000;
padding: 8px 20px;
border-radius: 5px;
text-decoration: none;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
background-color: #000000 !important;
border: 2px solid #00FF00 !important;
padding: 12px 16px;
box-shadow: 0 0 12px #00FF00;
border-radius: 8px;
display: flex;
gap: 12px;
}
.sidebar-left,
.sidebar-right {
background-color: #000000 !important;
border: 2px solid #00FF00 !important;
box-shadow: 0 0 15px #00FF00;
padding: 16px;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
}
.sidebar-left nav ul li,
.sidebar-right nav ul li {
width: 100%;
}
.sidebar-left nav ul li a,
.sidebar-right nav ul li a,
.header nav ul li a {
background-color: #000000 !important;
color: #00FF00 !important;
border: 1px solid #00FF00 !important;
font-weight: bold;
border-radius: 6px;
padding: 10px 14px;
display: flex;
justify-content: flex-start;
box-shadow: 0 0 6px #00FF00;
transition: background-color 0.3s ease, color 0.3s ease;
}
.sidebar-left nav ul li a:hover,
.sidebar-right nav ul li a:hover,
.header nav ul li a:hover {
background-color: #00FF00 !important;
color: #000000 !important;
}
.card {
color: #00FF00;
font-family: 'Courier New', monospace;
background-color: #1A1A1A;
}
.card-label {
color: #00FF00;
}
.card-footer {
color: #00FF00;
}
.card-tags a.tag-link {
color: #00FF00;
background: #000000;
}
.card-tags a.tag-link:hover {
background: #00FF00;
color: #000000;
cursor: pointer;
}
a.user-link {
background-color: #00FF00;
color: #000000;
border-color: #00FF00;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
a.user-link:hover {
background-color: #000000;
border-color: #00FF00;
color: #00FF00;
cursor: pointer;
}
a.user-link:focus {
background-color: #00FF00;
border-color: #00FF00;
color: #000000;
}
.date-link {
background-color: #00FF00;
color: #000000;
}
.date-link:hover {
background-color: #000000;
color: #00FF00;
}
.activitySpreadInhabitant2 {
background-color: #1A1A1A;
color: #00FF00;
}
.activityVotePost {
background-color: #00FF00;
color: #000000;
}
.update-banner {
background-color: #001a00;
border-bottom-color: #003300;
color: #00FF00;
}
.update-banner-link {
color: #00FF00;
}
.snh-invite-code {
color: #00FF00 !important;
}
.stats-kpi-label { color: #00FF00 !important; }
.stats-kpi-value { color: #00FF00 !important; }
.carbon-bar-note, .carbon-bar-formula { color: #00FF00 !important; }
.graphos-node-label { fill: #00FF00 !important; }
.graphos-node-label-me { fill: #ffa500 !important; font-weight: bold !important; }
.graphos-legend { color: #00FF00 !important; }
.graphos-edge { stroke: #003300 !important; }
/* Blockexplorer */
.blockchain-view { background-color: #000000 !important; color: #00FF00 !important; }
.block { background: #1A1A1A !important; border: 1px solid #00FF00 !important; }
.block:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #00FF00 !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #00FF00 !important; }
.block-content-preview, .block-content { background: #1A1A1A !important; color: #00FF00 !important; }
.block-author { color: #00FF00 !important; background: rgba(0,255,0,0.08) !important; }
.block-author:hover { color: #00FF00 !important; }
.block-url { color: #00FF00 !important; }
.block-row--details .block-url { background: #1A1A1A !important; }
.block-row--details .block-url:hover { background: #00FF00 !important; color: #000000 !important; }
.btn-singleview { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
.btn-singleview:hover { background: #00FF00 !important; color: #000000 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #00FF00 !important; color: #000000 !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #66FF66 !important; color: #000000 !important; }
.btn-back { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
.btn-back:hover { background: #00FF00 !important; color: #000000 !important; }
.block-info-table td, .pm-info-table td { border-color: #00FF00 !important; }
.block-diagram { border-color: #00FF00 !important; background: #000000 !important; }
.block-diagram-ruler { color: #00FF00 !important; background: #000000 !important; border-bottom-color: #00FF00 !important; }
.block-diagram-ruler span { color: #00FF00 !important; }
.block-diagram-cell { border-color: #00FF00 !important; background: #1A1A1A !important; }
.bd-label { color: #00FF00 !important; }
.bd-value { color: #00FF00 !important; }
.deleted-label { color: #ff3333 !important; }
/* Tribes */
.tribe-card { background: #1A1A1A !important; border-color: #00FF00 !important; }
.tribe-card:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
.tribe-card-title { color: #00FF00 !important; }
.tribe-card-description { color: #00FF00 !important; }
.tribe-info-table td { border-color: #00FF00 !important; }
.tribe-info-label { color: #00FF00 !important; background: #1A1A1A !important; }
.tribe-info-value { color: #00FF00 !important; background: #1A1A1A !important; }
.tribe-info-empty { color: #006600 !important; }
.tribe-card-members { border-color: #00FF00 !important; background: #1A1A1A !important; }
.tribe-members-count { color: #00FF00 !important; }
.tribe-card-actions { border-color: #00FF00 !important; background: #1A1A1A !important; }
.tribe-action-btn { border-color: #00FF00 !important; color: #00FF00 !important; }
.tribe-action-btn:hover { background: #00FF00 !important; color: #000 !important; }
.tribe-subtribe-link { background: #000000 !important; border-color: #00FF00 !important; color: #00FF00 !important; }
.tribe-thumb-link { border-color: #00FF00 !important; }
.tribe-thumb-link:hover { box-shadow: 0 0 10px rgba(0,255,0,0.3) !important; }
.tribe-subtribe-link:hover { background: #00FF00 !important; color: #000 !important; }
.tribe-parent-image { border-color: #00FF00 !important; }
.tribe-parent-box { background: #1A1A1A !important; }
.oasis-nav-header { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
.oasis-nav-header:hover { color: #000000 !important; background: #00FF00 !important; }
.oasis-nav-header .emoji, .oasis-nav-header .nav-text, .oasis-nav-arrow { color: inherit !important; }
.oasis-nav-list li a { color: #00FF00 !important; }
.oasis-nav-list li a:hover { color: #00FF00 !important; opacity: 1 !important; }
.oasis-nav-list .emoji, .oasis-nav-list .nav-text { color: inherit !important; }
nav, .sidebar-left nav, .sidebar-right nav, .sidebar-left ul, .sidebar-right ul { color: #00FF00 !important; }
.sidebar-left a, .sidebar-right a { color: #00FF00 !important; }
.ai-ask-form .ai-ask-input, .ai-ask-form .ai-ask-btn { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
.ai-ask-form .ai-ask-input::placeholder { color: rgba(0,255,0,0.55) !important; }
.ai-ask-form .ai-ask-input:focus { border-color: #00FF00 !important; background: #1A1A1A !important; box-shadow: 0 0 6px rgba(0,255,0,0.4) !important; }
.ai-ask-form .ai-ask-btn:hover { background: #00FF00 !important; color: #000000 !important; border-color: #00FF00 !important; }
.ai-nav-results, .ai-nav-result-card { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
.ai-nav-result-card .card-label { color: #00FF00 !important; }
.ai-nav-result-card a { color: #00FF00 !important; }
.ai-nav-result-card a:hover { background: #00FF00 !important; color: #000000 !important; }
.ai-nav-query { color: #00FF00 !important; }
.trending-card.own-content { box-shadow: inset 3px 0 0 #00FF00; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,398 @@
body {
background-color: #4B0A6D;
color: #E5E5E5;
font-family: 'Arial', sans-serif;
}
footer {
border-top: 2px solid #9B1C96;
}
.sidebar-left, .sidebar-right {
background-color: #39006D;
border: 1px solid #9B1C96;
color: #E5E5E5;
padding: 15px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.main-column {
background-color: #1A1A1A;
border: 1px solid #9B1C96;
padding: 20px;
color: #E5E5E5;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
button, input[type="submit"], input[type="button"] {
background-color: #9B1C96;
color: #E5E5E5;
border: 2px solid #6A0066;
border-radius: 10px;
padding: 12px 24px;
cursor: pointer;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #6A0066;
border-color: #9B1C96;
box-shadow: 0 0 15px rgba(155, 28, 150, 0.8);
}
input, textarea, select {
background-color: #333333;
color: #E5E5E5;
border: 1px solid #9B1C96;
border-radius: 5px;
padding: 12px;
}
input:focus, textarea:focus, select:focus {
background-color: #39006D;
outline: none;
}
a {
color: #9B1C96;
text-decoration: none;
}
a:hover {
color: #E5E5E5;
}
table {
background-color: #1A1A1A;
color: #E5E5E5;
width: 100%;
border-collapse: collapse;
}
table th {
background-color: #6A0066;
}
table tr:nth-child(even) {
background-color: #333333;
}
nav ul {
background-color: #39006D;
padding: 0;
margin: 0;
list-style: none;
}
nav ul li {
display: inline-block;
margin-right: 10px;
}
nav ul li a {
color: #E5E5E5;
padding: 15px;
display: inline-block;
text-transform: none;
font-weight: bold;
letter-spacing: 0;
}
.profile {
background-color: #333333;
color: #E5E5E5;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.profile .name {
color: #9B1C96;
font-size: 20px;
font-weight: bold;
}
.avatar {
border: 4px solid #9B1C96;
border-radius: 50%;
width: 80px;
height: 80px;
margin-bottom: 10px;
}
article, section {
background-color: #333333;
color: #E5E5E5;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.post-preview {
background-color: #39006D;
padding: 15px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.post-preview img {
border-radius: 8px;
max-width: 100%;
}
::-webkit-scrollbar-thumb {
background-color: #9B1C96;
}
::-webkit-scrollbar-track {
background-color: #1A1A1A;
}
.action-container {
background-color: #333333;
border-color: #9B1C96;
color: #E5E5E5;
}
footer a {
background-color: #9B1C96;
color: #FFFFFF;
padding: 8px 20px;
border-radius: 5px;
text-decoration: none;
}
footer a:hover {
background-color: #6A0066;
}
.sidebar-left nav ul li a,
.sidebar-right nav ul li a,
.header nav ul li a {
background-color: #682B94 !important;
color: #FFE082 !important;
border: 1px solid #B86ADE !important;
font-weight: 600;
border-radius: 6px;
padding: 12px 16px;
display: flex;
justify-content: flex-start;
box-sizing: border-box;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.sidebar-left nav ul li a:hover,
.sidebar-right nav ul li a:hover,
.header nav ul li a:hover {
background-color: #682B94 !important;
border-color: #FFD54F !important;
color: #FFFFFF !important;
}
body {
background-color: #2D0B47 !important;
}
.main-column,
article,
section,
.action-container,
.profile,
.post-preview {
background-color: #3C1360 !important;
border-color: #B86ADE !important;
color: #FFEEDB !important;
}
input,
textarea,
select {
background-color: #4B1A72 !important;
color: #FFFFFF !important;
border-color: #BB5EFF !important;
}
button,
input[type="submit"],
input[type="button"] {
background-color: #A34AD8 !important;
color: #FFFFFF !important;
border-color: #751E9F !important;
}
header {
background-color: #5A1A85 !important;
border-bottom: 1px solid #B86ADE !important;
box-shadow: none !important;
}
.header {
background-color: #5A1A85 !important;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
background-color: #39006D !important;
border: 1px solid #9B1C96 !important;
padding: 15px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
display: flex;
gap: 12px;
border-radius: 10px;
}
.sidebar-left,
.sidebar-right {
padding: 16px;
box-sizing: border-box;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 0;
}
.sidebar-left nav ul li,
.sidebar-right nav ul li {
width: 100%;
}
.card {
color: #E5E5E5;
box-shadow: 0 4px 30px 0 rgba(0, 0, 0, 0.2);
background-color: #3C1360;
border-color: #B86ADE;
}
.card-label {
color: #9B1C96;
}
.card-footer {
color: #B86ADE;
}
.card-tags a.tag-link {
color: #FFFFFF;
background: #D94F4F;
}
.card-tags a.tag-link:hover {
background: #B86ADE;
color: #E5E5E5;
cursor: pointer;
}
a.user-link {
background-color: #FFD600;
color: #3C1360;
border-color: #FFD600;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
a.user-link:hover {
background-color: #FFB600;
border-color: #FFB600;
color: #3C1360;
cursor: pointer;
}
a.user-link:focus {
background-color: #9A2F2F;
border-color: #9A2F2F;
color: #E5E5E5;
}
.date-link {
background-color: #2F3C32;
color: #fff;
}
.date-link:hover {
background-color: #3E4A3D;
color: #fff;
}
.activitySpreadInhabitant2 {
background-color: #3E4A3D;
color: #fff;
}
.activityVotePost {
background-color: #3B5C42;
color: #fff;
}
.update-banner {
background-color: #1a0025;
border-bottom-color: #3d004d;
color: #E5E5E5;
}
.update-banner-link {
color: #c44bc1;
}
.snh-invite-code {
color: #9B1C96 !important;
}
.stats-kpi-label { color: #B86ADE !important; }
.stats-kpi-value { color: #FFEEDB !important; }
.carbon-bar-note, .carbon-bar-formula { color: #FFEEDB !important; }
.graphos-node-label { fill: #FFEEDB !important; }
.graphos-node-label-me { fill: #ffa500 !important; }
.graphos-legend { color: #FFEEDB !important; }
/* Blockexplorer */
.blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; }
.block { background: #3C1360 !important; border: 1px solid #B86ADE !important; }
.block:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #B86ADE !important; }
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFEEDB !important; }
.block-content-preview, .block-content { background: #4B1A72 !important; color: #FFEEDB !important; }
.block-author { color: #FFD600 !important; background: rgba(255,214,0,0.08) !important; }
.block-author:hover { color: #FFB600 !important; }
.block-url { color: #B86ADE !important; }
.block-row--details .block-url { background: #4B1A72 !important; }
.block-row--details .block-url:hover { background: #5A1A85 !important; color: #FFD600 !important; }
.btn-singleview { background: #4B1A72 !important; color: #B86ADE !important; }
.btn-singleview:hover { background: #5A1A85 !important; color: #FFD600 !important; }
.btn-spread-on, .content-actions .btn-pin-on { background: #B86ADE !important; color: #2A0E42 !important; }
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #CE8CE9 !important; color: #2A0E42 !important; }
.btn-back { background: #A34AD8 !important; color: #FFFFFF !important; }
.btn-back:hover { background: #751E9F !important; color: #FFFFFF !important; }
.block-info-table td, .pm-info-table td { border-color: #B86ADE !important; }
.block-diagram { border-color: #B86ADE !important; background: #2D0B47 !important; }
.block-diagram-ruler { color: #B86ADE !important; background: #1A0030 !important; border-bottom-color: #B86ADE !important; }
.block-diagram-ruler span { color: #B86ADE !important; }
.block-diagram-cell { border-color: #B86ADE !important; background: #3C1360 !important; }
.bd-label { color: #B86ADE !important; }
.bd-value { color: #FFEEDB !important; }
.deleted-label { color: #ff5555 !important; }
/* Tribes */
.tribe-card { background: #3C1360 !important; border-color: #B86ADE !important; }
.tribe-card:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
.tribe-card-title { color: #FFEEDB !important; }
.tribe-card-description { color: #FFEEDB !important; }
.tribe-info-table td { border-color: #B86ADE !important; }
.tribe-info-label { color: #B86ADE !important; background: #3C1360 !important; }
.tribe-info-value { color: #FFEEDB !important; background: #3C1360 !important; }
.tribe-info-empty { color: #8844aa !important; }
.tribe-card-members { border-color: #B86ADE !important; background: #2D0B47 !important; }
.tribe-members-count { color: #FFD600 !important; }
.tribe-card-actions { border-color: #B86ADE !important; background: #2D0B47 !important; }
.tribe-action-btn { border-color: #B86ADE !important; color: #B86ADE !important; }
.tribe-action-btn:hover { background: #B86ADE !important; color: #000 !important; }
.tribe-subtribe-link { background: #4B1A72 !important; border-color: #B86ADE !important; color: #FFD600 !important; }
.tribe-thumb-link { border-color: #B86ADE !important; }
.tribe-thumb-link:hover { border-color: #FFD600 !important; }
.tribe-subtribe-link:hover { background: #5A1A85 !important; }
.tribe-parent-image { border-color: #B86ADE !important; }
.tribe-parent-box { background: #3C1360 !important; }
.trending-card.own-content { box-shadow: inset 3px 0 0 #B86ADE; }

View file

@ -0,0 +1,524 @@
// Claves de traduccion propias de la rama experimental.
// Se fusionan sobre las de upstream desde i18n.js, para no editar los 11
// ficheros oasis_*.js (upstream los reescribe entero en cada release).
//
// Contiene tres grupos:
// 1. Claves que anade la rama: barra inferior (bb*), menu Personal/Community
// y el modulo Karvan.
// 2. Claves que upstream tenia en 0.9.1 y elimino en 0.9.5, pero que el
// codigo de la rama sigue usando (menuBlogs, spreadHint, chatShareUrl...).
// 3. Textos de upstream que la rama corrige (el grupo spread en castellano).
module.exports = {
en: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Category",
bookmarkCategoryPlaceholder: "Optional",
chatShareUrl: "Share URL",
filter: "Filter",
forumFilterHot: "Hot",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogs",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Device",
publishBlog: "Publish Blog",
spreadHint: "Spread this to your supporters (replicates via your feed).",
spreadMore: "more"
},
es: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Salas efimeras: los mensajes viven solo en memoria y se autodestruyen.",
chatReply: "Responder",
bbAdd: "Añadir",
bbDone: "Hecho",
bbFull: "Máximo alcanzado",
bbManage: "Editar",
bbPickDesc: "Elige hasta 4 accesos para la barra inferior.",
bbPickNone: "Ninguno",
bbPickTitle: "Personaliza la barra",
bbQuickActions: "Accesos rápidos",
bbRemove: "Quitar",
bbYourBar: "Tu barra",
bookmarkCategoryLabel: "Categoría",
bookmarkCategoryPlaceholder: "Opcional",
chatShareUrl: "Compartir URL",
filter: "Filtrar",
forumFilterHot: "Populares",
karvanActive: "Salas activas",
karvanBlurb: "Salas que solo viven en memoria y se autodestruyen.",
karvanCall: "Llamar",
karvanCreate: "Crear sala efímera",
karvanEphemeral: "Salas efímeras",
karvanInvite: "Invitar",
karvanInviteBad: "✗ No es un @feed-id válido.",
karvanInviteFail: "✗ No se pudo enviar la invitación.",
karvanInvitePlaceholder: "Invita a un contacto por @feed-id…",
karvanInviteSent: "✓ Invitación enviada a su buzón.",
karvanNone: "No hay salas activas. Crea una: desaparece sola.",
karvanRelay: "relay del servidor",
karvanRoomName: "Nombre de la sala (opcional)",
karvanRooms: "Salas",
karvanSay: "Mensaje temporal…",
karvanSend: "Enviar",
karvanTitle: "Karvan",
karvanTtl: "Se autodestruye en:",
menuAll: "Todo",
menuBlogs: "Blogs",
menuCommunity: "Comunidad",
peerLastChange: "Último cambio",
profileVisibilityDevice: "Dispositivo",
publishBlog: "Publicar Blog",
spreadChron: "Difusión",
spreadHint: "Difunde esto a quienes te apoyan (se replica vía tu feed).",
spreadLabel: "Difundir",
spreadMore: "más",
spreaded: "Difundido",
spreadedDescription: "Lista de posts replicados por habitante.",
totalspreads: "Replicas totales"
},
fr: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Catégorie",
bookmarkCategoryPlaceholder: "Optionnel",
chatShareUrl: "Partager URL",
filter: "Filter",
forumFilterHot: "Populaires",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogs",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Appareil",
publishBlog: "Publier un blog",
spreadHint: "Diffusez ceci à ceux qui vous soutiennent (réplique via votre feed).",
spreadMore: "plus"
},
eu: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Kategoria",
bookmarkCategoryPlaceholder: "Aukerakoa",
chatShareUrl: "URLa Partekatu",
filter: "Filter",
forumFilterHot: "Beroa",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogak",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Gailua",
publishBlog: "Bloga argitaratu",
spreadHint: "Hedatu zure babesleei (zure feed bidez errepikatzen da).",
spreadMore: "gehiago"
},
de: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Kategorie",
bookmarkCategoryPlaceholder: "Optional",
chatShareUrl: "URL Teilen",
filter: "Filter",
forumFilterHot: "Beliebt",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogs",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Gerät",
publishBlog: "Blog veröffentlichen",
spreadHint: "Verbreite dies an deine Unterstützer (über deinen Feed).",
spreadMore: "mehr"
},
it: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Categoria",
bookmarkCategoryPlaceholder: "Opzionale",
chatShareUrl: "Condividi URL",
filter: "Filter",
forumFilterHot: "Caldi",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blog",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Dispositivo",
publishBlog: "Pubblica post",
spreadHint: "Diffondi questo a chi ti supporta (replica via il tuo feed).",
spreadMore: "altro"
},
pt: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Categoria",
bookmarkCategoryPlaceholder: "Opcional",
chatShareUrl: "Partilhar URL",
filter: "Filter",
forumFilterHot: "Populares",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogues",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Dispositivo",
publishBlog: "Publicar blog",
spreadHint: "Difunde isto aos teus apoiantes (replica pelo teu feed).",
spreadMore: "mais"
},
zh: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "类别",
bookmarkCategoryPlaceholder: "可选",
chatShareUrl: "分享链接",
filter: "Filter",
forumFilterHot: "热门",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "博客",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "设备",
publishBlog: "发布博客",
spreadHint: "转发给你的支持者(通过你的 feed 复制)。",
spreadMore: "更多"
},
ar: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "الفئة",
bookmarkCategoryPlaceholder: "اختياري",
chatShareUrl: "مشاركة الرابط",
filter: "Filter",
forumFilterHot: "رائج",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "مدونات",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "الجهاز",
publishBlog: "نشر مدونة",
spreadHint: "نشر هذا لداعميك (يتم النسخ عبر تغذيتك).",
spreadMore: "المزيد"
},
hi: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "श्रेणी",
bookmarkCategoryPlaceholder: "वैकल्पिक",
chatShareUrl: "URL शेयर करें",
filter: "Filter",
forumFilterHot: "लोकप्रिय",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "ब्लॉग",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "डिवाइस",
publishBlog: "ब्लॉग प्रकाशित करें",
spreadHint: "अपने समर्थकों को फैलाएँ (फ़ीड से प्रतिकृति)।",
spreadMore: "और"
},
ru: {
modulesKarvanLabel: "Karvan",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
chatReply: "Reply",
bbAdd: "Add",
bbDone: "Done",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Категория",
bookmarkCategoryPlaceholder: "Необязательно",
chatShareUrl: "Поделиться URL",
filter: "Filter",
forumFilterHot: "Популярные",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Блоги",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Устройство",
publishBlog: "Опубликовать блог",
spreadHint: "Распространить вашим сторонникам (через ваш feed).",
spreadMore: "ещё"
}
};

View file

@ -0,0 +1,20 @@
const path = require('path');
let i18n = {};
const languages = ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt', 'zh', 'ar', 'hi', 'ru'];
languages.forEach(language => {
try {
const languagePath = path.join(__dirname, `oasis_${language}.js`);
const languageData = require(languagePath);
i18n[language] = languageData[language];
} catch (error) {
console.error(`Failed to load language file for ${language}:`, error);
}
});
try {
const overlay = require('./fork/i18n_fork.js');
languages.forEach(l => Object.assign(i18n[l] = i18n[l] || {}, overlay[l] || overlay.en || {}));
} catch (e) {}
module.exports = i18n;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
module.exports = {
feed: 'createFeedStream',
history: 'createHistoryStream',
hist: 'createHistoryStream',
public: 'getPublicKey',
pub: 'getPublicKey',
log: 'createLogStream',
logt: 'messagesByType',
conf: 'config',
};

124
src/client/gui.js Normal file
View file

@ -0,0 +1,124 @@
const path = require('path');
const fs = require('fs');
const os = require('os');
const debug = require('../server/node_modules/debug')('oasis');
const lodash = require('../server/node_modules/lodash');
const ssbClient = require('../server/node_modules/ssb-client');
const ssbConfig = require('../server/node_modules/ssb-config');
const ssbKeys = require('../server/node_modules/ssb-keys');
const { printMetadata } = require('../server/ssb_metadata');
const updateFlagPath = path.join(__dirname, "../server/.update_required");
let internalSSB = null;
try {
const { server } = require('../server/SSB_server');
internalSSB = server;
} catch {}
if (process.env.OASIS_TEST) {
ssbConfig.path = fs.mkdtempSync(path.join(os.tmpdir(), "oasis-"));
ssbConfig.keys = ssbKeys.generate();
}
const socketPath = path.join(ssbConfig.path, "socket");
const publicInteger = ssbConfig.keys.public.replace(".ed25519", "");
const remote = `unix:${socketPath}~noauth:${publicInteger}`;
const connect = (options) =>
new Promise((resolve, reject) => {
ssbClient(process.env.OASIS_TEST ? ssbConfig.keys : null, options)
.then(resolve)
.catch(reject);
});
let closing = false;
let clientHandle;
const attemptConnectionWithBackoff = (attempt = 1) => {
const maxAttempts = 5;
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
return new Promise((resolve, reject) => {
connect({ remote })
.then(resolve)
.catch((error) => {
if (attempt >= maxAttempts) {
return reject(new Error("Failed to connect after multiple attempts"));
}
setTimeout(() => {
attemptConnectionWithBackoff(attempt + 1).then(resolve).catch(reject);
}, delay);
});
});
};
let pendingConnection = null;
const ensureConnection = (customConfig) => {
if (pendingConnection === null) {
pendingConnection = new Promise((resolve) => {
setTimeout(() => {
attemptConnectionWithBackoff()
.then(resolve)
.catch(() => {
resolve(null);
});
});
});
const cancel = () => (pendingConnection = null);
pendingConnection.then(cancel, cancel);
}
return pendingConnection;
};
module.exports = ({ offline, port = 3000, host = 'localhost', isPublic = false }) => {
const customConfig = JSON.parse(JSON.stringify(ssbConfig));
if (offline === true) {
lodash.set(customConfig, "conn.autostart", false);
}
lodash.set(
customConfig,
"conn.hops",
lodash.get(ssbConfig, "conn.hops", lodash.get(ssbConfig.friends, "hops", 0))
);
const cooler = {
open() {
return new Promise((resolve, reject) => {
if (internalSSB) {
const { printMetadata, colors } = require('../server/ssb_metadata');
printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
return resolve(internalSSB);
}
if (clientHandle && clientHandle.closed === false) {
return resolve(clientHandle);
}
ensureConnection(customConfig).then((ssb) => {
if (!ssb) return reject(new Error("No SSB server available"));
clientHandle = ssb;
if (closing) {
cooler.close();
reject(new Error("Closing Oasis"));
} else {
const { printMetadata, colors } = require('../server/ssb_metadata');
printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
resolve(ssb);
}
}).catch(reject);
});
},
close() {
closing = true;
if (clientHandle && clientHandle.closed === false) {
clientHandle.close();
}
},
};
return cooler;
};

212
src/client/middleware.js Normal file
View file

@ -0,0 +1,212 @@
const path = require("path");
const os = require("os");
const Koa = require(path.join(__dirname, "../server/node_modules/koa"));
const koaStatic = require(path.join(__dirname, "../server/node_modules/koa-static"));
const { join } = require("path");
const mount = require(path.join(__dirname, "../server/node_modules/koa-mount"));
function obfuscateClearnetHtml(html) {
if (typeof html !== 'string' || html.length === 0) return html;
const preserve = [];
const stash = (re) => {
html = html.replace(re, (m) => {
preserve.push(m);
return `${preserve.length - 1}`;
});
};
stash(/<pre[\s\S]*?<\/pre>/gi);
stash(/<textarea[\s\S]*?<\/textarea>/gi);
stash(/<style[\s\S]*?<\/style>/gi);
html = html.replace(/<!--[\s\S]*?-->/g, '');
html = html.replace(/>[\s\n\r\t]+</g, '><');
html = html.replace(/[ \t]{2,}/g, ' ');
html = html.replace(/[\r\n]+/g, '');
html = html.replace(/(\d+)/g, (_, i) => preserve[Number(i)] || '');
return html;
}
const collectLocalIPs = () => {
const out = [];
try {
const ifaces = os.networkInterfaces();
for (const name of Object.keys(ifaces)) {
for (const info of (ifaces[name] || [])) {
if (info && !info.internal && (info.family === 'IPv4' || info.family === 4)) {
out.push(info.address);
}
}
}
} catch (_) {}
return out;
};
module.exports = ({ host, port, middleware, allowHost }) => {
const assets = new Koa()
assets.use(koaStatic(join(__dirname, "..", "client", "assets"), { maxage: 60 * 60 * 1000 }));
const app = new Koa();
const validHosts = [];
const isClearnetPath = (request) => {
const url = String(request.url || '');
return url === '/c' || url.startsWith('/c/') || url.startsWith('/c?');
};
const isValidRequest = (request) => {
if (isClearnetPath(request)) return request.method === 'GET';
if (validHosts.includes(request.hostname) !== true) {
return false;
}
if (request.method !== "GET") {
if (request.header.referer == null) {
return false;
}
try {
const refererUrl = new URL(request.header.referer);
if (validHosts.includes(refererUrl.hostname) !== true) {
return false;
}
if (refererUrl.pathname.startsWith("/blob/")) {
return false;
}
} catch (e) {
return false;
}
}
return true;
};
const httpDebug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
app.on("error", (err, ctx) => {
if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
return;
}
if (err && (err.name === 'BadRequestError' || err.status === 400)) {
if (httpDebug) console.error(`[400] ${err.message}`);
return null;
}
console.error(err);
if (ctx && isValidRequest(ctx.request)) {
err.message = err.message || 'Internal server error';
err.expose = true;
}
return null;
});
app.use(mount("/assets", assets));
const maptiles = new Koa();
maptiles.use(koaStatic(join(__dirname, "..", "maps", "tiles")));
app.use(mount("/maptiles", maptiles));
const mapcache = new Koa();
mapcache.use(koaStatic(join(__dirname, "..", "maps", "cache")));
app.use(mount("/mapcache", mapcache));
const gamesStatic = new Koa();
gamesStatic.use(koaStatic(join(__dirname, "..", "games")));
app.use(mount("/game-assets", gamesStatic));
app.use(mount("/js", koaStatic(path.join(__dirname, 'public/js'))));
app.use(koaStatic(path.join(__dirname, 'public')));
app.use(async (ctx, next) => {
if (httpDebug) console.log(`[http] ${ctx.method} ${ctx.path}`);
const isClearnet = isClearnetPath(ctx.request);
const csp = isClearnet
? [
"default-src 'self'",
"script-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"media-src 'self' blob:",
"connect-src 'self'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
].join("; ")
: [
"default-src 'self'",
"script-src 'self' http://localhost:3000/js",
"style-src 'self'",
"img-src 'self'",
"media-src 'self' blob:",
"worker-src 'self' blob:",
"frame-src 'self'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
].join("; ");
ctx.set("Content-Security-Policy", csp);
ctx.set("X-Frame-Options", "SAMEORIGIN");
ctx.set("X-Content-Type-Options", "nosniff");
ctx.set("Referrer-Policy", "same-origin");
ctx.set("Permissions-Policy", "speaker=(self)");
const validHostsString = validHosts.join(" or ");
ctx.assert(
isValidRequest(ctx.request),
400,
`Request must be addressed to ${validHostsString} and non-GET requests must contain non-blob referer.`
);
await next();
if (isClearnet && typeof ctx.body === 'string') {
const type = String(ctx.response.type || ctx.response.get('Content-Type') || '').toLowerCase();
if (type.includes('html')) {
ctx.body = obfuscateClearnetHtml(ctx.body);
}
}
});
// pdf viewer
const pdfjsPath = path.join(__dirname, '../server/node_modules/pdfjs-dist/build/pdf.min.js');
app.use(koaStatic(pdfjsPath));
middleware.forEach((m) => app.use(m));
const server = app.listen({ host, port });
server.on("listening", () => {
const address = server.address();
if (typeof address === "string") {
throw new Error("HTTP server should never bind to Unix socket");
}
if (allowHost !== null) {
validHosts.push(allowHost);
}
validHosts.push(address.address);
if (validHosts.includes(host) === false) {
validHosts.push(host);
}
for (const ip of collectLocalIPs()) {
if (validHosts.includes(ip) === false) validHosts.push(ip);
}
for (const loopback of ['localhost', '127.0.0.1']) {
if (validHosts.includes(loopback) === false) validHosts.push(loopback);
}
});
return server;
};

View file

@ -0,0 +1,70 @@
"use strict";
const path = require('path');
const yargs = require(path.join(__dirname, '../server/node_modules/yargs'));
const { hideBin } = require(path.join(__dirname, '../server/node_modules/yargs/helpers'));
const _ = require(path.join(__dirname, '../server/node_modules/lodash'));
const moduleAlias = require(path.join(__dirname, '../server/node_modules/module-alias'));
moduleAlias.addAlias('punycode', 'punycode/');
const HELP_TEXT = `Usage: sh oasis.sh [mode] [options]
Modes:
gui Launch the Oasis web GUI (default if no mode given).
server, pub Launch only the Oasis Sbot (headless, PUB mode).
help, -h, --help Show this help message.
PUB admin commands (require the Oasis Sbot to be running):
whoami Print this Oasis ID.
invite [N] Create an invite code. N = number of uses (default 1).
name <text> Set this Oasis display name.
announce <host> [port] Publish a pub address (default port 8008).
follow <feedId> Follow another Oasis ID / feed.
status Show peer / replication status.
gossip List known gossip peers.
GUI options (forwarded to the backend):
--host=<ip> Hostname / IP the web UI listens on (default: localhost).
Use 0.0.0.0 to expose on a VPS.
--port=<n> Port for the web UI (default: 3000).
--allow-host=<host> Extra hostname allowed when behind a reverse proxy.
--public Public-hosting mode: disables POST and redacts content
from people who haven't opted in to public hosting.
--offline Don't connect to Oasis peers or pubs.
--no-open Don't auto-open a browser tab on launch (useful on VPS).
--debug Verbose logging.
Examples:
sh oasis.sh
sh oasis.sh server
sh oasis.sh invite 100
sh oasis.sh name "My PUB"
sh oasis.sh announce mypub.example.com
sh oasis.sh --host=0.0.0.0 --port=8080 --no-open
`;
const cli = (presets /*, defaultConfigFile */) => {
const argv = process.argv.slice(2);
if (argv.includes('-h') || argv.includes('--help') || argv.includes('-help') || argv.includes('help')) {
process.stdout.write(HELP_TEXT);
process.exit(0);
}
return yargs(hideBin(process.argv))
.scriptName("oasis")
.env("OASIS")
.help(false)
.version(false)
.options("open", { default: _.get(presets, "open", true), type: "boolean" })
.options("offline", { default: _.get(presets, "offline", false), type: "boolean" })
.options("host", { default: _.get(presets, "host", "localhost"), type: "string" })
.options("allow-host", { default: _.get(presets, "allow-host", null), type: "string" })
.options("port", { default: _.get(presets, "port", 3000), type: "number" })
.options("public", { default: _.get(presets, "public", false), type: "boolean" })
.options("debug", { default: _.get(presets, "debug", false), type: "boolean" })
.parserConfiguration({ "strip-aliased": true })
.argv;
};
module.exports = { cli };

View file

@ -0,0 +1,115 @@
ECOin - Copyright (c) - 2014/2025 - GPLv3 - epsylon@riseup.net (https://ecoin.03c8.net)
===========================================
# SOURCES for P2P Crypto-Currency (ECOin) #
===========================================
Testing machine is: Debian GNU/Linux 12 (bookworm) (x86_64).
All of the commands should be executed in a shell.
------------------------------
(0.) Clone the github tree to get the source code:
+ Official:
git clone http://code.03c8.net/epsylon/ecoin
+ Mirror:
git clone https://github.com/epsylon/ecoin
------------------------------
===================================================
# SERVER -ecoind- for P2P Crypto-Currency (ECOin) #
===================================================
(0.) Version libraries:
- Libboost -> source code 1.68 provided at: src/boost_1_68_0
(1.) Install dependencies:
sudo apt-get install build-essential libssl-dev libssl3 libdb5.3-dev libdb5.3++-dev libleveldb-dev miniupnpc libminiupnpc-dev
+ Optionally install qrencode (and set USE_QRCODE=1):
sudo apt-get install libqrencode-dev
(2.) Now you should be able to build ecoind:
cd src/
make -f makefile.linux USE_UPNP=- USE_IPV6=-
strip ecoind
An executable named 'ecoind' will be built.
Now you can launch: ./ecoind to run your ECOin server.
------------------------------
============================================
# WALLET for P2P Crypto-Currency (ECOin) #
============================================
(0.) Version libraries:
- Libboost -> source code 1.68 provided at: src/boost_1_68_0
(1.) First, make sure that the required packages for Qt5 development (an the others required for building the daemon) are installed:
sudo apt-get install qt5-qmake qtbase5-dev build-essential libssl-dev libssl3 libdb5.3-dev libdb5.3++-dev libleveldb-dev miniupnpc libminiupnpc-dev
+ Optionally install qrencode (and set USE_QRCODE=1):
sudo apt-get install libqrencode-dev
(2.) Then execute the following:
qmake USE_UPNP=- USE_IPV6=-
make
An executable named 'ecoin-qt' will be built.
Now you can launch: ./ecoin-qt to run your ECOin wallet/GUI.
------------------------------
========================================
+ CPU MINER for ECOin (Unix/GNU-Linux) #
========================================
See doc/MINING.txt for detailed instructions on running /ecoin-miner/ on different platforms.
+ GNU/Linux:
cd miner/
sh build.sh
An executable named 'cpuminer' will be built.
Now you can launch: ./cpuminer to run your ECOin PoW miner.
======================================
For a list of command-line options:
./ecoind --help
To start the ECOin daemon:
./ecoind -daemon
For ECOin-QT Wallet
./ecoin-qt
For debugging:
tail -f /home/$USER/.ecoin/debug.log
======================================

View file

@ -0,0 +1,316 @@
/* KARVAN client mensajes temporales + WebRTC (data-channel).
* Capa fiable: relay por servidor (RAM, autodestrucción) con polling.
* Capa turbo: data-channel WebRTC P2P (instantáneo) con "perfect negotiation".
* Todo degrada: si no hay WebRTC o no conecta, el chat sigue por el relay.
* Sin cámara/mic (solo data-channel) => no necesita permisos del wrapper. */
(function () {
"use strict";
var root = document.querySelector(".karvan-room");
if (!root) return;
var roomId = root.getAttribute("data-room");
var selfId = root.getAttribute("data-self") || "";
var peerId = "p" + Math.random().toString(16).slice(2, 12);
var fromLabel = selfId || peerId;
var listEl = document.getElementById("karvan-msgs");
var formEl = document.getElementById("karvan-form");
var textEl = document.getElementById("karvan-text");
var liveTxt = document.getElementById("karvan-live-txt");
var liveWrap = document.getElementById("karvan-live");
var seen = Object.create(null);
var lastSeq = 0;
var lastSig = 0;
var peers = Object.create(null);
// --- Fase 1: videollamada (media sobre el MISMO WebRTC del chat) ---
var callRoot = document.getElementById("karvan-call");
var localVid = document.getElementById("karvan-local");
var remoteEl = document.getElementById("karvan-remote");
var statusEl = document.getElementById("kc-status");
var btnStart = document.getElementById("kc-start");
var btnMic = document.getElementById("kc-mic");
var btnCam = document.getElementById("kc-cam");
var btnStop = document.getElementById("kc-stop");
var localStream = (window.MediaStream ? new MediaStream() : null);
if (localVid && localStream) localVid.srcObject = localStream;
var micActive = false, camActive = false;
function esc(s) { return String(s == null ? "" : s); }
function shortId(id) { id = esc(id || "?"); return id.charAt(0) === "@" ? id.slice(1, 7) : id.slice(0, 6); }
function showMsg(m, live) {
var key = m.mid ? "m" + m.mid : "s" + (m.seq || Math.random());
if (seen[key]) return;
seen[key] = 1;
var li = document.createElement("li");
li.className = "karvan-msg" + (m.from === fromLabel ? " karvan-msg-self" : "") + (live ? " karvan-msg-live" : "");
var f = document.createElement("span"); f.className = "km-from"; f.textContent = shortId(m.from);
var t = document.createElement("span"); t.className = "km-text"; t.textContent = esc(m.text);
li.appendChild(f); li.appendChild(t);
listEl.appendChild(li);
listEl.scrollTop = listEl.scrollHeight;
}
// --- relay por servidor (siempre activo) ---
function pollMsgs() {
fetch("/karvan/" + roomId + "/msgs?since=" + lastSeq, { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.messages) return;
d.messages.forEach(function (m) { if (m.seq > lastSeq) lastSeq = m.seq; showMsg(m, false); });
})
.catch(function () {});
}
function sendMessage(text) {
if (!text) return;
var mid = peerId + "-" + Date.now().toString(36) + Math.random().toString(16).slice(2, 6);
var m = { mid: mid, from: fromLabel, text: text };
showMsg(m, false); // optimista
broadcastDC({ kind: "chat", mid: mid, from: fromLabel, text: text }); // instantáneo a los pares
fetch("/karvan/" + roomId + "/msg", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: fromLabel, text: text, mid: mid })
}).catch(function () {});
}
if (formEl) {
formEl.addEventListener("submit", function (e) {
e.preventDefault();
var v = (textEl.value || "").trim();
if (!v) return;
textEl.value = "";
sendMessage(v);
});
}
// --- señalización (mailbox RAM del servidor) ---
function sendSig(to, payload) {
fetch("/karvan/" + roomId + "/signal", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: peerId, to: to || "", payload: payload })
}).catch(function () {});
}
function announce() { sendSig("", { kind: "hello" }); }
function pollSignals() {
fetch("/karvan/" + roomId + "/signal?for=" + peerId + "&since=" + lastSig, { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
(d.signals || []).forEach(function (s) {
if (s.seq > lastSig) lastSig = s.seq;
onSignal(s.from, s.payload);
});
(d.members || []).forEach(function (mid2) { if (mid2 && mid2 !== peerId) ensurePeer(mid2); });
})
.catch(function () {});
}
// --- WebRTC (data-channel, perfect negotiation) ---
var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection;
// Sin servidores ICE por defecto: solo candidatos host (LAN y NAT amable).
// Un STUN de terceros aprende la IP publica y el momento de cada llamada, asi que
// no se pone ninguno de fabrica. Si el nodo ofrece TURN propio, /karvan/ice lo da.
var rtcConfig = { iceServers: [] };
function setLive(on) {
if (!liveTxt) return;
liveTxt.textContent = on ? "🔒 P2P (WebRTC)" : "server relay";
if (liveWrap) liveWrap.className = "karvan-live" + (on ? " on" : "");
}
function broadcastDC(obj) {
var s = JSON.stringify(obj);
for (var k in peers) {
var dc = peers[k] && peers[k].dc;
if (dc && dc.readyState === "open") { try { dc.send(s); } catch (e) {} }
}
}
function attachDC(peer, dc) {
peer.dc = dc;
dc.onopen = function () { setLive(true); };
dc.onmessage = function (e) {
try { var m = JSON.parse(e.data); if (m && m.kind === "chat") showMsg(m, true); } catch (x) {}
};
dc.onclose = function () {
var any = false;
for (var k in peers) if (peers[k].dc && peers[k].dc.readyState === "open") any = true;
if (!any) setLive(false);
};
}
function ensurePeer(remoteId) {
if (peers[remoteId] || !RTC) return peers[remoteId];
var polite = peerId > remoteId; // el id mayor es "polite"
var pc = new RTC(rtcConfig);
var peer = { pc: pc, dc: null, polite: polite, makingOffer: false, ignoreOffer: false, vid: null, remoteStream: null };
peers[remoteId] = peer;
if (!polite) { attachDC(peer, pc.createDataChannel("chat")); } // el impolite inicia el canal
pc.ondatachannel = function (e) { attachDC(peer, e.channel); };
// media: publicar los tracks locales que ya tengamos (si la llamada está activa) + recibir los del par
if (localStream) { localStream.getTracks().forEach(function (t) { try { pc.addTrack(t, localStream); } catch (e) {} }); }
pc.ontrack = function (e) { attachRemote(peer, remoteId, e); };
pc.onicecandidate = function (e) { if (e.candidate) sendSig(remoteId, { kind: "ice", candidate: e.candidate }); };
pc.onnegotiationneeded = function () {
peer.makingOffer = true;
Promise.resolve()
.then(function () { return pc.setLocalDescription(); })
.then(function () { sendSig(remoteId, { kind: "desc", desc: pc.localDescription }); })
.catch(function () {})
.then(function () { peer.makingOffer = false; });
};
pc.onconnectionstatechange = function () {
var st = pc.connectionState;
if (st === "failed" || st === "closed") { try { pc.close(); } catch (e) {} removeRemoteVideo(peer); delete peers[remoteId]; }
};
return peer;
}
// --- media / videollamada (Fase 1) ---
function setCallActive(on) { if (callRoot) callRoot.className = "karvan-call" + (on ? " active" : ""); }
function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; }
function statusText() { var p = []; if (micActive) p.push("mic ON"); if (camActive) p.push("cam ON"); return p.length ? p.join(" · ") : ""; }
function updateBtns() {
if (btnMic) btnMic.className = "kc-btn" + (micActive ? " on" : "");
if (btnCam) btnCam.className = "kc-btn" + (camActive ? " on" : "");
}
function attachRemote(peer, remoteId, e) {
if (!remoteEl) return;
if (!peer.vid) {
peer.vid = document.createElement("video");
peer.vid.autoplay = true; peer.vid.playsInline = true; peer.vid.className = "kc-video kc-remote-vid";
peer.vid.setAttribute("data-peer", remoteId);
remoteEl.appendChild(peer.vid);
}
if (e.streams && e.streams[0]) peer.vid.srcObject = e.streams[0];
else { if (!peer.remoteStream) peer.remoteStream = new MediaStream(); peer.remoteStream.addTrack(e.track); peer.vid.srcObject = peer.remoteStream; }
setCallActive(true);
}
function removeRemoteVideo(peer) {
if (peer && peer.vid && peer.vid.parentNode) { try { peer.vid.parentNode.removeChild(peer.vid); } catch (_) {} peer.vid = null; }
}
function addLocalTrack(track) {
if (!localStream) return;
localStream.addTrack(track);
for (var k in peers) {
var pc = peers[k].pc, senders = pc.getSenders(), existing = null;
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === track.kind) { existing = senders[s]; break; }
if (existing) { try { existing.replaceTrack(track); } catch (_) {} } // swap (cambio de cámara) — sin renegociar
else { try { pc.addTrack(track, localStream); } catch (_) {} } // kind nuevo — dispara onnegotiationneeded
}
}
function removeLocalKind(kind) {
if (!localStream) return;
localStream.getTracks().filter(function (t) { return t.kind === kind; }).forEach(function (t) { t.stop(); localStream.removeTrack(t); });
for (var k in peers) {
var senders = peers[k].pc.getSenders();
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === kind) { try { senders[s].replaceTrack(null); } catch (_) {} }
}
}
function onMediaErr(kind, x) {
var name = (x && x.name) || "", msg = (x && x.message) || String(x);
if (name === "NotAllowedError" || /denied|permission/i.test(msg)) setStatus("⛔ Cámara/micro denegados (en el móvil, pendiente del wrapper). El chat sigue.");
else if (name === "NotFoundError") setStatus("⚠ No hay " + (kind === "audio" ? "micrófono" : "cámara") + ".");
else if (name === "NotReadableError") setStatus("⚠ Dispositivo ocupado.");
else setStatus("⚠ " + (name || "error") + ": " + msg);
}
function getMedia(kind) {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
var c = (kind === "audio") ? { audio: true, video: false } : { audio: false, video: true };
setStatus((kind === "audio" ? "🎤" : "🎥") + " pidiendo permiso…");
navigator.mediaDevices.getUserMedia(c).then(function (s) {
s.getTracks().forEach(addLocalTrack);
if (kind === "audio") micActive = true; else camActive = true;
setCallActive(true); updateBtns(); setStatus(statusText());
}).catch(function (x) { onMediaErr(kind, x); });
}
function startCall() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
setStatus("📞 pidiendo cámara y micro…");
navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(function (s) {
s.getTracks().forEach(addLocalTrack);
micActive = true; camActive = true; setCallActive(true); updateBtns(); setStatus(statusText());
}).catch(function (x) {
if (x && x.name === "NotFoundError") { getMedia("audio"); return; } // sin cámara → intenta solo audio
onMediaErr("call", x);
});
}
function hangUp() {
if (localStream) localStream.getTracks().forEach(function (t) { t.stop(); localStream.removeTrack(t); });
for (var k in peers) { var sn = peers[k].pc.getSenders(); for (var s = 0; s < sn.length; s++) { try { sn[s].replaceTrack(null); } catch (_) {} } }
micActive = false; camActive = false; setCallActive(false); updateBtns(); setStatus("");
}
if (btnStart) btnStart.addEventListener("click", startCall);
if (btnMic) btnMic.addEventListener("click", function () { if (micActive) { removeLocalKind("audio"); micActive = false; updateBtns(); setStatus(statusText()); } else getMedia("audio"); });
if (btnCam) btnCam.addEventListener("click", function () { if (camActive) { removeLocalKind("video"); camActive = false; updateBtns(); setStatus(statusText()); } else getMedia("video"); });
if (btnStop) btnStop.addEventListener("click", hangUp);
function onSignal(fromRemote, payload) {
if (!RTC || !payload) return;
var peer = ensurePeer(fromRemote);
if (!peer) return;
var pc = peer.pc;
if (payload.kind === "desc") {
var desc = payload.desc;
if (!desc) return; // señal malformada (sin desc) — no reventar el lote de sondeo
var collision = desc.type === "offer" && (peer.makingOffer || pc.signalingState !== "stable");
peer.ignoreOffer = !peer.polite && collision;
if (peer.ignoreOffer) return;
Promise.resolve()
.then(function () { return pc.setRemoteDescription(desc); })
.then(function () {
if (desc.type === "offer") {
return pc.setLocalDescription().then(function () {
sendSig(fromRemote, { kind: "desc", desc: pc.localDescription });
});
}
})
.catch(function () {});
} else if (payload.kind === "ice") {
Promise.resolve().then(function () { return pc.addIceCandidate(payload.candidate); }).catch(function () {});
}
}
// --- arranque ---
showMsgInitialSeq();
function showMsgInitialSeq() {
// registrar los mensajes ya renderizados por el servidor para no duplicarlos
var items = listEl ? listEl.querySelectorAll(".karvan-msg") : [];
for (var i = 0; i < items.length; i++) {
var seq = parseInt(items[i].getAttribute("data-seq"), 10) || 0;
var mid = items[i].getAttribute("data-mid") || "";
if (seq > lastSeq) lastSeq = seq;
seen[mid ? "m" + mid : "s" + seq] = 1;
}
}
pollMsgs();
setInterval(pollMsgs, 2000);
if (RTC) {
setLive(false);
// La configuracion ICE tiene que estar resuelta antes de la primera
// RTCPeerConnection: no se puede cambiar de forma fiable una ya creada.
var iceReady = fetch("/karvan/ice", { headers: { accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && Array.isArray(d.iceServers) && d.iceServers.length) {
rtcConfig = { iceServers: d.iceServers };
if (d.relayOnly) rtcConfig.iceTransportPolicy = "relay";
}
})
.catch(function () {});
var iceTimeout = new Promise(function (res) { setTimeout(res, 2000); });
Promise.race([iceReady, iceTimeout]).then(function () {
announce();
setInterval(announce, 8000);
pollSignals();
setInterval(pollSignals, 2000);
});
}
})();

View file

@ -0,0 +1,76 @@
document.addEventListener('DOMContentLoaded', () => {
if (typeof pdfjsLib === 'undefined') return;
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.mjs';
document.querySelectorAll('.pdf-viewer-container').forEach(async container => {
const pdfUrl = container.getAttribute('data-pdf-url');
if (!pdfUrl) return;
const pdf = await pdfjsLib.getDocument(pdfUrl).promise;
let currentPage = 1;
let scale = 1.5;
let rotation = 0;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
container.innerHTML = '';
container.appendChild(canvas);
const controls = document.createElement('div');
controls.className = 'pdf-controls';
controls.innerHTML = `
<button id="prev"></button>
<button id="next"></button>
<button id="zoomIn">🔍+</button>
<button id="zoomOut">🔍</button>
<button id="rotate"></button>
<button id="download"></button>
<button id="fullscreen">🔲</button>
<button id="metadata"></button>
`;
container.appendChild(controls);
const renderPage = async (num) => {
const page = await pdf.getPage(num);
const viewport = page.getViewport({ scale, rotation });
canvas.width = viewport.width;
canvas.height = viewport.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport }).promise;
};
const goToPage = (delta) => {
const newPage = currentPage + delta;
if (newPage >= 1 && newPage <= pdf.numPages) {
currentPage = newPage;
renderPage(currentPage);
}
};
renderPage(currentPage);
controls.querySelector('#prev').onclick = () => goToPage(-1);
controls.querySelector('#next').onclick = () => goToPage(1);
controls.querySelector('#zoomIn').onclick = () => { scale += 0.2; renderPage(currentPage); };
controls.querySelector('#zoomOut').onclick = () => { scale = Math.max(0.5, scale - 0.2); renderPage(currentPage); };
controls.querySelector('#rotate').onclick = () => { rotation = (rotation + 90) % 360; renderPage(currentPage); };
controls.querySelector('#download').onclick = () => {
const a = document.createElement('a');
a.href = pdfUrl;
a.download = 'document.pdf';
a.click();
};
controls.querySelector('#fullscreen').onclick = () => {
if (canvas.requestFullscreen) canvas.requestFullscreen();
else if (canvas.webkitRequestFullscreen) canvas.webkitRequestFullscreen();
else if (canvas.mozRequestFullScreen) canvas.mozRequestFullScreen();
else if (canvas.msRequestFullscreen) canvas.msRequestFullscreen();
};
controls.querySelector('#metadata').onclick = async () => {
const info = await pdf.getMetadata();
alert(`Title: ${info.info.Title || 'N/A'}\nAuthor: ${info.info.Author || 'N/A'}\nPDF Producer: ${info.info.Producer || 'N/A'}`);
};
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,126 @@
const fs = require('fs');
const path = require('path');
const configFilePath = path.join(__dirname, 'oasis-config.json');
if (!fs.existsSync(configFilePath)) {
const defaultConfig = {
"themes": {
"current": "Dark-SNH"
},
"ux": {
"current": "blocks"
},
"modules": {
"blogsMod": "on",
"housingMod": "off",
"karvanMod": "on",
"popularMod": "on",
"topicsMod": "on",
"summariesMod": "on",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"pollsMod": "on",
"fediverseMod": "off",
"invitesMod": "on",
"walletMod": "on",
"legacyMod": "on",
"devMod": "on",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "on",
"docsMod": "on",
"audiosMod": "on",
"tagsMod": "on",
"imagesMod": "on",
"trendingMod": "on",
"eventsMod": "on",
"tasksMod": "on",
"marketMod": "on",
"votesMod": "on",
"tribesMod": "on",
"reportsMod": "on",
"opinionsMod": "on",
"padsMod": "on",
"calendarsMod": "on",
"transfersMod": "on",
"feedMod": "on",
"pixeliaMod": "on",
"melodyMod": "on",
"agendaMod": "on",
"aiMod": "on",
"aiNavMod": "on",
"forumMod": "on",
"gamesMod": "on",
"jobsMod": "on",
"shopsMod": "on",
"projectsMod": "on",
"industryMod": "on",
"bankingMod": "on",
"parliamentMod": "on",
"courtsMod": "on",
"favoritesMod": "on",
"logsMod": "on",
"mapsMod": "on",
"chatsMod": "on",
"torrentsMod": "on",
"graphosMod": "on",
"larpMod": "on"
},
"wallet": {
"url": "http://localhost:7474",
"user": "",
"pass": "",
"fee": "5"
},
"walletPub": {
"pubId": ""
},
"ai": {
"prompt": "Provide an informative and precise response."
},
"ssbLogStream": {
"limit": 2000
},
"homePage": "activity",
"language": "en",
"wish": "whole",
"pmVisibility": "whole",
"lanBroadcasting": true
};
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
}
const getConfig = () => {
const configData = fs.readFileSync(configFilePath);
const cfg = JSON.parse(configData);
if (!['whole', 'mutuals', 'only-lan'].includes(cfg.wish)) cfg.wish = 'whole';
if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole';
if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks';
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'chats' && cfg.modules && cfg.modules.chatsMod !== 'on') cfg.ux.current = 'blocks';
let pins = Array.isArray(cfg.bottomBarPins)
? cfg.bottomBarPins
: (typeof cfg.bottomBarPin === 'string'
? (cfg.bottomBarPin ? [cfg.bottomBarPin] : [])
: ['search', 'inbox', 'publish']);
pins = pins.filter(k => typeof k === 'string' && k);
pins = pins.filter((k, i) => pins.indexOf(k) === i).slice(0, 4);
cfg.bottomBarPins = pins;
delete cfg.bottomBarPin;
return cfg;
};
const saveConfig = (newConfig) => {
fs.writeFileSync(configFilePath, JSON.stringify(newConfig, null, 2));
};
module.exports = {
getConfig,
saveConfig,
};

View file

@ -0,0 +1,27 @@
{
"audios": [],
"blogs": [],
"bookmarks": [],
"calendars": [],
"chats": [],
"documents": [],
"events": [],
"forum": [],
"housing": [],
"images": [],
"jobs": [],
"logs": [],
"maps": [],
"market": [],
"pads": [],
"polls": [],
"projects": [],
"reports": [],
"shopProducts": [],
"shops": [],
"tasks": [],
"torrents": [],
"transfers": [],
"videos": [],
"votes": []
}

View file

@ -0,0 +1,61 @@
{
"logging": {
"level": "notice"
},
"caps": {
"shs": "H5EC+V5BU9s0lWxCkt4z8a095Sj8a6TgiLKPYi1JD7s="
},
"pub": false,
"local": true,
"friends": {
"dunbar": 300,
"hops": 2
},
"gossip": {
"connections": 20,
"local": true,
"friends": true,
"seed": true,
"global": true
},
"replicationScheduler": {
"autostart": true,
"partialReplication": null
},
"autofollow": {
"enabled": false,
"feeds": []
},
"connections": {
"seeds": [],
"incoming": {
"net": [
{
"scope": ["device", "local"],
"transform": "shs",
"port": 8008
}
],
"unix": [
{
"scope": [
"device",
"local",
"private"
],
"transform": "noauth"
}
]
},
"outgoing": {
"net": [
{
"transform": "shs"
}
],
"tunnel": [],
"onion": [],
"ws": []
}
}
}

View file

@ -0,0 +1,44 @@
let _inboxCount = 0;
let _carbonHcT = 0;
let _carbonHcH = 0;
let _lastRefresh = 0;
let _onlinePeers = null;
let _inboxUnread = null;
let _lastSyncTs = null;
let _ecoValue = null;
let _lastActivity = null;
let _maxBlockBytes = 0;
let _inhabitantCount = 0;
let _mentionsCount = 0;
let _bestMatch = null;
let _dismissedSuggestion = null;
module.exports = {
getInboxCount: () => _inboxCount,
setInboxCount: (n) => { _inboxCount = n; },
getCarbonHcT: () => _carbonHcT,
setCarbonHcT: (n) => { _carbonHcT = n; },
getCarbonHcH: () => _carbonHcH,
setCarbonHcH: (n) => { _carbonHcH = n; },
getLastRefresh: () => _lastRefresh,
setLastRefresh: (t) => { _lastRefresh = t; },
getOnlinePeerCount: () => _onlinePeers,
setOnlinePeerCount: (n) => { _onlinePeers = n; },
getInboxUnreadCount: () => _inboxUnread,
setInboxUnreadCount: (n) => { _inboxUnread = n; },
getLastSyncTs: () => _lastSyncTs,
setLastSyncTs: (t) => { _lastSyncTs = t; },
getEcoValue: () => _ecoValue,
setEcoValue: (v) => { _ecoValue = v; },
getLastActivity: () => _lastActivity,
setLastActivity: (a) => { _lastActivity = a; },
getMaxBlockBytes: () => _maxBlockBytes,
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
getMentionsCount: () => _mentionsCount,
setMentionsCount: (n) => { _mentionsCount = Math.max(0, Number(n) || 0); },
getInhabitantCount: () => _inhabitantCount,
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); },
getBestMatch: () => _bestMatch,
setBestMatch: (m) => { _bestMatch = m || null; },
getDismissedSuggestion: () => _dismissedSuggestion,
setDismissedSuggestion: (href) => { _dismissedSuggestion = href || null; }
};

285
src/games/8ball/index.html Normal file
View file

@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>8Ball Pool</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: crosshair; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 24px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 6px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">8BALL POOL</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>SHOTS: <b id="shots">0</b></span>
<span>BEST: <b id="best">-</b></span>
</div>
<canvas id="c" width="600" height="340"></canvas>
<div id="msg">Click to aim &amp; shoot. Hold = more power.</div>
<div id="controls">Click on table to aim cue ball &nbsp;|&nbsp; Hold longer = more power &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="8ball">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const TABLE_X = 20, TABLE_Y = 20, TABLE_W = 560, TABLE_H = 280;
const POCKET_R = 20, BALL_R = 10;
const FRICTION = 0.975;
const POCKETS = [
[TABLE_X, TABLE_Y], [TABLE_X + TABLE_W/2, TABLE_Y - 6], [TABLE_X + TABLE_W, TABLE_Y],
[TABLE_X, TABLE_Y + TABLE_H], [TABLE_X + TABLE_W/2, TABLE_Y + TABLE_H + 6], [TABLE_X + TABLE_W, TABLE_Y + TABLE_H]
];
const BALL_COLORS = ['#fff','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000','#000','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000'];
const BALL_STRIPE = [false,false,false,false,false,false,false,false,false,true,true,true,true,true,true,true];
let score = 0, shots = 0, best = parseInt(localStorage.getItem('8ball_best') || '-1');
let balls = [], state = 'idle', mouseDown = false, mousePos = {x:0,y:0}, powerStart = 0, power = 0;
let foul = false, gameOver = false;
function initBalls() {
balls = [];
const cx = TABLE_X + TABLE_W * 0.72, cy = TABLE_Y + TABLE_H / 2;
const sp = BALL_R * 2.05;
const rack = [
[0,0],[1,-0.5],[1,0.5],[2,-1],[2,0],[2,1],[3,-1.5],[3,-0.5],[3,0.5],[3,1.5],
[4,-2],[4,-1],[4,0],[4,1],[4,2]
];
for (let i = 0; i < 15; i++) {
const [row, col] = rack[i];
balls.push({ x: cx + row * sp * 0.866, y: cy + col * sp, vx: 0, vy: 0, potted: false, idx: i + 1 });
}
balls.push({ x: TABLE_X + TABLE_W * 0.25, y: cy, vx: 0, vy: 0, potted: false, idx: 0 });
}
function resetGame() {
score = 0; shots = 0; foul = false; gameOver = false;
state = 'aiming';
document.getElementById('score').textContent = '0';
document.getElementById('shots').textContent = '0';
document.getElementById('msg').textContent = 'Click to aim & shoot. Hold = more power.';
document.getElementById('scoreSubmit').style.display = 'none';
initBalls();
}
const cue = () => balls.find(b => b.idx === 0);
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function processPhysics() {
let moving = true;
let steps = 0;
while (moving && steps < 800) {
steps++;
moving = false;
for (const b of balls) {
if (b.potted) continue;
b.x += b.vx; b.y += b.vy;
b.vx *= FRICTION; b.vy *= FRICTION;
if (Math.abs(b.vx) < 0.05) b.vx = 0;
if (Math.abs(b.vy) < 0.05) b.vy = 0;
if (Math.abs(b.vx) > 0.05 || Math.abs(b.vy) > 0.05) moving = true;
const lx = TABLE_X + BALL_R, rx = TABLE_X + TABLE_W - BALL_R;
const ty = TABLE_Y + BALL_R, by = TABLE_Y + TABLE_H - BALL_R;
if (b.x < lx) { b.x = lx; b.vx = Math.abs(b.vx) * 0.8; }
if (b.x > rx) { b.x = rx; b.vx = -Math.abs(b.vx) * 0.8; }
if (b.y < ty) { b.y = ty; b.vy = Math.abs(b.vy) * 0.8; }
if (b.y > by) { b.y = by; b.vy = -Math.abs(b.vy) * 0.8; }
for (const p of POCKETS) {
if (Math.hypot(b.x - p[0], b.y - p[1]) < POCKET_R) {
b.potted = true; b.vx = 0; b.vy = 0;
if (b.idx !== 0) {
if (b.idx === 8) { foul = true; }
else { score++; document.getElementById('score').textContent = score; }
} else {
b.x = TABLE_X + TABLE_W * 0.25; b.y = TABLE_Y + TABLE_H / 2; b.potted = false;
}
break;
}
}
}
const active = balls.filter(b => !b.potted);
for (let i = 0; i < active.length; i++) {
for (let j = i + 1; j < active.length; j++) {
const a = active[i], b2 = active[j];
const dx = b2.x - a.x, dy = b2.y - a.y;
const d = Math.hypot(dx, dy);
if (d < BALL_R * 2 && d > 0.001) {
const nx = dx / d, ny = dy / d;
const rel = (a.vx - b2.vx) * nx + (a.vy - b2.vy) * ny;
if (rel > 0) {
a.vx -= rel * nx; a.vy -= rel * ny;
b2.vx += rel * nx; b2.vy += rel * ny;
}
const overlap = BALL_R * 2 - d;
a.x -= overlap / 2 * nx; a.y -= overlap / 2 * ny;
b2.x += overlap / 2 * nx; b2.y += overlap / 2 * ny;
}
}
}
}
}
function shoot(targetX, targetY, pw) {
const c = cue();
if (!c) return;
shots++;
document.getElementById('shots').textContent = shots;
const angle = Math.atan2(targetY - c.y, targetX - c.x);
const spd = pw * 0.28;
c.vx = Math.cos(angle) * spd;
c.vy = Math.sin(angle) * spd;
processPhysics();
const remaining = balls.filter(b => !b.potted && b.idx !== 0 && b.idx !== 8);
if (remaining.length === 0) {
const eight = balls.find(b => b.idx === 8);
if (!eight || eight.potted) {
endGame();
}
} else if (foul) {
endGame();
}
}
function endGame() {
gameOver = true;
state = 'over';
const finalScore = foul ? Math.floor(score / 2) : score;
document.getElementById('score').textContent = finalScore;
if (best < 0 || finalScore > best) {
best = finalScore;
localStorage.setItem('8ball_best', best);
document.getElementById('best').textContent = best;
}
document.getElementById('msg').textContent = foul ? `FOUL! 8-ball potted early. Score: ${finalScore}. SPACE = new game` : `Game over! Potted ${finalScore} balls in ${shots} shots. SPACE = new game`;
document.getElementById('scoreInput').value = finalScore;
document.getElementById('scoreSubmit').style.display = 'block';
}
function getCanvasPos(e) {
const r = canvas.getBoundingClientRect();
const touch = e.touches ? e.touches[0] : e;
return { x: (touch.clientX - r.left) * canvas.width / r.width, y: (touch.clientY - r.top) * canvas.height / r.height };
}
canvas.addEventListener('mousedown', e => {
if (state !== 'aiming') return;
mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); power = 0;
});
canvas.addEventListener('mousemove', e => { mousePos = getCanvasPos(e); });
canvas.addEventListener('mouseup', e => {
if (!mouseDown || state !== 'aiming') return;
mouseDown = false;
power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
shoot(mousePos.x, mousePos.y, power);
});
canvas.addEventListener('touchstart', e => { e.preventDefault(); if (state !== 'aiming') return; mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); mousePos = getCanvasPos(e); }, { passive: false });
canvas.addEventListener('touchend', e => {
e.preventDefault();
if (!mouseDown || state !== 'aiming') return;
mouseDown = false;
power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
shoot(mousePos.x, mousePos.y, power);
}, { passive: false });
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); resetGame(); }
});
function drawTable() {
ctx.fillStyle = '#2d5a1b';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#1a6b30';
ctx.fillRect(TABLE_X, TABLE_Y, TABLE_W, TABLE_H);
ctx.fillStyle = '#8B4513';
ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, TABLE_W + 28, 14);
ctx.fillRect(TABLE_X - 14, TABLE_Y + TABLE_H, TABLE_W + 28, 14);
ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, 14, TABLE_H + 28);
ctx.fillRect(TABLE_X + TABLE_W, TABLE_Y - 14, 14, TABLE_H + 28);
for (const p of POCKETS) {
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(p[0], p[1], POCKET_R, 0, Math.PI * 2); ctx.fill();
}
}
function drawBall(b) {
if (b.potted) return;
const stripe = BALL_STRIPE[b.idx];
ctx.fillStyle = BALL_COLORS[b.idx];
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.fill();
if (stripe) {
ctx.save();
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.clip();
ctx.fillStyle = '#fff';
ctx.fillRect(b.x - BALL_R, b.y - BALL_R * 0.4, BALL_R * 2, BALL_R * 0.8);
ctx.restore();
}
if (b.idx > 0) {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R * 0.38, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.font = `bold ${BALL_R * 0.6}px monospace`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(b.idx, b.x, b.y + 0.5);
}
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.stroke();
}
function drawAim() {
const c = cue();
if (!c) return;
const angle = Math.atan2(mousePos.y - c.y, mousePos.x - c.x);
const pw = mouseDown ? Math.min((Date.now() - powerStart) / 1000 * 80, 100) : 30;
ctx.strokeStyle = mouseDown ? `rgba(255,${Math.round(165*(1-pw/100))},0,0.7)` : 'rgba(255,255,255,0.4)';
ctx.lineWidth = mouseDown ? 2 : 1;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(c.x, c.y);
ctx.lineTo(c.x + Math.cos(angle) * 120, c.y + Math.sin(angle) * 120);
ctx.stroke();
ctx.setLineDash([]);
if (mouseDown) {
ctx.fillStyle = '#FFA500';
ctx.fillRect(TABLE_X, TABLE_Y + TABLE_H + 6, pw / 100 * TABLE_W, 6);
ctx.strokeStyle = '#555'; ctx.lineWidth = 1;
ctx.strokeRect(TABLE_X, TABLE_Y + TABLE_H + 6, TABLE_W, 6);
}
}
if (best >= 0) document.getElementById('best').textContent = best;
resetGame();
function loop() {
ctx.clearRect(0, 0, W, H);
drawTable();
balls.forEach(drawBall);
if (state === 'aiming') drawAim();
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#0a5e2a"/>
<rect x="6" y="6" width="108" height="68" fill="#0a6e2e" rx="3"/>
<rect x="8" y="8" width="104" height="64" fill="#0d8040" rx="2"/>
<circle cx="20" cy="20" r="5" fill="#fff"/>
<circle cx="40" cy="15" r="5" fill="#ff0"/>
<circle cx="40" cy="25" r="5" fill="#00f"/>
<circle cx="55" cy="20" r="5" fill="#f00"/>
<circle cx="55" cy="10" r="5" fill="#800080"/>
<circle cx="55" cy="30" r="5" fill="#f90"/>
<circle cx="70" cy="15" r="5" fill="#0a0"/>
<circle cx="70" cy="25" r="5" fill="#700"/>
<circle cx="85" cy="20" r="5" fill="#000"/>
<text x="85" y="24" font-size="6" fill="#fff" text-anchor="middle" font-weight="bold">8</text>
<circle cx="6" cy="6" r="4" fill="#000"/>
<circle cx="114" cy="6" r="4" fill="#000"/>
<circle cx="6" cy="74" r="4" fill="#000"/>
<circle cx="114" cy="74" r="4" fill="#000"/>
<circle cx="60" cy="6" r="4" fill="#000"/>
<circle cx="60" cy="74" r="4" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arkanoid</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: none; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ARKANOID</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>LEVEL: <b id="level">1</b></span>
</div>
<canvas id="c" width="560" height="420"></canvas>
<div id="msg">Press SPACE or CLICK to start</div>
<div id="controls">&#8592;&#8594; or MOUSE — Move paddle &nbsp;|&nbsp; SPACE / CLICK — Launch ball</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="arkanoid">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PADDLE_W = 80, PADDLE_H = 12, PADDLE_Y = H - 30;
const BALL_R = 8;
const BRICK_COLS = 10, BRICK_ROWS = 5;
const BRICK_W = 48, BRICK_H = 16, BRICK_GAP = 4;
const BRICK_OFF_X = (W - (BRICK_COLS * (BRICK_W + BRICK_GAP) - BRICK_GAP)) / 2;
const BRICK_OFF_Y = 40;
const BRICK_COLORS = ['#e74c3c', '#e67e22', '#FFA500', '#2ecc71', '#3498db'];
let state = 'idle';
let score = 0, lives = 3, level = 1;
let paddle, ball, bricks, attached;
function initGame() {
paddle = { x: W / 2 - PADDLE_W / 2, y: PADDLE_Y, w: PADDLE_W, h: PADDLE_H };
resetBall();
initBricks();
}
function resetBall() {
ball = { x: paddle.x + paddle.w / 2, y: PADDLE_Y - BALL_R - 1, vx: 3 + level * 0.3, vy: -(4 + level * 0.3) };
attached = true;
}
function initBricks() {
bricks = [];
for (let r = 0; r < BRICK_ROWS; r++) {
for (let c = 0; c < BRICK_COLS; c++) {
bricks.push({
x: BRICK_OFF_X + c * (BRICK_W + BRICK_GAP),
y: BRICK_OFF_Y + r * (BRICK_H + BRICK_GAP),
w: BRICK_W, h: BRICK_H,
alive: true,
color: BRICK_COLORS[r % BRICK_COLORS.length],
points: (BRICK_ROWS - r) * 10
});
}
}
}
function launch() {
if (state === 'idle' || state === 'over' || state === 'win') {
if (state === 'over') { score = 0; lives = 3; level = 1; document.getElementById('score').textContent = '0'; document.getElementById('lives').textContent = '3'; document.getElementById('level').textContent = '1'; }
if (state === 'win') { level++; document.getElementById('level').textContent = level; }
initGame();
state = 'play';
attached = false;
document.getElementById('msg').textContent = '';
return;
}
if (attached) {
attached = false;
}
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') { e.preventDefault(); launch(); }
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
canvas.addEventListener('click', launch);
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * canvas.width / rect.width;
paddle.x = Math.max(0, Math.min(W - paddle.w, mx - paddle.w / 2));
if (attached) ball.x = paddle.x + paddle.w / 2;
});
function reflect(ball, bx, by, bw, bh) {
const overlapLeft = ball.x + BALL_R - bx;
const overlapRight = bx + bw - (ball.x - BALL_R);
const overlapTop = ball.y + BALL_R - by;
const overlapBottom = by + bh - (ball.y - BALL_R);
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
if (minOverlap === overlapTop || minOverlap === overlapBottom) ball.vy *= -1;
else ball.vx *= -1;
}
function loop() {
ctx.clearRect(0, 0, W, H);
if (state === 'play') {
if (keys['ArrowLeft']) paddle.x = Math.max(0, paddle.x - 6);
if (keys['ArrowRight']) paddle.x = Math.min(W - paddle.w, paddle.x + 6);
if (attached) {
ball.x = paddle.x + paddle.w / 2;
} else {
ball.x += ball.vx;
ball.y += ball.vy;
if (ball.x - BALL_R < 0) { ball.x = BALL_R; ball.vx = Math.abs(ball.vx); }
if (ball.x + BALL_R > W) { ball.x = W - BALL_R; ball.vx = -Math.abs(ball.vx); }
if (ball.y - BALL_R < 0) { ball.y = BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.y + BALL_R >= paddle.y && ball.y + BALL_R <= paddle.y + paddle.h + Math.abs(ball.vy) &&
ball.x >= paddle.x - BALL_R && ball.x <= paddle.x + paddle.w + BALL_R) {
const rel = (ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2);
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = rel * speed * 1.1;
ball.vy = -Math.abs(ball.vy);
ball.y = paddle.y - BALL_R - 1;
}
if (ball.y - BALL_R > H) {
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = 'GAME OVER — Press SPACE'; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
else { resetBall(); attached = true; }
}
for (const b of bricks) {
if (!b.alive) continue;
if (ball.x + BALL_R > b.x && ball.x - BALL_R < b.x + b.w && ball.y + BALL_R > b.y && ball.y - BALL_R < b.y + b.h) {
b.alive = false;
score += b.points;
document.getElementById('score').textContent = score;
reflect(ball, b.x, b.y, b.w, b.h);
break;
}
}
if (bricks.every(b => !b.alive)) {
state = 'win';
document.getElementById('msg').textContent = 'LEVEL CLEAR! — Press SPACE for next level';
}
}
}
bricks.forEach(b => {
if (!b.alive) return;
ctx.fillStyle = b.color;
ctx.beginPath();
ctx.roundRect(b.x, b.y, b.w, b.h, 3);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.stroke();
});
ctx.fillStyle = '#FFA500';
ctx.beginPath();
ctx.roundRect(paddle.x, paddle.y, paddle.w, paddle.h, 4);
ctx.fill();
ctx.fillStyle = '#FFD700';
ctx.fillRect(paddle.x + 8, paddle.y + 3, paddle.w - 16, 3);
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.arc(ball.x - 3, ball.y - 3, BALL_R * 0.4, 0, Math.PI * 2);
ctx.fill();
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<g>
<rect x="20" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="82" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="144" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="206" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="268" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="330" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
</g>
<g>
<rect x="20" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="82" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="144" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="206" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="268" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="330" y="46" width="54" height="18" rx="3" fill="#3498db"/>
</g>
<g>
<rect x="20" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="82" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="144" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="206" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="268" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="330" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
</g>
<g>
<rect x="20" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="82" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="144" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="330" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
</g>
<circle cx="220" cy="155" r="9" fill="#fff"/>
<rect x="155" y="190" width="90" height="14" rx="5" fill="#FFA500"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artillery</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 16px; padding: 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#controls-panel { display: flex; gap: 16px; align-items: center; padding: 8px; flex-wrap: wrap; justify-content: center; }
#controls-panel label { color: #aaa; font-size: 13px; }
#controls-panel input[type=range] { width: 120px; }
#controls-panel span { color: #FFA500; font-size: 14px; min-width: 40px; }
#fire-btn { background: #3a0000; border: 1px solid #f44; color: #f44; padding: 6px 20px; cursor: pointer; font-family: monospace; font-size: 15px; font-weight: bold; }
#fire-btn:hover { background: #5a0000; }
#msg { font-size: 15px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ARTILLERY</span>
</div>
<div id="ui">
<span>ROUND: <b id="roundEl">1/5</b></span>
<span>SHOTS: <b id="shotsEl">0</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>WIND: <b id="windEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c" width="800" height="400"></canvas>
<div id="controls-panel">
<label>Angle: <input type="range" id="angleInput" min="1" max="89" value="45"> <span id="angleVal">45</span>°</label>
<label>Power: <input type="range" id="powerInput" min="1" max="100" value="60"> <span id="powerVal">60</span></label>
<button id="fire-btn">FIRE!</button>
</div>
<div id="msg">Adjust angle and power, then fire!</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="artillery">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const TOTAL_ROUNDS = 5;
let round = 1, shots = 0, score = 0, roundShots = 0;
let terrain = [], cannonY = 0, targetX = 0, targetY = 0, wind = 0;
let lastPath = [], lastHit = null, hitFlag = false;
let gameOver = false;
let best = parseInt(localStorage.getItem('artillery_best') || '-1');
function seededRand(seed) {
let s = seed;
return function() { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
}
function generateTerrain(seed) {
const rng = seededRand(seed);
const pts = new Array(80).fill(0);
pts[0] = 150 + rng() * 80;
pts[79] = 150 + rng() * 80;
function subdivide(arr, lo, hi, rough) {
if (hi - lo <= 1) return;
const mid = Math.floor((lo + hi) / 2);
arr[mid] = (arr[lo] + arr[hi]) / 2 + (rng() - 0.5) * rough;
subdivide(arr, lo, mid, rough * 0.6);
subdivide(arr, mid, hi, rough * 0.6);
}
subdivide(pts, 0, 79, 100);
for (let i = 0; i < 80; i++) pts[i] = Math.max(60, Math.min(H - 30, pts[i]));
return pts;
}
function terrainHeightAt(x) {
const idx = Math.floor(x / (W / 79));
const clamped = Math.max(0, Math.min(78, idx));
return terrain[clamped];
}
function startRound() {
const seed = Date.now() + round * 1337;
terrain = generateTerrain(seed);
const rng = seededRand(seed + 42);
targetX = Math.floor(W * 0.6 + rng() * W * 0.3);
targetY = terrainHeightAt(targetX) - 1;
wind = (rng() - 0.5) * 16;
roundShots = 0;
lastPath = []; lastHit = null; hitFlag = false;
document.getElementById('windEl').textContent = wind.toFixed(1);
document.getElementById('roundEl').textContent = `${round}/${TOTAL_ROUNDS}`;
document.getElementById('msg').textContent = `Round ${round} — Adjust angle and power, then fire!`;
}
function calcTrajectory(angleDeg, pw) {
const angle = angleDeg * Math.PI / 180;
let vx = Math.cos(angle) * pw * 0.45;
let vy = -Math.sin(angle) * pw * 0.45;
const gravity = 0.18;
const windA = wind * 0.012;
const cannonX = 30;
const startY = terrainHeightAt(cannonX) - 6;
let x = cannonX, y = startY;
const path = [[x, y]];
let hitX = null;
for (let i = 0; i < 500; i++) {
vx += windA; vy += gravity;
x += vx; y += vy;
if (x < 0 || x > W) break;
if (y > H) break;
const th = terrainHeightAt(x);
if (y >= th) { hitX = x; break; }
path.push([x, y]);
}
return { path, hitX };
}
function fire() {
if (gameOver) return;
lastPath = [];
lastHit = null;
hitFlag = false;
const angle = parseInt(document.getElementById('angleInput').value);
const pw = parseInt(document.getElementById('powerInput').value);
const { path, hitX } = calcTrajectory(angle, pw);
lastPath = path;
lastHit = hitX;
shots++; roundShots++;
document.getElementById('shotsEl').textContent = shots;
if (hitX !== null && Math.abs(hitX - targetX) < 28) {
hitFlag = true;
const roundScore = Math.max(10, 100 - (roundShots - 1) * 15) + (roundShots === 1 ? 50 : 0);
score += roundScore;
document.getElementById('scoreEl').textContent = score;
document.getElementById('msg').textContent = `HIT! +${roundScore} pts. ${round < TOTAL_ROUNDS ? 'Next round...' : 'Game over!'}`;
setTimeout(() => {
lastPath = []; lastHit = null; hitFlag = false;
if (round < TOTAL_ROUNDS) { round++; startRound(); } else { endGame(); }
}, 1400);
} else {
const dist = hitX ? Math.abs(Math.round(hitX - targetX)) : '?';
document.getElementById('msg').textContent = `Miss! Distance: ${dist}px from target.`;
}
}
function endGame() {
gameOver = true;
if (best < 0 || score > best) {
best = score;
localStorage.setItem('artillery_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('msg').textContent = `All rounds done! Final score: ${score}. SPACE = new game`;
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function newGame() {
round = 1; shots = 0; score = 0; roundShots = 0; gameOver = false;
document.getElementById('scoreEl').textContent = '0';
document.getElementById('shotsEl').textContent = '0';
document.getElementById('scoreSubmit').style.display = 'none';
startRound();
}
document.getElementById('angleInput').addEventListener('input', function() { document.getElementById('angleVal').textContent = this.value; });
document.getElementById('powerInput').addEventListener('input', function() { document.getElementById('powerVal').textContent = this.value; });
document.getElementById('fire-btn').addEventListener('click', fire);
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); if (gameOver) newGame(); else fire(); } });
if (best >= 0) document.getElementById('bestEl').textContent = best;
function drawTerrain() {
ctx.fillStyle = '#4a7c3f';
ctx.beginPath();
ctx.moveTo(0, H);
for (let i = 0; i < 80; i++) {
const x = i * (W / 79);
ctx.lineTo(x, terrain[i]);
}
ctx.lineTo(W, H); ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#2d5a25'; ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < 80; i++) ctx.lineTo(i * (W / 79), terrain[i]);
ctx.stroke();
}
function drawSky() {
const grad = ctx.createLinearGradient(0, 0, 0, H * 0.7);
grad.addColorStop(0, '#000020');
grad.addColorStop(1, '#1a3a6a');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
}
function drawCannon() {
const cx = 30, cy = terrainHeightAt(30);
const angle = parseInt(document.getElementById('angleInput').value) * Math.PI / 180;
ctx.fillStyle = '#888';
ctx.fillRect(cx - 10, cy - 8, 20, 10);
ctx.strokeStyle = '#aaa'; ctx.lineWidth = 4; ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx, cy - 3);
ctx.lineTo(cx + Math.cos(angle) * 28, cy - 3 - Math.sin(angle) * 28);
ctx.stroke();
ctx.fillStyle = '#666';
ctx.beginPath(); ctx.arc(cx, cy + 2, 8, 0, Math.PI * 2); ctx.fill();
}
function drawTarget() {
const x = targetX, y = targetY;
ctx.strokeStyle = '#f44'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x - 14, y); ctx.lineTo(x + 14, y); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x, y - 14); ctx.lineTo(x, y + 14); ctx.stroke();
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#f44'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 22, y - 16); ctx.lineTo(x + 22, y - 5); ctx.stroke();
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.moveTo(x + 22, y - 16); ctx.lineTo(x + 34, y - 10); ctx.lineTo(x + 22, y - 5); ctx.fill();
}
function drawTrajectory() {
if (lastPath.length < 2) return;
ctx.strokeStyle = hitFlag ? 'rgba(100,255,100,0.6)' : 'rgba(255,200,0,0.5)';
ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]);
ctx.beginPath();
lastPath.forEach((pt, i) => i === 0 ? ctx.moveTo(pt[0], pt[1]) : ctx.lineTo(pt[0], pt[1]));
ctx.stroke(); ctx.setLineDash([]);
if (lastHit !== null) {
const hy = terrainHeightAt(lastHit);
ctx.fillStyle = hitFlag ? '#0f0' : '#f80';
ctx.beginPath(); ctx.arc(lastHit, hy, 8, 0, Math.PI * 2); ctx.fill();
}
}
function drawWind() {
const arrow = wind >= 0 ? '→' : '←';
const strength = Math.abs(wind).toFixed(1);
ctx.fillStyle = '#87CEEB'; ctx.font = '13px monospace';
ctx.fillText(`WIND ${arrow} ${strength}`, W - 130, 22);
}
newGame();
function loop() {
drawSky();
if (terrain.length) {
drawTerrain();
drawTrajectory();
drawCannon();
drawTarget();
drawWind();
}
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#87CEEB"/>
<polygon points="0,80 0,55 20,50 35,45 50,48 65,40 80,50 95,42 110,52 120,48 120,80" fill="#4a7c3f"/>
<rect x="8" y="56" width="20" height="8" fill="#555" rx="2"/>
<rect x="20" y="50" width="14" height="5" fill="#666" rx="2" transform="rotate(-30 27 52)"/>
<circle cx="30" cy="45" r="3" fill="#f44" opacity="0.8"/>
<circle cx="50" cy="35" r="2.5" fill="#f44" opacity="0.6"/>
<circle cx="70" cy="28" r="2" fill="#f44" opacity="0.4"/>
<rect x="95" y="38" width="8" height="12" fill="#f00" rx="1"/>
<polygon points="99,38 95,32 103,32" fill="#f00"/>
<text x="60" y="20" font-size="10" fill="#fff" text-anchor="middle" font-weight="bold" opacity="0.8">~</text>
</svg>

After

Width:  |  Height:  |  Size: 793 B

View file

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Asteroids</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ASTEROIDS</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>LEVEL: <b id="level">1</b></span>
</div>
<canvas id="c" width="600" height="420"></canvas>
<div id="msg">Press SPACE to start</div>
<div id="controls">&#8592;&#8594; Rotate &nbsp;|&nbsp; &#8593; Thrust &nbsp;|&nbsp; SPACE Shoot</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="asteroids">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let state = 'idle';
let score = 0, lives = 3, level = 1;
let ship, bullets, asteroids, invincible, frames;
const SHIP_SIZE = 14;
const BULLET_SPEED = 9;
const TURN_SPEED = 0.065;
const THRUST = 0.18;
const FRICTION = 0.985;
const stars = Array.from({ length: 80 }, () => ({
x: Math.random() * W, y: Math.random() * H, r: Math.random() * 1.5 + 0.5, b: Math.random() * 0.7 + 0.3
}));
function initGame() {
ship = { x: W / 2, y: H / 2, vx: 0, vy: 0, angle: -Math.PI / 2, cooldown: 0 };
bullets = [];
invincible = 120;
frames = 0;
spawnAsteroids();
}
function randAsteroid(size, x, y) {
const angle = Math.random() * Math.PI * 2;
const speed = (0.6 + Math.random() * 0.8) * (level * 0.15 + 1);
const pts = 7 + Math.floor(Math.random() * 5);
const verts = Array.from({ length: pts }, (_, i) => {
const a = (i / pts) * Math.PI * 2;
const r = size * (0.75 + Math.random() * 0.5);
return { x: Math.cos(a) * r, y: Math.sin(a) * r };
});
return {
x: x ?? (Math.random() < 0.5 ? Math.random() * 100 : W - Math.random() * 100),
y: y ?? (Math.random() < 0.5 ? Math.random() * 100 : H - Math.random() * 100),
vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
size, verts, rot: 0, rotSpeed: (Math.random() - 0.5) * 0.04
};
}
function spawnAsteroids() {
asteroids = [];
for (let i = 0; i < 3 + level; i++) asteroids.push(randAsteroid(38));
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') {
e.preventDefault();
if (state === 'idle' || state === 'over') {
score = 0; lives = 3; level = 1;
document.getElementById('score').textContent = '0';
document.getElementById('lives').textContent = '3';
document.getElementById('level').textContent = '1';
initGame(); state = 'play';
document.getElementById('msg').textContent = '';
}
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function wrap(obj) {
if (obj.x < -50) obj.x = W + 50;
if (obj.x > W + 50) obj.x = -50;
if (obj.y < -50) obj.y = H + 50;
if (obj.y > H + 50) obj.y = -50;
}
function drawShip(s, alpha) {
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(s.angle);
ctx.strokeStyle = `rgba(255,165,0,${alpha})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(SHIP_SIZE, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.6);
ctx.lineTo(-SHIP_SIZE * 0.4, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.6);
ctx.closePath();
ctx.stroke();
if (keys['ArrowUp'] && state === 'play' && frames % 4 < 2) {
ctx.fillStyle = `rgba(255,100,0,${alpha})`;
ctx.beginPath();
ctx.moveTo(-SHIP_SIZE * 0.4, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.35);
ctx.lineTo(-SHIP_SIZE * 1.3, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.35);
ctx.fill();
}
ctx.restore();
}
function drawAsteroid(a) {
ctx.save();
ctx.translate(a.x, a.y);
ctx.rotate(a.rot);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(a.verts[0].x, a.verts[0].y);
for (let i = 1; i < a.verts.length; i++) ctx.lineTo(a.verts[i].x, a.verts[i].y);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function loop() {
ctx.clearRect(0, 0, W, H);
stars.forEach(s => {
ctx.fillStyle = `rgba(255,255,255,${s.b})`;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
});
if (state === 'play') {
if (keys['ArrowLeft']) ship.angle -= TURN_SPEED;
if (keys['ArrowRight']) ship.angle += TURN_SPEED;
if (keys['ArrowUp']) {
ship.vx += Math.cos(ship.angle) * THRUST;
ship.vy += Math.sin(ship.angle) * THRUST;
}
ship.vx *= FRICTION; ship.vy *= FRICTION;
ship.x += ship.vx; ship.y += ship.vy;
wrap(ship);
if (ship.cooldown > 0) ship.cooldown--;
if (keys['Space'] && ship.cooldown === 0) {
bullets.push({ x: ship.x + Math.cos(ship.angle) * SHIP_SIZE, y: ship.y + Math.sin(ship.angle) * SHIP_SIZE, vx: Math.cos(ship.angle) * BULLET_SPEED + ship.vx, vy: Math.sin(ship.angle) * BULLET_SPEED + ship.vy, life: 55 });
ship.cooldown = 12;
}
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += b.vx; b.y += b.vy; b.life--;
wrap(b);
if (b.life <= 0) { bullets.splice(i, 1); continue; }
let hit = false;
for (let j = asteroids.length - 1; j >= 0; j--) {
const a = asteroids[j];
const dx = b.x - a.x, dy = b.y - a.y;
if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.85) {
const pts = a.size > 25 ? 20 : a.size > 14 ? 50 : 100;
score += pts;
document.getElementById('score').textContent = score;
bullets.splice(i, 1);
asteroids.splice(j, 1);
if (a.size > 25) { asteroids.push(randAsteroid(18, a.x + 10, a.y)); asteroids.push(randAsteroid(18, a.x - 10, a.y)); }
else if (a.size > 14) { asteroids.push(randAsteroid(10, a.x, a.y + 10)); asteroids.push(randAsteroid(10, a.x, a.y - 10)); }
hit = true; break;
}
}
if (hit) continue;
}
for (let j = asteroids.length - 1; j >= 0; j--) {
const a = asteroids[j];
a.x += a.vx; a.y += a.vy; a.rot += a.rotSpeed;
wrap(a);
if (invincible <= 0) {
const dx = ship.x - a.x, dy = ship.y - a.y;
if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.75 + SHIP_SIZE * 0.6) {
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = `GAME OVER! Score: ${score} — SPACE to retry`; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
else { ship.x = W / 2; ship.y = H / 2; ship.vx = 0; ship.vy = 0; invincible = 120; }
}
}
}
if (invincible > 0) invincible--;
if (asteroids.length === 0) {
level++;
document.getElementById('level').textContent = level;
spawnAsteroids();
}
frames++;
}
asteroids.forEach(drawAsteroid);
bullets.forEach(b => {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(b.x, b.y, 2.5, 0, Math.PI * 2); ctx.fill();
});
const alpha = invincible > 0 && frames % 6 < 3 ? 0.3 : 1;
drawShip(ship, alpha);
for (let i = 0; i < lives; i++) {
ctx.save(); ctx.translate(16 + i * 22, H - 18); ctx.rotate(-Math.PI / 2);
ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(9, 0); ctx.lineTo(-6, -5); ctx.lineTo(-3, 0); ctx.lineTo(-6, 5); ctx.closePath(); ctx.stroke();
ctx.restore();
}
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
<rect width="400" height="220" fill="#000"/>
<circle cx="30" cy="20" r="1.5" fill="#fff" opacity="0.8"/>
<circle cx="80" cy="45" r="1" fill="#fff" opacity="0.6"/>
<circle cx="120" cy="10" r="2" fill="#fff" opacity="0.9"/>
<circle cx="200" cy="30" r="1" fill="#fff" opacity="0.7"/>
<circle cx="320" cy="15" r="1.5" fill="#fff" opacity="0.8"/>
<circle cx="370" cy="50" r="1" fill="#fff" opacity="0.6"/>
<circle cx="50" cy="180" r="1" fill="#fff" opacity="0.5"/>
<circle cx="350" cy="190" r="1.5" fill="#fff" opacity="0.7"/>
<circle cx="280" cy="8" r="1" fill="#fff" opacity="0.8"/>
<polygon points="85,60 110,45 140,55 155,80 140,110 110,120 75,105 65,80" fill="#888" stroke="#aaa" stroke-width="2"/>
<polygon points="240,30 265,22 290,35 300,60 285,85 255,90 230,75 225,50" fill="#777" stroke="#999" stroke-width="2"/>
<polygon points="310,130 330,118 355,128 365,152 350,172 325,178 305,165 298,142" fill="#999" stroke="#bbb" stroke-width="2"/>
<polygon points="40,130 58,120 75,132 80,155 65,170 43,172 28,160 25,140" fill="#666" stroke="#888" stroke-width="2"/>
<polygon points="200,105 196,95 200,92 204,95 200,108" fill="#FFA500" stroke="#FFA500" stroke-width="1"/>
<polygon points="196,108 192,118 200,112 208,118 204,108" fill="#FFA500"/>
<circle cx="200" cy="96" r="4" fill="#fff" stroke="#FFA500" stroke-width="1.5"/>
<rect x="192" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
<rect x="204" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
<rect x="197" y="115" width="6" height="14" fill="#ff3300" opacity="0.6"/>
<line x1="200" y1="80" x2="200" y2="50" stroke="#fff" stroke-width="2" opacity="0.8"/>
<circle cx="200" cy="46" r="3" fill="#fff" opacity="0.9"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,693 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Musical Double Pendulum</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #1a1a1a;
--border: #333;
--accent: #4a9eff;
--axis: #2b6fff;
--grid: #121212;
--text: #ffffff;
--muted: #888;
--trace1: #ff6b4a;
--trace2: #4aff6b;
--warning: #ff6b4a;
--success: #4aff6b;
--audio: #ff9500;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 14px; font-family: Consolas, monospace; background: var(--bg); color: var(--text); }
.container { display: flex; gap: 14px; align-items: flex-start; }
.simulation-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 14px; flex: 1; min-width: 0;
}
.controls-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 14px; width: 340px; flex-shrink: 0;
}
h3 { margin: 0 0 8px 0; color: var(--accent); font-size: 13px; letter-spacing: 2px; text-transform: uppercase; }
.audio-section { border: 2px solid var(--audio); border-radius: 8px; padding: 10px; margin: 8px 0; background: #1a0f00; }
.audio-section h3 { color: var(--audio); margin-bottom: 8px; }
.status { background:#000; color:var(--accent); padding:7px 10px; border-radius:6px; margin-bottom:7px; font-size:11px; }
.audio-status { background:#1a0f00; color:var(--audio); border: 1px solid var(--audio); }
.toolbar { display:flex; gap:8px; margin-bottom:8px; flex-wrap: wrap; }
button {
padding: 6px 10px; border: 1px solid var(--accent); background: var(--panel); color: var(--accent);
border-radius: 6px; cursor: pointer; transition: all .2s; font-family: inherit; font-size: 12px;
}
button:hover { background: var(--accent); color: #000; }
button.audio-btn { border-color: var(--audio); color: var(--audio); }
button.audio-btn:hover { background: var(--audio); color: #000; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
canvas { background:#000; border:1px solid #444; display:block; width:100%; cursor:grab; }
canvas:active { cursor:grabbing; }
.geometry-info { background:#0a0a2a; color:#99aaff; padding:8px 10px; border-radius:6px; margin-top:8px; font-size:11px; }
.energy-info { background:#0a2a0a; color:#99ff99; padding:8px 10px; border-radius:6px; margin-top:5px; font-size:11px; }
.control-group { margin-bottom: 10px; padding-bottom:10px; border-bottom:1px solid #333; }
.control-group:last-child { border-bottom: none; }
label { display:block; margin-bottom:4px; font-size:11px; color:#ccc; text-transform:uppercase; letter-spacing:1px; }
input[type="range"] { width:100%; margin-bottom:4px; accent-color: var(--accent); }
.audio-control input[type="range"] { accent-color: var(--audio); }
.value-display { background:#000; color:var(--accent); padding:2px 6px; border-radius:3px; font-size:11px; float:right; }
.audio-value { color: var(--audio); }
.state-tag { display: none; font-size: 11px; }
.state-tag.visible { display: inline; }
#topbar { width:100%; padding:8px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; margin-bottom:10px; }
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
#topbar a:hover { text-decoration:underline; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">AUDIO PENDULUM</span>
</div>
<div class="container">
<div class="simulation-panel" id="simPanel">
<div class="status" id="status">Status: Stopped | Energy: 0.00 J | Time: 0.00 s</div>
<div class="status audio-status" id="audioStatus">Tonal: Disabled | Percussion: Disabled | ♪: -- Hz | 🥁: 0 hits</div>
<div class="toolbar">
<button id="startBtn">&#9654; Start</button>
<button id="stopBtn">&#9209; Stop</button>
<button id="resetBtn">&#8635; Reset</button>
<button id="clearBtn">Clear Traces</button>
<button id="audioBtn" class="audio-btn">&#128266; Enable Audio</button>
</div>
<canvas id="pendulumCanvas" width="680" height="540"></canvas>
<div class="geometry-info" id="geometryInfo">
<strong>Physical State:</strong><br />
<span id="geoTheta">Initial position: vertical up (12 o'clock)</span><br />
<span id="geoOmega">State: Unstable equilibrium</span><br />
<span id="geoStateNearEq" class="state-tag" style="color:var(--success)">&#10003; NEAR EQUILIBRIUM</span>
<span id="geoStateMoving" class="state-tag visible">In motion</span>
<span id="geoStateRest" class="state-tag" style="color:var(--success)"><br />&#10003; AT REST</span>
<span id="geoStateTonal" class="state-tag" style="color:var(--audio)"><br />&#127925; TONAL AUDIO ACTIVE</span>
<span id="geoStatePerc" class="state-tag" style="color:var(--audio)"><br />&#129345; PERCUSSION ACTIVE</span>
</div>
<div class="energy-info" id="energyInfo">
<strong>Energy Conservation:</strong><br />
<span id="energyLine1">Initial: -- J | Current: -- J</span><br />
<span id="energyLine2">Energy drift: 0.000% | Status: CORRECT</span>
<span id="energySingularity" class="state-tag" style="color:var(--warning)"><br />Singularities: 0</span>
</div>
</div>
<div class="controls-panel">
<div class="control-group">
<label>Pendulum 1 Length (m) <span class="value-display" id="l1Display">1.00</span></label>
<input type="range" id="l1" min="0.2" max="3.0" step="0.05" value="1.0" />
<label>Pendulum 2 Length (m) <span class="value-display" id="l2Display">1.00</span></label>
<input type="range" id="l2" min="0.2" max="3.0" step="0.05" value="1.0" />
</div>
<div class="control-group">
<label>Pendulum 1 Mass (kg) <span class="value-display" id="m1Display">1.00</span></label>
<input type="range" id="m1" min="0.2" max="5.0" step="0.1" value="1.0" />
<label>Pendulum 2 Mass (kg) <span class="value-display" id="m2Display">1.00</span></label>
<input type="range" id="m2" min="0.2" max="5.0" step="0.1" value="1.0" />
</div>
<div class="control-group">
<label>Gravity (m/s²) <span class="value-display" id="gDisplay">9.81</span></label>
<input type="range" id="g" min="0.1" max="20.0" step="0.1" value="9.81" />
<label>Friction / Damping <span class="value-display" id="dampingDisplay">0.10</span></label>
<input type="range" id="damping" min="0" max="2.0" step="0.01" value="0.1" />
</div>
<div class="control-group">
<label>Angular Velocity 1 (rad/s) <span class="value-display" id="w1Display">0.00</span></label>
<input type="range" id="w1" min="-6.0" max="6.0" step="0.1" value="0.0" />
<label>Angular Velocity 2 (rad/s) <span class="value-display" id="w2Display">0.00</span></label>
<input type="range" id="w2" min="-6.0" max="6.0" step="0.1" value="0.0" />
</div>
<div class="audio-section">
<div class="control-group audio-control">
<label>Master Volume <span class="value-display audio-value" id="volumeDisplay">0.30</span></label>
<input type="range" id="volume" min="0" max="1.0" step="0.05" value="0.3" />
<label>Base Freq - Pendulum 1 (Hz) <span class="value-display audio-value" id="freq1Display">440</span></label>
<input type="range" id="freq1" min="200" max="1000" step="10" value="440" />
<label>Base Freq - Pendulum 2 (Hz) <span class="value-display audio-value" id="freq2Display">550</span></label>
<input type="range" id="freq2" min="200" max="1000" step="10" value="550" />
<label>Sensitivity <span class="value-display audio-value" id="sensitivityDisplay">2.0</span></label>
<input type="range" id="sensitivity" min="0.5" max="5.0" step="0.1" value="2.0" />
</div>
<div class="control-group audio-control">
<label>Percussion Volume <span class="value-display audio-value" id="percVolumeDisplay">0.00</span></label>
<input type="range" id="percVolume" min="0" max="1.0" step="0.05" value="0" />
<label>Peak Threshold <span class="value-display audio-value" id="thresholdDisplay">1.0</span></label>
<input type="range" id="threshold" min="0.2" max="3.0" step="0.1" value="1.0" />
<label>Cooldown (ms) <span class="value-display audio-value" id="cooldownDisplay">100</span></label>
<input type="range" id="cooldown" min="50" max="500" step="10" value="100" />
</div>
</div>
<div class="control-group">
<label>Integration &#916;t (s) <span class="value-display" id="dtDisplay">0.005</span></label>
<input type="range" id="dt" min="0.001" max="0.020" step="0.001" value="0.005" />
</div>
</div>
</div>
<script>
class MusicalDoublePendulum {
constructor(canvasId, panelId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.panel = document.getElementById(panelId);
this.width = this.canvas.width;
this.height = this.canvas.height;
this.originX = this.width / 2;
this.originY = this.height / 2;
this.l1 = 1.0; this.l2 = 1.0;
this.m1 = 1.0; this.m2 = 1.0;
this.g = 9.81;
this.damping = 0.1;
this.theta1 = Math.PI;
this.theta2 = 0.0;
this.omega1 = 0.0; this.omega2 = 0.0;
this.running = false;
this.time = 0;
this.dt = 0.005;
this.scale = 100;
this.trace1 = []; this.trace2 = [];
this.maxTraceLength = 3000;
this.dragging = false;
this.dragTarget = null;
this.initialEnergy = 0;
this.currentEnergy = 0;
this.maxEnergyDrift = 0;
this.energyHistory = [];
this.singularityCount = 0;
this.audioContext = null;
this.oscillator1 = null; this.oscillator2 = null;
this.gainNode1 = null; this.gainNode2 = null;
this.masterGain = null;
this.audioEnabled = false;
this.percussionEnabled = false;
this.kickBuffer = null; this.snareBuffer = null;
this.lastOmega1 = 0; this.lastOmega2 = 0;
this.omega1Trend = 0; this.omega2Trend = 0;
this.lastTrigger1 = 0; this.lastTrigger2 = 0;
this.triggerCooldown = 0.1;
this.baseFreq1 = 440; this.baseFreq2 = 550;
this.volume = 0.3;
this.sensitivity = 2.0;
this.percussionVolume = 0;
this.peakThreshold = 1.0;
this.currentFreq1 = 0; this.currentFreq2 = 0;
this.kickCount = 0; this.snareCount = 0;
this.setupEventListeners();
this.updateControls();
this.initialEnergy = this.computeEnergy();
requestAnimationFrame(() => { this.resizeCanvas(); });
}
createPercussionSounds() {
if (!this.audioContext) return;
const sr = this.audioContext.sampleRate;
const kickBuf = this.audioContext.createBuffer(1, 0.5 * sr, sr);
const kd = kickBuf.getChannelData(0);
for (let i = 0; i < kd.length; i++) {
const t = i / sr;
const env = Math.exp(-t * 15);
kd[i] = Math.sin(2 * Math.PI * 60 * (1 - t * 0.8) * t) * env + (Math.random() - 0.5) * 0.1 * env;
}
this.kickBuffer = kickBuf;
const snareBuf = this.audioContext.createBuffer(1, 0.2 * sr, sr);
const sd = snareBuf.getChannelData(0);
for (let i = 0; i < sd.length; i++) {
const t = i / sr;
const env = Math.exp(-t * 30);
sd[i] = (Math.sin(2 * Math.PI * 200 * t) * 0.3 + (Math.random() - 0.5) * 0.7) * env;
}
this.snareBuffer = snareBuf;
}
playPercussion(type) {
if (!this.percussionEnabled || !this.audioContext) return;
const buf = type === 'kick' ? this.kickBuffer : this.snareBuffer;
if (!buf) return;
const now = this.audioContext.currentTime;
const src = this.audioContext.createBufferSource();
const gain = this.audioContext.createGain();
src.buffer = buf;
gain.gain.setValueAtTime(this.percussionVolume * (type === 'kick' ? 0.8 : 0.6), now);
src.connect(gain);
gain.connect(this.audioContext.destination);
src.start(now);
if (type === 'kick') this.kickCount++; else this.snareCount++;
setTimeout(() => { try { src.disconnect(); gain.disconnect(); } catch (e) {} }, 600);
}
detectPeaks() {
if (!this.percussionEnabled) return;
const now = this.audioContext ? this.audioContext.currentTime : this.time;
const d1 = this.omega1 - this.lastOmega1;
const d2 = this.omega2 - this.lastOmega2;
const t1 = d1 > 0.01 ? 1 : (d1 < -0.01 ? -1 : 0);
const t2 = d2 > 0.01 ? 1 : (d2 < -0.01 ? -1 : 0);
if (this.omega1Trend === 1 && t1 === -1 && Math.abs(this.omega1) > this.peakThreshold && now - this.lastTrigger1 > this.triggerCooldown) {
this.playPercussion('kick');
this.lastTrigger1 = now;
this.flashPercStatus('kick');
}
if (this.omega2Trend === 1 && t2 === -1 && Math.abs(this.omega2) > this.peakThreshold && now - this.lastTrigger2 > this.triggerCooldown) {
this.playPercussion('snare');
this.lastTrigger2 = now;
this.flashPercStatus('snare');
}
this.lastOmega1 = this.omega1; this.lastOmega2 = this.omega2;
this.omega1Trend = t1; this.omega2Trend = t2;
}
flashPercStatus(type) {
const el = document.getElementById(type === 'kick' ? 'kick-status' : 'snare-status');
if (!el) return;
const orig = el.textContent;
el.textContent = type === 'kick' ? '\u{1F941} Kick: HIT!' : '\u{1F514} Snare: HIT!';
el.style.color = type === 'kick' ? '#ff6b4a' : '#4aff6b';
setTimeout(() => { el.textContent = orig; el.style.color = ''; }, 150);
}
async initAudio() {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.audioContext.createGain();
this.masterGain.connect(this.audioContext.destination);
this.masterGain.gain.setValueAtTime(this.volume, this.audioContext.currentTime);
this.oscillator1 = this.audioContext.createOscillator();
this.gainNode1 = this.audioContext.createGain();
this.oscillator1.connect(this.gainNode1);
this.gainNode1.connect(this.masterGain);
this.oscillator2 = this.audioContext.createOscillator();
this.gainNode2 = this.audioContext.createGain();
this.oscillator2.connect(this.gainNode2);
this.gainNode2.connect(this.masterGain);
this.oscillator1.type = 'sine';
this.oscillator2.type = 'triangle';
this.oscillator1.frequency.setValueAtTime(this.baseFreq1, this.audioContext.currentTime);
this.oscillator2.frequency.setValueAtTime(this.baseFreq2, this.audioContext.currentTime);
this.gainNode1.gain.setValueAtTime(0, this.audioContext.currentTime);
this.gainNode2.gain.setValueAtTime(0, this.audioContext.currentTime);
this.oscillator1.start();
this.oscillator2.start();
this.audioEnabled = true;
return true;
} catch (e) {
return false;
}
}
updateAudio() {
if (!this.audioEnabled || !this.audioContext) return;
const now = this.audioContext.currentTime;
this.currentFreq1 = Math.max(100, Math.min(2000, this.baseFreq1 * (1 + Math.abs(this.omega1) * this.sensitivity * 0.1)));
this.currentFreq2 = Math.max(100, Math.min(2000, this.baseFreq2 * (1 + Math.abs(this.omega2) * this.sensitivity * 0.1)));
this.oscillator1.frequency.setTargetAtTime(this.currentFreq1, now, 0.01);
this.oscillator2.frequency.setTargetAtTime(this.currentFreq2, now, 0.01);
this.gainNode1.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega1) * 0.1) * this.volume, now, 0.01);
this.gainNode2.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega2) * 0.1) * this.volume, now, 0.01);
}
stopAudio() {
if (this.audioContext && this.audioEnabled) {
try { this.oscillator1.stop(); this.oscillator2.stop(); this.audioContext.close(); } catch (e) {}
}
this.audioEnabled = false;
this.audioContext = null;
}
setupEventListeners() {
const controls = ['l1','l2','m1','m2','g','damping','w1','w2','dt','volume','freq1','freq2','sensitivity','percVolume','threshold','cooldown'];
controls.forEach(id => {
document.getElementById(id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (id === 'w1') this.omega1 = v;
else if (id === 'w2') this.omega2 = v;
else if (id === 'dt') this.dt = v;
else if (id === 'volume') this.volume = v;
else if (id === 'freq1') this.baseFreq1 = v;
else if (id === 'freq2') this.baseFreq2 = v;
else if (id === 'sensitivity') this.sensitivity = v;
else if (id === 'percVolume') {
this.percussionVolume = v;
if (v > 0 && !this.percussionEnabled) {
if (!this.audioContext) this.initAudio().then(ok => { if (ok) { this.createPercussionSounds(); this.percussionEnabled = true; } });
else { this.createPercussionSounds(); this.percussionEnabled = true; }
} else if (v === 0) {
this.percussionEnabled = false;
}
}
else if (id === 'threshold') this.peakThreshold = v;
else if (id === 'cooldown') this.triggerCooldown = v / 1000;
else this[id] = v;
this.updateControls();
if (id === 'l1' || id === 'l2') this.updateScale();
if (this.audioEnabled && this.masterGain && (id === 'volume' || id === 'freq1' || id === 'freq2' || id === 'sensitivity')) {
this.masterGain.gain.setTargetAtTime(this.volume, this.audioContext.currentTime, 0.1);
}
if (!this.running) { this.initialEnergy = this.computeEnergy(); this.maxEnergyDrift = 0; this.energyHistory = []; }
if (!this.running) this.draw();
});
});
document.getElementById('startBtn').addEventListener('click', () => this.start());
document.getElementById('stopBtn').addEventListener('click', () => this.stop());
document.getElementById('resetBtn').addEventListener('click', () => this.reset());
document.getElementById('clearBtn').addEventListener('click', () => this.clearTraces());
document.getElementById('audioBtn').addEventListener('click', async () => {
const btn = document.getElementById('audioBtn');
if (!this.audioEnabled) {
if (await this.initAudio()) {
btn.textContent = 'Disable Audio';
btn.style.background = '#ff9500';
btn.style.color = '#000';
}
} else {
this.stopAudio();
btn.textContent = '\u{1F50A} Enable Audio';
btn.style.background = '';
btn.style.color = '#ff9500';
}
});
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
this.canvas.addEventListener('mouseup', () => this.onMouseUp());
this.canvas.addEventListener('mouseleave', () => this.onMouseUp());
window.addEventListener('resize', () => this.resizeCanvas());
}
updateControls() {
const set = (id, v, d=2) => { const el = document.getElementById(id); if (el) el.textContent = v.toFixed(d); };
set('l1Display', this.l1); set('l2Display', this.l2);
set('m1Display', this.m1); set('m2Display', this.m2);
set('gDisplay', this.g); set('dampingDisplay', this.damping);
set('w1Display', this.omega1); set('w2Display', this.omega2);
set('dtDisplay', this.dt, 3);
set('volumeDisplay', this.volume);
const f1el = document.getElementById('freq1Display'); if (f1el) f1el.textContent = this.baseFreq1.toFixed(0);
const f2el = document.getElementById('freq2Display'); if (f2el) f2el.textContent = this.baseFreq2.toFixed(0);
set('sensitivityDisplay', this.sensitivity, 1);
set('percVolumeDisplay', this.percussionVolume);
set('thresholdDisplay', this.peakThreshold, 1);
const cdel = document.getElementById('cooldownDisplay'); if (cdel) cdel.textContent = (this.triggerCooldown * 1000).toFixed(0);
}
resizeCanvas() {
const w = this.canvas.clientWidth;
if (w < 10) return;
this.canvas.width = w;
this.canvas.height = Math.round(w * (540 / 680));
this.updateCanvasMetrics();
}
getMousePos(e) {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
onMouseDown(e) {
if (this.running) return;
const m = this.getMousePos(e);
const p1 = this.getPendulumPosition(1);
const p2 = this.getPendulumPosition(2);
if (Math.hypot(m.x - p1.x, m.y - p1.y) < 20) { this.dragging = true; this.dragTarget = 1; }
else if (Math.hypot(m.x - p2.x, m.y - p2.y) < 20) { this.dragging = true; this.dragTarget = 2; }
}
onMouseMove(e) {
if (!this.dragging || this.running) return;
const m = this.getMousePos(e);
if (this.dragTarget === 1) {
this.theta1 = Math.atan2(m.x - this.originX, m.y - this.originY);
} else {
const p1 = this.getPendulumPosition(1);
this.theta2 = Math.atan2(m.x - p1.x, m.y - p1.y) - this.theta1;
}
this.initialEnergy = this.computeEnergy();
this.maxEnergyDrift = 0;
this.energyHistory = [];
this.draw();
}
onMouseUp() { this.dragging = false; this.dragTarget = null; }
updateCanvasMetrics() {
this.width = this.canvas.width; this.height = this.canvas.height;
this.originX = this.width / 2; this.originY = this.height / 2;
this.updateScale();
if (!this.running) this.draw();
}
updateScale() {
const half = 0.5 * Math.min(this.width, this.height) * 0.9;
const len = this.l1 + this.l2;
this.scale = len > 0 ? half / len : 100;
}
getPendulumPosition(n) {
if (n === 1) return {
x: this.originX + this.l1 * this.scale * Math.sin(this.theta1),
y: this.originY + this.l1 * this.scale * Math.cos(this.theta1)
};
const p1 = this.getPendulumPosition(1);
return {
x: p1.x + this.l2 * this.scale * Math.sin(this.theta1 + this.theta2),
y: p1.y + this.l2 * this.scale * Math.cos(this.theta1 + this.theta2)
};
}
accelerations() {
const { m1, m2, l1, l2, g } = this;
const th1 = this.theta1, th2 = this.theta2, w1 = this.omega1, w2 = this.omega2;
const s12 = Math.sin(th1 - th2), c12 = Math.cos(th1 - th2);
const den = 2*m1 + m2 - m2 * Math.cos(2*th1 - 2*th2);
if (Math.abs(den) < 1e-8) {
this.singularityCount++;
return { a1: -0.1*w1 - 0.5*th1, a2: -0.1*w2 - 0.5*th2 };
}
let a1 = (-g*(2*m1+m2)*Math.sin(th1) - m2*g*Math.sin(th1-2*th2) - 2*s12*m2*(w2*w2*l2 + w1*w1*l1*c12)) / (l1*den);
let a2 = (2*s12*(w1*w1*l1*(m1+m2) + g*(m1+m2)*Math.cos(th1) + w2*w2*l2*m2*c12)) / (l2*den);
if (this.damping > 0) {
const vx1 = l1*w1*Math.cos(th1), vy1 = -l1*w1*Math.sin(th1);
const vx2 = vx1 + l2*(w1+w2)*Math.cos(th1+th2), vy2 = vy1 - l2*(w1+w2)*Math.sin(th1+th2);
const dr = this.damping * 0.05;
const v1sq = vx1*vx1+vy1*vy1, v2sq = vx2*vx2+vy2*vy2;
if (v1sq > 1e-6) a1 += -dr * v1sq * Math.sign(w1) / l1;
if (v2sq > 1e-6) a2 += -dr * v2sq * Math.sign(w2) / l2;
const jf = this.damping * 0.3;
a1 -= jf*(w1 + 0.5*th1); a2 -= jf*(w2 + 0.5*th2);
if (this.damping > 1.0) { const hd = (this.damping-1.0)*2.0; a1 -= hd*w1; a2 -= hd*w2; }
}
return { a1, a2 };
}
rk4Step(dt) {
const deriv = (th1, th2, w1, w2) => {
const [st1, st2, sw1, sw2] = [this.theta1, this.theta2, this.omega1, this.omega2];
this.theta1=th1; this.theta2=th2; this.omega1=w1; this.omega2=w2;
const {a1,a2} = this.accelerations();
this.theta1=st1; this.theta2=st2; this.omega1=sw1; this.omega2=sw2;
return {dth1:w1, dth2:w2, dw1:a1, dw2:a2};
};
const s = {th1:this.theta1, th2:this.theta2, w1:this.omega1, w2:this.omega2};
const k1=deriv(s.th1,s.th2,s.w1,s.w2);
const k2=deriv(s.th1+.5*dt*k1.dth1, s.th2+.5*dt*k1.dth2, s.w1+.5*dt*k1.dw1, s.w2+.5*dt*k1.dw2);
const k3=deriv(s.th1+.5*dt*k2.dth1, s.th2+.5*dt*k2.dth2, s.w1+.5*dt*k2.dw1, s.w2+.5*dt*k2.dw2);
const k4=deriv(s.th1+dt*k3.dth1, s.th2+dt*k3.dth2, s.w1+dt*k3.dw1, s.w2+dt*k3.dw2);
this.theta1 += (dt/6)*(k1.dth1+2*k2.dth1+2*k3.dth1+k4.dth1);
this.theta2 += (dt/6)*(k1.dth2+2*k2.dth2+2*k3.dth2+k4.dth2);
this.omega1 += (dt/6)*(k1.dw1+2*k2.dw1+2*k3.dw1+k4.dw1);
this.omega2 += (dt/6)*(k1.dw2+2*k2.dw2+2*k3.dw2+k4.dw2);
}
update() {
if (!this.running) return;
this.rk4Step(this.dt);
this.updateAudio();
this.detectPeaks();
const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
this.trace1.push({x:p1.x, y:p1.y}); this.trace2.push({x:p2.x, y:p2.y});
if (this.trace1.length > this.maxTraceLength) this.trace1.shift();
if (this.trace2.length > this.maxTraceLength) this.trace2.shift();
this.currentEnergy = this.computeEnergy();
this.energyHistory.push(this.currentEnergy);
if (this.energyHistory.length > 1000) this.energyHistory.shift();
this.maxEnergyDrift = Math.max(this.maxEnergyDrift, Math.abs(this.currentEnergy - this.initialEnergy));
this.time += this.dt;
this.updateStatus();
}
computeEnergy() {
const vx1 = this.l1*this.omega1*Math.cos(this.theta1);
const vy1 = -this.l1*this.omega1*Math.sin(this.theta1);
const vx2 = vx1 + this.l2*(this.omega1+this.omega2)*Math.cos(this.theta1+this.theta2);
const vy2 = vy1 - this.l2*(this.omega1+this.omega2)*Math.sin(this.theta1+this.theta2);
const T = 0.5*this.m1*(vx1*vx1+vy1*vy1) + 0.5*this.m2*(vx2*vx2+vy2*vy2);
const V = this.m1*this.g*this.l1*(1-Math.cos(this.theta1)) +
this.m2*this.g*(this.l1*(1-Math.cos(this.theta1)) + this.l2*(1-Math.cos(this.theta1+this.theta2)));
return T + V;
}
setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
setVisible(id, v) { const el = document.getElementById(id); if (el) el.classList.toggle('visible', v); }
updateStatus() {
const state = this.running ? 'Running' : 'Stopped';
this.setText('status', 'Status: ' + state + ' | Energy: ' + this.currentEnergy.toFixed(3) + ' J | Time: ' + this.time.toFixed(2) + ' s');
const f1 = this.audioEnabled ? this.currentFreq1.toFixed(0) : '--';
const f2 = this.audioEnabled ? this.currentFreq2.toFixed(0) : '--';
this.setText('audioStatus',
'Tonal: ' + (this.audioEnabled ? 'Active' : 'Disabled') +
' | Percussion: ' + (this.percussionEnabled ? 'Active' : 'Disabled') +
' | \u266a: ' + f1 + '/' + f2 + ' Hz | \u{1F941}: ' + (this.kickCount + this.snareCount) + ' hits');
const driftPct = this.initialEnergy !== 0 ? (this.maxEnergyDrift / Math.abs(this.initialEnergy)) * 100 : 0;
let energyStatus = 'CORRECT';
if (driftPct > 1.0) energyStatus = 'HIGH DRIFT';
else if (driftPct > 0.1) energyStatus = 'MINOR DRIFT';
this.setText('energyLine1', 'Initial: ' + this.initialEnergy.toFixed(4) + ' J | Current: ' + this.currentEnergy.toFixed(4) + ' J');
this.setText('energyLine2', 'Energy drift: ' + driftPct.toFixed(3) + '% | Status: ' + energyStatus);
this.setVisible('energySingularity', this.singularityCount > 0);
if (this.singularityCount > 0) this.setText('energySingularity', 'Singularities: ' + this.singularityCount);
this.setText('geoTheta', '\u03b8\u2081=' + this.theta1.toFixed(3) + ' rad, \u03b8\u2082=' + this.theta2.toFixed(3) + ' rad');
this.setText('geoOmega', '\u03c9\u2081=' + this.omega1.toFixed(3) + ' rad/s, \u03c9\u2082=' + this.omega2.toFixed(3) + ' rad/s');
const vel = Math.sqrt(this.omega1*this.omega1 + this.omega2*this.omega2);
const nearEq = Math.abs(this.theta1) < 0.1 && Math.abs(this.theta2) < 0.1 && vel < 0.1;
this.setVisible('geoStateNearEq', nearEq);
this.setVisible('geoStateMoving', !nearEq);
this.setVisible('geoStateRest', this.damping > 1.0 && vel < 0.01);
this.setVisible('geoStateTonal', this.audioEnabled && vel > 0.1);
this.setVisible('geoStatePerc', this.percussionEnabled && vel > 0.1);
}
draw() {
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawGridAndAxes();
this.drawTraces();
this.drawPendulum();
this.drawInfo();
}
drawGridAndAxes() {
this.ctx.strokeStyle = '#121212'; this.ctx.lineWidth = 1;
for (let x = 0; x <= this.width; x += 40) { this.ctx.beginPath(); this.ctx.moveTo(x,0); this.ctx.lineTo(x,this.height); this.ctx.stroke(); }
for (let y = 0; y <= this.height; y += 40) { this.ctx.beginPath(); this.ctx.moveTo(0,y); this.ctx.lineTo(this.width,y); this.ctx.stroke(); }
this.ctx.strokeStyle = '#2b6fff'; this.ctx.lineWidth = 2;
this.ctx.beginPath(); this.ctx.moveTo(this.originX,0); this.ctx.lineTo(this.originX,this.height); this.ctx.stroke();
this.ctx.beginPath(); this.ctx.moveTo(0,this.originY); this.ctx.lineTo(this.width,this.originY); this.ctx.stroke();
this.ctx.fillStyle = '#4a9eff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,5,0,Math.PI*2); this.ctx.fill();
this.ctx.fillStyle = '#fff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,2,0,Math.PI*2); this.ctx.fill();
}
drawTraces() {
const drawTrace = (trace, color, lw, alpha) => {
if (trace.length < 2) return;
this.ctx.strokeStyle = color; this.ctx.lineWidth = lw; this.ctx.globalAlpha = alpha;
this.ctx.beginPath(); this.ctx.moveTo(trace[0].x, trace[0].y);
for (let i = 1; i < trace.length; i++) this.ctx.lineTo(trace[i].x, trace[i].y);
this.ctx.stroke();
};
drawTrace(this.trace1, '#ff6b4a', 1.5, 0.7);
drawTrace(this.trace2, '#4aff6b', 2.5, 0.9);
this.ctx.globalAlpha = 1.0;
}
drawPendulum() {
const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 4;
this.ctx.beginPath(); this.ctx.moveTo(this.originX,this.originY); this.ctx.lineTo(p1.x,p1.y); this.ctx.stroke();
this.ctx.beginPath(); this.ctx.moveTo(p1.x,p1.y); this.ctx.lineTo(p2.x,p2.y); this.ctx.stroke();
const r1 = Math.max(6, 4+this.m1*4), r2 = Math.max(6, 4+this.m2*4);
const drawBob = (px, py, r, color, omega) => {
if (this.audioEnabled && this.running) {
const intensity = Math.min(1, Math.abs(omega) * 0.2);
if (intensity > 0.1) { this.ctx.shadowBlur = 15*intensity; this.ctx.shadowColor = color; }
}
this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.fill();
this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.stroke();
this.ctx.shadowBlur = 0;
};
drawBob(p1.x, p1.y, r1, '#ff6b4a', this.omega1);
drawBob(p2.x, p2.y, r2, '#4aff6b', this.omega2);
}
drawInfo() {
this.ctx.fillStyle = '#4a9eff'; this.ctx.font = '13px Consolas';
this.ctx.fillText('\u03b8\u2081: ' + this.theta1.toFixed(3) + ' rad', 10, 20);
this.ctx.fillText('\u03b8\u2082: ' + this.theta2.toFixed(3) + ' rad', 10, 36);
this.ctx.fillText('\u03c9\u2081: ' + this.omega1.toFixed(3) + ' rad/s', 10, 52);
this.ctx.fillText('\u03c9\u2082: ' + this.omega2.toFixed(3) + ' rad/s', 10, 68);
let y = 84;
if (this.audioEnabled && this.running) {
this.ctx.fillStyle = '#ff9500';
this.ctx.fillText('\u266a1: ' + this.currentFreq1.toFixed(0) + ' Hz', 10, y);
this.ctx.fillText('\u266a2: ' + this.currentFreq2.toFixed(0) + ' Hz', 10, y+16);
y += 32;
}
if (this.percussionEnabled && this.running) {
this.ctx.fillStyle = '#ff6b4a';
this.ctx.fillText('\u{1F941}: ' + this.kickCount + ' hits', 10, y);
this.ctx.fillStyle = '#4aff6b';
this.ctx.fillText('\u{1F514}: ' + this.snareCount + ' hits', 10, y+16);
y += 32;
}
if (this.damping > 0) {
this.ctx.fillStyle = this.damping > 1.0 ? '#ff6b4a' : '#ffaa4a';
this.ctx.fillText('Friction: ' + this.damping.toFixed(2), 10, y);
}
this.ctx.fillStyle = '#888'; this.ctx.font = '11px Consolas';
this.ctx.fillText('Scale: ' + this.scale.toFixed(1) + ' px/m', 10, this.height-10);
}
start() {
if (!this.running) {
this.running = true;
this.initialEnergy = this.computeEnergy();
this.maxEnergyDrift = 0;
this.animate();
}
}
stop() {
this.running = false;
if (this.audioEnabled) {
this.gainNode1.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
this.gainNode2.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
}
}
reset() {
this.stop();
this.theta1 = Math.PI; this.theta2 = 0.0;
this.omega1 = 0; this.omega2 = 0;
this.time = 0;
this.clearTraces();
this.initialEnergy = this.computeEnergy();
this.currentEnergy = this.initialEnergy;
this.maxEnergyDrift = 0; this.energyHistory = []; this.singularityCount = 0;
this.kickCount = 0; this.snareCount = 0;
this.lastTrigger1 = 0; this.lastTrigger2 = 0;
this.lastOmega1 = 0; this.lastOmega2 = 0;
this.omega1Trend = 0; this.omega2Trend = 0;
this.resizeCanvas();
}
clearTraces() { this.trace1 = []; this.trace2 = []; if (!this.running) this.draw(); }
animate() {
if (!this.running) return;
this.update(); this.draw();
requestAnimationFrame(() => this.animate());
}
}
const pendulum = new MusicalDoublePendulum('pendulumCanvas', 'simPanel');
</script>
</body>
</html>

View file

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="10" y="10" width="380" height="200" rx="6" fill="#0a0a0a" stroke="#1a1a1a" stroke-width="1"/>
<line x1="200" y1="30" x2="200" y2="220" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
<line x1="10" y1="110" x2="390" y2="110" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
<circle cx="200" cy="50" r="5" fill="#4a9eff"/>
<circle cx="200" cy="50" r="2" fill="#fff"/>
<line x1="200" y1="50" x2="148" y2="128" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<line x1="148" y1="128" x2="220" y2="185" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<circle cx="148" cy="128" r="10" fill="#ff6b4a"/>
<circle cx="148" cy="128" r="10" fill="none" stroke="#fff" stroke-width="1.5"/>
<circle cx="220" cy="185" r="12" fill="#4aff6b"/>
<circle cx="220" cy="185" r="12" fill="none" stroke="#fff" stroke-width="1.5"/>
<path d="M 200 50 Q 175 90 148 128 Q 130 155 120 170 Q 110 185 130 192 Q 155 200 175 185 Q 200 168 220 152 Q 245 135 255 118 Q 265 100 248 88 Q 228 76 210 88 Q 192 100 178 115" stroke="#ff6b4a" stroke-width="1.2" fill="none" opacity="0.6"/>
<path d="M 148 128 Q 170 148 195 162 Q 218 175 232 180 Q 248 184 255 175 Q 265 163 258 148 Q 250 132 235 125 Q 218 117 205 122 Q 190 128 182 138 Q 172 150 168 162" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.75"/>
<path d="M 270 85 Q 278 78 278 85 Q 278 92 270 92" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.9"/>
<path d="M 278 80 Q 290 72 290 85 Q 290 98 278 90" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.7"/>
<path d="M 290 75 Q 305 65 305 85 Q 305 105 290 95" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.5"/>
<path d="M 285 128 Q 293 121 293 128 Q 293 135 285 135" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.9"/>
<path d="M 293 123 Q 305 115 305 128 Q 305 141 293 133" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.7"/>
<text x="200" y="213" font-family="'Courier New', monospace" font-size="13" fill="#ff9500" text-anchor="middle" font-weight="bold" letter-spacing="3">AUDIOPENDULUM</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cocoland</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 8px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">COCOLAND</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>BEST: <b id="best">0</b></span>
</div>
<canvas id="c" width="600" height="250"></canvas>
<div id="msg">Press SPACE or TAP to start</div>
<div id="controls">SPACE / TAP — Jump &nbsp;|&nbsp; Double jump allowed</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="cocoland">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const msgEl = document.getElementById('msg');
const W = canvas.width, H = canvas.height;
const GROUND = H - 40;
const GRAVITY = 0.6;
const JUMP = -13;
let state = 'idle';
let score = 0, best = 0, frames = 0;
let coco, obstacles, coins, speed;
function reset() {
coco = { x: 80, y: GROUND - 44, vy: 0, r: 22, jumps: 0 };
obstacles = [];
coins = [];
speed = 2;
score = 0;
frames = 0;
scoreEl.textContent = '0';
}
function jump() {
if (state === 'idle' || state === 'over') {
reset();
state = 'play';
msgEl.textContent = '';
return;
}
if (coco.jumps < 2) {
coco.vy = JUMP;
coco.jumps++;
}
}
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); jump(); } });
canvas.addEventListener('click', jump);
function spawnObstacle() {
const h = 30 + Math.random() * 40;
obstacles.push({ x: W, y: GROUND - h, w: 22, h });
}
function spawnCoin() {
const y = GROUND - 60 - Math.random() * 80;
coins.push({ x: W, y, r: 9, collected: false });
}
function drawCoco() {
const x = coco.x, y = coco.y;
ctx.fillStyle = '#8B4513';
ctx.beginPath();
ctx.arc(x, y + coco.r, coco.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(x - 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x + 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(x - 7, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x + 9, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#5a2000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y + coco.r + 7, 8, 0.1 * Math.PI, 0.9 * Math.PI);
ctx.stroke();
ctx.fillStyle = '#3a8000';
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x - 14, y - 14, x - 8, y - 24);
ctx.quadraticCurveTo(x, y - 10, x, y);
ctx.fill();
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x + 14, y - 14, x + 8, y - 24);
ctx.quadraticCurveTo(x, y - 10, x, y);
ctx.fill();
}
function drawPalmTree(px, groundY) {
ctx.strokeStyle = '#4a7a00';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(px, groundY);
ctx.quadraticCurveTo(px + 6, groundY - 60, px - 4, groundY - 110);
ctx.stroke();
ctx.fillStyle = '#3a8000';
for (let a = -0.6; a <= 0.7; a += 0.3) {
ctx.beginPath();
ctx.ellipse(px - 4 + Math.cos(a) * 32, groundY - 110 + Math.sin(a) * 12 - 10, 30, 10, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#8B4513';
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.arc(px - 4 + (i - 1) * 9, groundY - 115, 6, 0, Math.PI * 2);
ctx.fill();
}
}
function drawCoin(c) {
if (c.collected) return;
ctx.fillStyle = '#FFA500';
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('E', c.x, c.y);
}
function collides(ax, ay, aw, ah, bx, by, bw, bh) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
let bgOffset = 0;
let palmTimer = 0, coinTimer = 0;
function loop() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1a3a00';
ctx.fillRect(0, GROUND, W, H - GROUND);
if (state === 'play') {
bgOffset = (bgOffset + speed * 0.5) % W;
palmTimer += speed;
coinTimer += speed;
if (palmTimer > 350 + Math.random() * 150) {
spawnObstacle();
palmTimer = 0;
}
if (coinTimer > 150 + Math.random() * 80) {
spawnCoin();
coinTimer = 0;
}
}
for (let i = obstacles.length - 1; i >= 0; i--) {
const o = obstacles[i];
if (state === 'play') o.x -= speed;
if (o.x + o.w < 0) { obstacles.splice(i, 1); continue; }
drawPalmTree(o.x + 11, GROUND);
if (state === 'play' && collides(coco.x - coco.r + 4, coco.y, coco.r * 2 - 8, coco.r * 2, o.x, o.y, o.w, o.h)) {
state = 'over';
if (score > best) { best = score; bestEl.textContent = best; }
msgEl.textContent = 'GAME OVER — Press SPACE to retry';
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
}
for (let i = coins.length - 1; i >= 0; i--) {
const c = coins[i];
if (state === 'play') c.x -= speed;
if (c.x + c.r < 0) { coins.splice(i, 1); continue; }
drawCoin(c);
if (!c.collected && state === 'play') {
const dx = coco.x - c.x, dy = (coco.y + coco.r) - c.y;
if (Math.sqrt(dx * dx + dy * dy) < coco.r + c.r) {
c.collected = true;
score += 10;
scoreEl.textContent = score;
}
}
}
if (state === 'play') {
coco.vy += GRAVITY;
coco.y += coco.vy;
if (coco.y >= GROUND - coco.r * 2) {
coco.y = GROUND - coco.r * 2;
coco.vy = 0;
coco.jumps = 0;
}
frames++;
if (frames % 6 === 0) {
score++;
scoreEl.textContent = score;
}
if (frames % 60 === 0 && speed < 10) speed += 0.1;
}
drawCoco();
requestAnimationFrame(loop);
}
reset();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="0" y="180" width="400" height="40" fill="#1a3a00"/>
<line x1="300" y1="180" x2="280" y2="50" stroke="#4a7a00" stroke-width="8" stroke-linecap="round"/>
<ellipse cx="265" cy="55" rx="50" ry="30" fill="#2d6a00"/>
<ellipse cx="285" cy="45" rx="45" ry="25" fill="#3a8000"/>
<ellipse cx="275" cy="35" rx="40" ry="20" fill="#4a9400"/>
<circle cx="268" cy="62" r="8" fill="#8B4513"/>
<circle cx="282" cy="65" r="7" fill="#8B4513"/>
<circle cx="275" cy="58" r="7" fill="#8B4513"/>
<line x1="100" y1="180" x2="90" y2="90" stroke="#4a7a00" stroke-width="6" stroke-linecap="round"/>
<ellipse cx="80" cy="95" rx="35" ry="22" fill="#2d6a00"/>
<ellipse cx="95" cy="87" rx="30" ry="18" fill="#3a8000"/>
<circle cx="88" cy="100" r="6" fill="#8B4513"/>
<circle cx="100" cy="102" r="5" fill="#8B4513"/>
<circle cx="58" cy="150" r="22" fill="#8B4513"/>
<circle cx="48" cy="143" r="7" fill="#fff"/>
<circle cx="68" cy="143" r="7" fill="#fff"/>
<circle cx="50" cy="143" r="4" fill="#222"/>
<circle cx="70" cy="143" r="4" fill="#222"/>
<path d="M48 158 Q58 164 68 158" stroke="#5a2000" stroke-width="2" fill="none" stroke-linecap="round"/>
<polygon points="150,165 158,145 166,165" fill="#FFA500"/>
<polygon points="190,170 198,150 206,170" fill="#FFA500"/>
<polygon points="230,162 238,142 246,162" fill="#FFA500"/>
<text x="200" y="210" font-family="monospace" font-size="13" fill="#FFA500" text-anchor="middle" opacity="0.7">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cocoman</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
#dpad-row { display: flex; gap: 4px; }
#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
#dpad button:active { background: #444; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">COCOMAN</span>
</div>
<div id="ui">
<span>SCORE: <b id="scoreEl">0</b></span>
<span>LIVES: <b id="livesEl">3</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press SPACE or tap arrow to start</div>
<div id="dpad">
<div id="dpad-row"><button id="btn-up">&#8593;</button></div>
<div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="cocoman">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const CELL = 20;
const MAP = [
'############################',
'#............##............#',
'#.####.#####.##.#####.####.#',
'#o# #.# #.##.# #.# #o#',
'#.####.#####.##.#####.####.#',
'#..........................#',
'#.####.##.########.##.####.#',
'#.####.##.########.##.####.#',
'#......##....##....##......#',
'######.##### ## #####.######',
' #.##### ## #####.# ',
' #.## G ##.# ',
' #.## ###=### ##.# ',
'######.## # # ##.######',
' . # GGG # . ',
'######.## # # ##.######',
' #.## ####### ##.# ',
' #.## ##.# ',
' #.## ######## ##.# ',
'######.## ######## ##.######',
'#............##............#',
'#.####.#####.##.#####.####.#',
'#.####.#####.##.#####.####.#',
'#o..##....... .......##..o#',
'###.##.##.########.##.##.###',
'###.##.##.########.##.##.###',
'#......##....##....##......#',
'#.##########.##.##########.#',
'#.##########.##.##########.#',
'#..........................#',
'############################'
];
const COLS = MAP[0].length, ROWS = MAP.length;
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
const GHOST_COLORS = ['#f00', '#f9a', '#0ff', '#fa0'];
let score = 0, lives = 3, gameState = 'idle', comboGhost = 0;
let best = parseInt(localStorage.getItem('cocoman_best') || '-1');
let dots = [], powers = [], player, ghosts, nextDir = [1, 0], frightTimer = 0;
let tickInterval = null;
function initLevel() {
dots = []; powers = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (MAP[r][c] === '.') dots.push([c, r]);
if (MAP[r][c] === 'o') powers.push([c, r]);
}
}
player = { x: 14, y: 23, dir: [1, 0] };
nextDir = [1, 0];
ghosts = [
{ x: 13, y: 14, dir: [0, -1], color: GHOST_COLORS[0] },
{ x: 14, y: 14, dir: [0, 1], color: GHOST_COLORS[1] },
{ x: 13, y: 15, dir: [1, 0], color: GHOST_COLORS[2] },
{ x: 14, y: 15, dir: [-1, 0], color: GHOST_COLORS[3] }
];
frightTimer = 0; comboGhost = 0;
}
function wrapX(x) {
if (x < 0) return COLS - 1;
if (x >= COLS) return 0;
return x;
}
function cellAt(x, y) {
if (y < 0 || y >= ROWS) return '#';
const row = MAP[Math.floor(y)];
const ch = row[Math.floor(x)];
return ch === undefined ? ' ' : ch;
}
function canMovePlayer(x, y, dx, dy) {
const nx = wrapX(x + dx), ny = y + dy;
const ch = cellAt(nx, ny);
return ch !== '#' && ch !== '=';
}
function canGhostMove(x, y, dx, dy) {
const nx = wrapX(x + dx), ny = y + dy;
return cellAt(nx, ny) !== '#';
}
function moveGhost(g) {
const dirs = [[0,-1],[0,1],[-1,0],[1,0]].filter(([dx,dy]) => {
if (!canGhostMove(g.x, g.y, dx, dy)) return false;
if (dx === -g.dir[0] && dy === -g.dir[1]) return false;
return true;
});
if (!dirs.length) return;
if (frightTimer > 0) {
g.dir = dirs[Math.floor(Math.random() * dirs.length)];
} else {
dirs.sort((a, b) =>
Math.hypot(g.x+a[0]-player.x, g.y+a[1]-player.y) -
Math.hypot(g.x+b[0]-player.x, g.y+b[1]-player.y)
);
g.dir = dirs[0];
}
g.x = wrapX(g.x + g.dir[0]);
g.y += g.dir[1];
}
function tick() {
if (gameState !== 'play') return;
if (canMovePlayer(player.x, player.y, ...nextDir)) player.dir = [...nextDir];
if (canMovePlayer(player.x, player.y, ...player.dir)) {
player.x = wrapX(player.x + player.dir[0]);
player.y += player.dir[1];
}
const di = dots.findIndex(d => d[0] === player.x && d[1] === player.y);
if (di >= 0) { dots.splice(di, 1); score += 10; document.getElementById('scoreEl').textContent = score; }
const pi = powers.findIndex(p => p[0] === player.x && p[1] === player.y);
if (pi >= 0) { powers.splice(pi, 1); score += 50; frightTimer = 22; comboGhost = 0; document.getElementById('scoreEl').textContent = score; }
if (frightTimer > 0) frightTimer--;
ghosts.forEach(moveGhost);
for (const g of ghosts) {
if (g.x === player.x && g.y === player.y) {
if (frightTimer > 0) {
comboGhost++;
score += 200 * Math.pow(2, comboGhost - 1);
document.getElementById('scoreEl').textContent = score;
g.x = 13; g.y = 14; g.dir = [0, -1];
} else {
loseLife();
return;
}
}
}
if (!dots.length && !powers.length) winGame();
}
function loseLife() {
lives--;
document.getElementById('livesEl').textContent = lives;
if (lives <= 0) {
endGame();
} else {
player = { x: 14, y: 23, dir: [1, 0] };
nextDir = [1, 0]; frightTimer = 0;
document.getElementById('msg').textContent = 'Caught! Keep going...';
}
}
function winGame() {
clearInterval(tickInterval); tickInterval = null;
gameState = 'over';
score += lives * 100;
document.getElementById('scoreEl').textContent = score;
document.getElementById('msg').textContent = `You won! Score: ${score}. SPACE = new game`;
saveAndShow();
}
function endGame() {
clearInterval(tickInterval); tickInterval = null;
gameState = 'over';
document.getElementById('msg').textContent = `GAME OVER! Score: ${score}. SPACE = new game`;
saveAndShow();
}
function saveAndShow() {
if (best < 0 || score > best) {
best = score;
localStorage.setItem('cocoman_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function startGame() {
score = 0; lives = 3; gameState = 'play';
document.getElementById('scoreEl').textContent = '0';
document.getElementById('livesEl').textContent = '3';
document.getElementById('msg').textContent = '';
document.getElementById('scoreSubmit').style.display = 'none';
initLevel();
if (tickInterval) clearInterval(tickInterval);
tickInterval = setInterval(tick, 155);
}
document.addEventListener('keydown', e => {
const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
if (e.code === 'Space') { e.preventDefault(); startGame(); return; }
if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') startGame(); nextDir = map[e.code]; }
});
function dpadInput(dx, dy) { if (gameState === 'idle') startGame(); nextDir = [dx, dy]; }
document.getElementById('btn-up').addEventListener('click', () => dpadInput(0, -1));
document.getElementById('btn-down').addEventListener('click', () => dpadInput(0, 1));
document.getElementById('btn-left').addEventListener('click', () => dpadInput(-1, 0));
document.getElementById('btn-right').addEventListener('click', () => dpadInput(1, 0));
if (best >= 0) document.getElementById('bestEl').textContent = best;
initLevel();
let animFrame = 0;
function draw() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (MAP[r][c] === '#') {
ctx.fillStyle = '#00c';
ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
ctx.strokeStyle = '#44f';
ctx.strokeRect(c * CELL + 1, r * CELL + 1, CELL - 2, CELL - 2);
}
}
}
ctx.fillStyle = '#fff';
for (const [dc, dr] of dots) {
ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 2.5, 0, Math.PI * 2); ctx.fill();
}
if (Math.floor(animFrame / 15) % 2) {
ctx.fillStyle = '#FFA500';
for (const [dc, dr] of powers) {
ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 5, 0, Math.PI * 2); ctx.fill();
}
}
for (const g of ghosts) {
ctx.fillStyle = frightTimer > 0 ? '#006' : g.color;
const gx = g.x * CELL + CELL/2, gy = g.y * CELL + CELL/2;
ctx.beginPath();
ctx.arc(gx, gy - 2, CELL/2 - 2, Math.PI, 0);
ctx.lineTo(gx + CELL/2 - 2, gy + CELL/2 - 2);
for (let w = 0; w < 4; w++) {
ctx.lineTo(gx + CELL/2 - 2 - w * (CELL - 4) / 4, gy + CELL/2 - 2 - (w % 2 ? 3 : 0));
}
ctx.lineTo(gx - CELL/2 + 2, gy + CELL/2 - 2); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
if (frightTimer <= 0) {
ctx.fillStyle = '#00c';
ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
}
}
const mouth = Math.abs(Math.sin(animFrame * 0.15)) * 0.4;
const pa = player.dir[0] !== 0 ? Math.atan2(player.dir[1], player.dir[0]) : -Math.PI / 2 * Math.sign(player.dir[1] || 1);
ctx.fillStyle = '#ff0';
ctx.beginPath();
ctx.moveTo(player.x * CELL + CELL/2, player.y * CELL + CELL/2);
ctx.arc(player.x * CELL + CELL/2, player.y * CELL + CELL/2, CELL/2 - 1, pa + mouth, pa + Math.PI * 2 - mouth);
ctx.closePath(); ctx.fill();
animFrame++;
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#000"/>
<g fill="#00f" stroke="#00f" stroke-width="1">
<rect x="5" y="5" width="110" height="8"/>
<rect x="5" y="67" width="110" height="8"/>
<rect x="5" y="5" width="8" height="70"/>
<rect x="107" y="5" width="8" height="70"/>
<rect x="30" y="5" width="8" height="35"/>
<rect x="60" y="5" width="8" height="35"/>
<rect x="90" y="5" width="8" height="35"/>
<rect x="30" y="45" width="8" height="30"/>
<rect x="60" y="45" width="8" height="30"/>
<rect x="90" y="45" width="8" height="30"/>
</g>
<circle cx="20" cy="42" r="8" fill="#ff0"/>
<path d="M20,42 L28,38 L28,46 Z" fill="#000"/>
<circle cx="55" cy="25" r="4" fill="#f00" opacity="0.9"/>
<circle cx="80" cy="55" r="4" fill="#f88" opacity="0.9"/>
<circle cx="19" cy="25" r="2" fill="#fff"/>
<circle cx="45" cy="55" r="2" fill="#fff"/>
<circle cx="75" cy="35" r="2" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 998 B

View file

@ -0,0 +1,793 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ECOINFLOW</title>
<style>
:root {
--pub: #FFA500;
--hab: #8BC34A;
--val: #eeeeee;
--shop: #4CAF50;
--acum: #FF5252;
--check: #FFFF55;
--cbdc: #607D8B;
--bg: #000;
--fg: #eee;
--cell: 64px;
--gap: 5px;
--pad: 10px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100vh; overflow: hidden; }
body {
background: var(--bg); color: var(--fg);
font-family: 'Courier New', ui-monospace, monospace;
height: 100vh; display: flex; flex-direction: column;
align-items: center; user-select: none; -webkit-user-select: none;
}
#topbar { width:100%; padding:8px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; }
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
#topbar a:hover { text-decoration:underline; }
#ui { display:flex; gap:16px; padding:8px; font-size:14px; color:#FFA500; flex-wrap:wrap; justify-content:center; }
#ui b { font-size:15px; }
#hudTurn { transition:color 0.3s; }
#boardWrap { position:relative; flex:1; min-height:0; margin:0; display:flex; align-items:center; justify-content:center; width:100%; overflow:hidden; }
#board { display:grid; gap:var(--gap); background:#222; padding:var(--pad); border:1px solid #3a3a3a; position:relative; }
.cell { width:var(--cell); height:var(--cell); background:#2c2c2c; border:1px solid #444; display:flex; align-items:center; justify-content:center; cursor:pointer; position:relative; transition:background 0.15s; }
.cell.empty:hover { background:#3a3a3a; }
.cell.drawable { background:#4a3800; border-color:#9a7200; }
.cell.drawable.empty:hover { background:#5e4700; }
.cell.in-path { background:#524000; }
.cell.cursor { outline:2px solid var(--pub); outline-offset:-2px; z-index:3; }
.node-label { font-size:40px; font-weight:bold; color:#fff; pointer-events:none; line-height:1; text-align:center; background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.45); padding:2px 7px; border-radius:3px; }
.node.validator .node-label,.node.accumulator .node-label { font-size:22px; padding:1px 4px; }
.node { width:82%; height:82%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; font-weight:bold; font-size:11px; color:#000; position:relative; transition:transform 0.2s,opacity 0.2s; }
.node-icon { display:flex; align-items:center; justify-content:center; pointer-events:none; line-height:1; font-size:34px; }
.node.validator .node-icon { font-size:22px; }
.node.inactive { opacity:0.42; }
.node.active { animation:pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%,100%{transform:scale(1);} 50%{transform:scale(1.07);} }
.node.danger { animation:danger 0.42s ease-in-out infinite !important; opacity:1 !important; }
@keyframes danger { 0%,100%{background:var(--hab);box-shadow:0 0 0 2px var(--hab);} 50%{background:#c83030;box-shadow:0 0 0 5px rgba(255,82,82,.9),0 0 16px rgba(255,82,82,.5);} }
.node.acum-danger { animation:acumDanger 0.44s ease-in-out infinite !important; opacity:1 !important; }
@keyframes acumDanger { 0%,100%{filter:drop-shadow(0 0 3px rgba(255,82,82,.45));} 50%{filter:drop-shadow(0 0 12px rgba(255,17,68,1)) drop-shadow(0 0 4px rgba(255,82,82,.9));} }
.node.cbdc-threat { animation:cbdcThreat 0.55s ease-in-out infinite !important; opacity:1 !important; }
@keyframes cbdcThreat { 0%,100%{background:var(--hab);box-shadow:0 0 0 2px var(--hab);} 50%{background:#4a6572;box-shadow:0 0 0 5px rgba(96,125,139,.85),0 0 14px rgba(96,125,139,.5);} }
.node.cbdc-alarm { animation:cbdcAlarm 0.7s ease-in-out infinite; }
@keyframes cbdcAlarm { 0%,100%{box-shadow:0 0 0 2px var(--cbdc);} 50%{box-shadow:0 0 0 6px rgba(96,125,139,.8),0 0 14px rgba(96,125,139,.5);} }
.node.pub { background:var(--pub); border-radius:3px; box-shadow:0 0 0 2px var(--pub),0 0 10px rgba(255,165,0,.18); }
.node.pub::before { content:''; position:absolute; width:2px; height:10px; background:var(--pub); top:-10px; left:50%; transform:translateX(-50%); }
.node.pub::after { content:''; position:absolute; width:6px; height:6px; background:var(--pub); border-radius:50%; top:-16px; left:50%; transform:translateX(-50%); }
.node.pub.reduced { outline:2px dashed #c47800; outline-offset:2px; }
.node.inhabitant { background:var(--hab); border-radius:50%; box-shadow:0 0 0 2px var(--hab),0 0 10px rgba(139,195,74,.18); }
.node.inhabitant.cbdc-controlled { background:#455a64; border-radius:50%; box-shadow:0 0 0 2px #455a64; }
.node.checkpoint { background:transparent; border-radius:3px; border:3px solid var(--check); box-shadow:0 0 6px rgba(255,215,0,.35); opacity:1 !important; }
.node.checkpoint.active { background:transparent !important; border:3px solid var(--check) !important; box-shadow:0 0 0 2px var(--check),0 0 18px rgba(255,229,102,.9) !important; filter:none !important; animation:checkActive 1.8s ease-in-out infinite !important; }
.node.checkpoint .node-label { color:var(--check); text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 8px rgba(0,0,0,.9); }
@keyframes checkActive { 0%,100%{transform:scale(1.1);box-shadow:0 0 0 2px var(--check),0 0 12px rgba(255,229,102,.65);} 50%{transform:scale(1.22);box-shadow:0 0 0 5px var(--check),0 0 26px rgba(255,229,102,1);} }
.node.validator { background:rgba(200,218,230,.55); clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%); width:68%; height:68%; overflow:hidden; filter:drop-shadow(0 0 5px rgba(200,218,230,.7)); }
.node.validator.active { background:var(--val) !important; filter:drop-shadow(0 0 8px rgba(238,238,238,.9)) !important; }
.node.shop { background:var(--shop); clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%); filter:drop-shadow(0 0 4px rgba(76,175,80,.45)); }
.node.accumulator { background:rgba(255,82,82,.18); clip-path:polygon(50% 0,100% 100%,0 100%); overflow:hidden; filter:drop-shadow(0 0 4px rgba(255,82,82,.42)); }
.node.cbdc { background:var(--cbdc); border-radius:3px; color:#cfd8dc; font-size:14px; box-shadow:0 0 0 2px var(--cbdc),0 0 10px rgba(96,125,139,.25); }
#pipes { position:absolute; pointer-events:none; }
#pipes path { stroke:var(--pub); stroke-width:5; stroke-linecap:round; stroke-linejoin:round; opacity:.4; fill:none; }
#pipes path.flowing { opacity:1; stroke-width:6; filter:drop-shadow(0 0 4px rgba(255,165,0,.85)) drop-shadow(0 0 10px rgba(255,165,0,.4)); stroke-dasharray:14 8; animation:pipeDash 1.1s linear infinite; }
#pipes path.clickable { pointer-events:auto; cursor:pointer; }
#pipes path.clickable:hover { stroke:#fff; filter:none; stroke-dasharray:none; animation:none; }
#pipes path.cbdc-line { stroke:var(--cbdc); stroke-width:3; stroke-dasharray:5 4; opacity:.75; pointer-events:none; }
#particleLayer { position:absolute; pointer-events:none; }
.flow-particle { position:absolute; width:7px; height:7px; background:var(--pub); border-radius:50%; box-shadow:0 0 7px var(--pub),0 0 14px rgba(255,165,0,.6),0 0 22px rgba(255,165,0,.3); offset-distance:0%; animation:flowParticle 900ms linear infinite; will-change:offset-distance,opacity; }
@keyframes flowParticle { 0%{offset-distance:0%;opacity:0;} 8%{opacity:1;} 85%{opacity:1;} 100%{offset-distance:100%;opacity:0;} }
@keyframes pipeDash { to { stroke-dashoffset:-22; } }
.pot-float { position:absolute; color:var(--acum); font-size:15px; font-weight:bold; font-family:'Courier New',monospace; letter-spacing:1px; pointer-events:none; text-shadow:0 0 8px rgba(255,82,82,.7); animation:potFloat 1.4s ease-out forwards; z-index:20; white-space:nowrap; }
@keyframes potFloat { 0%{transform:translateY(0) scale(1);opacity:1;} 20%{transform:translateY(-6px) scale(1.15);} 100%{transform:translateY(-48px) scale(.9);opacity:0;} }
.check-pulse { position:absolute; border-radius:50%; border:2px solid var(--check); box-shadow:0 0 10px var(--check); pointer-events:none; animation:checkPulse .8s ease-out forwards; z-index:15; }
@keyframes checkPulse { 0%{transform:translate(-50%,-50%) scale(.3);opacity:1;} 100%{transform:translate(-50%,-50%) scale(2.5);opacity:0;} }
#message { margin:8px 0; min-height:18px; font-size:12px; color:#888; text-align:center; max-width:500px; padding:0 16px; line-height:1.5; }
#message.warning { color:var(--pub); background:rgba(255,165,0,.07); border:1px solid rgba(255,165,0,.2); padding:6px 16px; border-radius:2px; }
#message.alert { color:var(--acum); background:rgba(255,82,82,.07); border:1px solid rgba(255,82,82,.2); padding:6px 16px; border-radius:2px; }
#legend { display:flex; flex-wrap:wrap; justify-content:center; gap:10px 16px; margin:4px 0; padding:8px 16px; max-width:560px; font-size:11px; color:#888; }
#legend .item { display:flex; align-items:center; gap:6px; }
#legend .mini { width:16px; height:16px; display:inline-block; flex-shrink:0; }
#legend .mini.pub { background:var(--pub); border-radius:2px; }
#legend .mini.inhabitant { background:var(--hab); border-radius:50%; }
#legend .mini.validator { background:var(--val); clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%); width:14px; height:14px; margin:1px; }
#legend .mini.shop { background:var(--shop); clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%); }
#legend .mini.accumulator { background:var(--acum); clip-path:polygon(50% 0,100% 100%,0 100%); }
#legend .mini.checkpoint { background:transparent; border:2px solid var(--check); border-radius:2px; }
#legend .mini.cbdc { background:var(--cbdc); border-radius:2px; }
#controls { color:#888; font-size:13px; text-align:center; margin-bottom:8px; display:flex; gap:10px; flex-wrap:wrap; justify-content:center; align-items:center; padding:0 16px; }
#btnAdvanceTurn { background:#0a0a0a; color:var(--fg); border:1px solid var(--pub); padding:8px 18px; font-family:inherit; font-size:12px; cursor:pointer; text-transform:uppercase; letter-spacing:1px; }
#btnAdvanceTurn:hover { background:var(--pub); color:#000; }
button { background:#0a0a0a; color:var(--fg); border:1px solid var(--pub); padding:10px 16px; font-family:inherit; font-size:12px; cursor:pointer; text-transform:uppercase; letter-spacing:1px; }
button:hover { background:var(--pub); color:#000; }
button.primary { background:var(--pub); color:#000; font-weight:bold; }
button.primary:hover { background:#ffb733; }
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.97); display:flex; align-items:center; justify-content:center; z-index:100; padding:20px; }
.overlay.hidden { display:none; }
.overlay .panel { max-width:480px; border:1px solid var(--pub); padding:24px 28px; background:#050505; }
.overlay h2 { color:var(--pub); margin-bottom:14px; font-size:16px; letter-spacing:1px; }
.overlay p { margin:10px 0; line-height:1.55; font-size:13px; color:#bbb; }
.overlay .hint { color:#bbb; font-size:12px; border-left:2px solid #444; padding-left:10px; margin-top:16px; }
.info-panel { max-width:560px !important; max-height:85vh; overflow-y:auto; }
.info-panel h3 { color:var(--pub); font-size:13px; letter-spacing:1px; margin-top:18px; margin-bottom:6px; text-transform:uppercase; }
.info-panel h3:first-of-type { margin-top:4px; }
.info-panel ul { list-style:none; padding-left:0; margin:8px 0; }
.info-panel ul li { font-size:13px; color:#bbb; padding:4px 0 4px 14px; position:relative; line-height:1.5; }
.info-panel ul li::before { content:'>'; color:var(--pub); position:absolute; left:0; }
.info-panel .stat-line { font-family:inherit; color:#888; font-size:12px; margin:6px 0 12px; }
.info-panel .stat-line b { color:var(--pub); }
.overlay .buttons { margin-top:20px; display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap; }
#scoreSubmit { margin-top:14px; text-align:center; }
#scoreSubmit button { border-color:var(--check); color:var(--check); background:#1a3a1a; padding:6px 16px; font-size:14px; }
#scoreSubmit button:hover { background:var(--check); color:#000; }
#winBreakdown { font-size:11px; color:#555; line-height:1.9; margin:4px 0 10px; border-left:2px solid #222; padding-left:10px; }
#winBreakdown .positive { color:#6a9a3a; }
#winBreakdown .negative { color:var(--acum); }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ECOINFLOW</span>
</div>
<div id="ui">
<span>LEVEL: <b id="hudLevel">1</b>/<b id="hudLevelTotal">8</b></span>
<span>TURN: <b id="hudTurn">0</b></span>
<span>PAR: <b id="hudPar">2</b></span>
<span>SCORE: <b id="hudScore">0</b></span>
<span>BEST: <b id="topBest">-</b></span>
</div>
<div id="boardWrap">
<div id="board"></div>
<svg id="pipes"></svg>
<div id="particleLayer"></div>
</div>
<div id="message"></div>
<div id="legend"></div>
<div id="controls">
<button id="btnAdvanceTurn" onclick="tick()">ADVANCE TURN</button>
<span>ENTER &#8212; advance &nbsp;|&nbsp; Backspace &#8212; undo &nbsp;|&nbsp; I &#8212; info</span>
</div>
<div id="overlayIntro" class="overlay">
<div class="panel">
<h2 id="introTitle">LEVEL 1 &#8212; Your first PUB</h2>
<p id="introText"></p>
<p style="margin-top:10px;font-size:12px;line-height:2.2">
<a href="https://wiki.solarnethub.com/ecoin/overview" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none;margin-right:14px">ECOin &#8599;</a>
<a href="https://wiki.solarnethub.com/socialnet/overview" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none;margin-right:14px">Oasis &#8599;</a>
<a href="https://wiki.solarnethub.com/" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none">SolarNET.HuB &#8599;</a>
</p>
<p class="hint">Click a node to start a pipe, then click adjacent cells to extend it, and finish on another node. Click an existing pipe to remove it. Press <b>ENTER</b> (or ADVANCE TURN button) to process one flow cycle.</p>
<div class="buttons"><button class="primary" onclick="closeIntro()">START</button></div>
</div>
</div>
<div id="overlayWin" class="overlay hidden">
<div class="panel">
<h2>&#10003; LEVEL COMPLETE</h2>
<p id="winText"></p>
<p>Level score: <b id="winLevelPoints" style="color:var(--pub);font-size:18px">0</b></p>
<p>Total score: <b id="winTotalPoints" style="color:var(--check);font-size:18px">0</b></p>
<div id="winBreakdown"></div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="ecoinflow">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<div class="buttons">
<button onclick="restartLevel()">RETRY</button>
<button id="btnNextLevel" class="primary" onclick="goNextLevel()">NEXT LEVEL</button>
</div>
</div>
</div>
<div id="overlayLoss" class="overlay hidden">
<div class="panel">
<h2 style="color:var(--acum)">&#10007; NETWORK DOWN</h2>
<p id="lossText"></p>
<div class="buttons"><button class="primary" onclick="restartLevel()">RETRY</button></div>
</div>
</div>
<div id="overlayInfo" class="overlay hidden">
<div class="panel info-panel">
<h2>INFORMATION</h2>
<div id="infoBody"></div>
<div class="buttons"><button class="primary" onclick="closeInfo()">CLOSE</button></div>
</div>
</div>
<script>
var ecoflowBest = parseInt(localStorage.getItem('ecoinflow_best') || '0');
function updateBest(score) {
if (score > ecoflowBest) { ecoflowBest = score; localStorage.setItem('ecoinflow_best', ecoflowBest); }
document.getElementById('topBest').textContent = ecoflowBest > 0 ? String(ecoflowBest) : '-';
}
updateBest(0);
var LEVELS = [
{ id:1, size:3, par:2,
grid:[['empty','inhabitant','empty'],['pub','empty','inhabitant'],['empty','empty','checkpoint']],
concept:'Your first PUB',
introText:'A PUB is a public server in the Oasis network. It generates ECOin and distributes it to those connected. Your task: connect the PUB to the inhabitants and reach the checkpoint.',
winText:'Network active. Inhabitants receive their flow. This is how a PUB works: it does not accumulate, it distributes.' },
{ id:2, size:4, par:4,
grid:[['inhabitant','empty','inhabitant','empty'],['empty','pub','empty','pub'],['inhabitant','empty','inhabitant','empty'],['empty','empty','checkpoint','empty']],
concept:'Universal Basic Income',
introText:'UBI is the flow that reaches every inhabitant of Oasis, regardless of what they do. It is not charity: it is the network recognizing that your time has value. Each PUB emits UBI to connected inhabitants. None can be left without it.',
winText:'All inhabitants receive UBI. The network is fair when no one is disconnected.' },
{ id:3, size:4, par:5,
grid:[['pub','empty','empty','inhabitant'],['empty','validator','empty','empty'],['inhabitant','empty','validator','empty'],['empty','empty','inhabitant','checkpoint']],
concept:'Validators',
introText:'The ECOin network needs validators: nodes that confirm transactions are legitimate. A validator must accumulate minimum flow before it can pass ECOin through. Plan the connection order: charge validators first, then extend the network through them.',
winText:'Validators active. The blockchain advances. Without validators, there is no network.' },
{ id:4, size:4, par:6,
grid:[['pub','empty','inhabitant','empty'],['inhabitant','accumulator','empty','shop'],['empty','empty','shop','empty'],['inhabitant','empty','pub','checkpoint']],
concept:'Proof of Transaction',
introText:'PoT discourages accumulating ECOin without using it. If flow reaches an accumulator and does not exit, the network penalizes it. You can route around it or connect it to a Shop so the flow keeps circulating. ECOin that does not circulate serves no one.',
winText:'Clean flow. This is how the network maintains its economic health.',
winTextPoT:'PoT triggered. Penalty applied. Next time, keep the flow moving.' },
{ id:5, size:5, par:8,
grid:[['pub','empty','inhabitant','empty','inhabitant'],['empty','validator','empty','shop','empty'],['inhabitant','empty','accumulator','empty','validator'],['empty','shop','empty','inhabitant','empty'],['pub','empty','empty','empty','checkpoint']],
concept:'The Checkpoint',
introText:'The ECOin chain advances through checkpoints: milestones confirming that a number of blocks have been mined and validated. This level integrates all elements seen so far. Manage flow carefully: validators block until loaded, the accumulator penalizes if full. Plan before advancing the turn.',
winText:'Checkpoint reached. The network advances. Every mined block is a step toward digital sovereignty.',
winTextPoT:'Checkpoint reached, but PoT triggered. Penalty applied.' },
{ id:6, size:5, par:10,
grid:[[{type:'pub',capacity:2},'empty','inhabitant','empty','pub'],['inhabitant','validator','empty','shop','inhabitant'],['empty','empty','accumulator','checkpoint','validator'],['pub','shop','empty','inhabitant','empty'],['inhabitant','validator','shop','accumulator','checkpoint']],
concept:'Network Under Pressure',
introText:'The real network operates with limited resources. One PUB is running at reduced capacity (marked with a dashed border). You cannot always feed all nodes at once: learn to prioritize. Inhabitants first, then validators, shops if there is surplus. Two checkpoints to activate.',
winText:'Network stabilized under pressure. Digital sovereignty requires conscious management of real resources.',
winTextPoT:'Network stabilized, but PoT penalized. Optimize the accumulator flow.' },
{ id:7, size:5, par:12,
grid:[['pub','inhabitant','empty','inhabitant','pub'],['inhabitant','cbdc','inhabitant','empty','empty'],['empty','inhabitant','empty','validator','shop'],['pub','empty','shop','empty','inhabitant'],['empty','inhabitant','empty','validator','checkpoint']],
concept:'The Digital Euro',
introText:'The Digital Euro is being implemented. Unlike ECOin, a CBDC (Central Bank Digital Currency) is not sovereignty: it is surveillance. The CBDC node tries to capture adjacent inhabitants not connected to a PUB every 2 turns. Connect your PUBs to vulnerable inhabitants first. Speed matters.',
winText:'Sovereign network. Inhabitants are connected to community PUBs, not surveillance infrastructure. This is what is at stake.',
winTextCBDC:'Some inhabitants were captured by CBDC before you could connect them. The network survived, but not all are free.' },
{ id:8, size:6, par:14,
grid:[['pub','empty','inhabitant','empty','inhabitant','pub'],['empty','validator','empty','shop','empty','inhabitant'],['inhabitant','empty','accumulator','empty','validator','empty'],['empty','shop','empty','inhabitant','empty','shop'],['pub','empty','validator','empty','pub','empty'],['inhabitant','empty','cbdc','inhabitant','empty','checkpoint']],
concept:'Complete Oasis',
introText:'This is Oasis. A decentralized network where ECOin flows from PUBs to inhabitants as universal basic income, validators maintain blockchain integrity, shops sustain the internal economy, and checkpoints consolidate network history. CBDC tries to expand from the edge. There is no central bank. Only the network. Complete the picture.',
winText:'Oasis active. You have built a sovereign network. Now you know how it works. Now you can be part of it.' }
];
function createCell(type) {
return { type:type, active:type==='pub', flowReceivedThisTurn:0, turnosSinFlujo:0, acumulado:0, validadorCargado:0, contadorActivo:0, bloqueado:false, capacity:null, cbdcControlled:false };
}
var state = { levelIndex:0, size:0, board:[], paths:[], drawing:null, turn:0, score:0, potActivations:0, cbdcCaptured:new Set(), pendingEvents:[], status:'intro', cursor:{r:0,c:0} };
function loadLevel(i) {
var lvl = LEVELS[i];
state.levelIndex = i; state.size = lvl.size;
state.board = lvl.grid.map(function(row) { return row.map(function(def) {
if (typeof def === 'string') return createCell(def);
var cell = createCell(def.type);
if (def.capacity !== undefined) cell.capacity = def.capacity;
return cell;
}); });
state.paths = []; state.drawing = null; state.turn = 0;
if (i === 0) state.score = 0;
state.potActivations = 0; state.cbdcCaptured = new Set(); state.pendingEvents = [];
state.status = 'playing'; state.cursor = {r:0,c:0};
document.getElementById('hudLevel').textContent = lvl.id;
document.getElementById('hudLevelTotal').textContent = LEVELS.length;
document.getElementById('hudPar').textContent = lvl.par;
document.getElementById('introTitle').textContent = 'LEVEL ' + lvl.id + ' \u2014 ' + lvl.concept;
document.getElementById('introText').textContent = lvl.introText;
renderLegend(); showMessage(''); resizeBoard(); render();
}
var LEGEND_LABELS = { pub:'PUB', inhabitant:'Inhabitant', validator:'Validator', shop:'Shop', accumulator:'Accumulator', checkpoint:'Checkpoint', cbdc:'CBDC' };
var LEGEND_ORDER = ['pub','inhabitant','validator','shop','accumulator','checkpoint','cbdc'];
function renderLegend() {
var present = new Set();
for (var r=0;r<state.size;r++) for (var c=0;c<state.size;c++) { var t=state.board[r][c].type; if(t!=='empty') present.add(t); }
var el = document.getElementById('legend');
el.innerHTML = '';
LEGEND_ORDER.forEach(function(type) {
if (!present.has(type)) return;
var item = document.createElement('div'); item.className = 'item';
var mini = document.createElement('span'); mini.className = 'mini '+type;
var label = document.createElement('span'); label.textContent = LEGEND_LABELS[type];
item.appendChild(mini); item.appendChild(label); el.appendChild(item);
});
}
function getCell(r,c) { if(r<0||c<0||r>=state.size||c>=state.size) return null; return state.board[r][c]; }
function areAdjacent(a,b) { return Math.abs(a.r-b.r)+Math.abs(a.c-b.c)===1; }
function cellInOtherPath(r,c,exc) {
for(var i=0;i<state.paths.length;i++) { if(i===exc) continue; var p=state.paths[i]; for(var k=1;k<p.length-1;k++) if(p[k].r===r&&p[k].c===c) return true; }
return false;
}
function countConnections(r,c) {
return state.paths.filter(function(p){var a=p[0],b=p[p.length-1]; return(a.r===r&&a.c===c)||(b.r===r&&b.c===c);}).length;
}
function getConnectedNodes(r,c) {
var result=[];
state.paths.forEach(function(path){ var a=path[0],b=path[path.length-1]; if(a.r===r&&a.c===c) result.push(b); else if(b.r===r&&b.c===c) result.push(a); });
return result;
}
function isConnectedToPUB(sr,sc) {
var visited=new Set(), queue=[sr+','+sc]; visited.add(sr+','+sc);
while(queue.length>0) {
var key=queue.shift(), parts=key.split(','), kr=+parts[0], kc=+parts[1], cell=state.board[kr][kc];
if(cell.type==='pub'&&cell.active&&!cell.bloqueado) return true;
getConnectedNodes(kr,kc).forEach(function(n){ var nk=n.r+','+n.c; if(!visited.has(nk)){visited.add(nk);queue.push(nk);} });
}
return false;
}
var PUB_OUTPUT=4;
function computePreviewFlow() {
var flow={};
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) flow[r+','+c]=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c];
if(cell.type!=='pub'||!cell.active||cell.bloqueado) continue;
var outs=getConnectedNodes(r,c); if(!outs.length) continue;
var pubOut=cell.capacity!==null?cell.capacity:PUB_OUTPUT, share=pubOut/outs.length;
outs.forEach(function(n){ previewPropagate(n.r,n.c,share,new Set([r+','+c]),flow); });
}
return flow;
}
function previewPropagate(r,c,amount,visited,flow) {
var cell=state.board[r][c], k=r+','+c;
if(visited.has(k)) return; visited.add(k);
if(cell.type!=='pub') flow[k]=(flow[k]||0)+amount;
if(cell.type!=='validator'||!cell.active||cell.bloqueado) return;
getConnectedNodes(r,c).filter(function(n){return!visited.has(n.r+','+n.c);}).forEach(function(n){ previewPropagate(n.r,n.c,amount,new Set(visited),flow); });
}
function render() { renderBoard(); renderPipes(); renderParticles(); renderHud(); }
function makeEcoinSVG() {
var ns='http://www.w3.org/2000/svg';
var svg=document.createElementNS(ns,'svg');
svg.setAttribute('viewBox','0 0 32 32'); svg.setAttribute('width','68%'); svg.setAttribute('height','68%');
svg.classList.add('node-icon');
var c1=document.createElementNS(ns,'circle'); c1.setAttribute('cx','16'); c1.setAttribute('cy','16'); c1.setAttribute('r','13'); c1.setAttribute('fill','#0d0800'); c1.setAttribute('stroke','#FFA500'); c1.setAttribute('stroke-width','2.5');
var c2=document.createElementNS(ns,'circle'); c2.setAttribute('cx','16'); c2.setAttribute('cy','16'); c2.setAttribute('r','9'); c2.setAttribute('fill','none'); c2.setAttribute('stroke','rgba(255,165,0,0.35)'); c2.setAttribute('stroke-width','1');
var t=document.createElementNS(ns,'text'); t.setAttribute('x','16'); t.setAttribute('y','21'); t.setAttribute('text-anchor','middle'); t.setAttribute('font-size','15'); t.setAttribute('font-weight','bold'); t.setAttribute('fill','#FFA500'); t.setAttribute('font-family','monospace'); t.textContent='E';
svg.appendChild(c1); svg.appendChild(c2); svg.appendChild(t);
return svg;
}
function renderBoard() {
var preview=computePreviewFlow();
var fmtFlow=function(v){return Number.isInteger(v)?String(v):v.toFixed(1);};
var board=document.getElementById('board');
board.innerHTML='';
board.style.gridTemplateColumns='repeat('+state.size+', var(--cell))';
var last=state.drawing?state.drawing.cells[state.drawing.cells.length-1]:null;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
(function(r,c){
var cell=state.board[r][c];
var div=document.createElement('div'); div.className='cell';
if(cell.type==='empty') div.classList.add('empty');
if(state.cursor&&state.cursor.r===r&&state.cursor.c===c) div.classList.add('cursor');
if(last&&areAdjacent(last,{r:r,c:c})) {
var alreadyIn=state.drawing.cells.some(function(p){return p.r===r&&p.c===c;});
var blocked=cell.type==='empty'&&cellInOtherPath(r,c);
if(!alreadyIn&&!blocked) div.classList.add('drawable');
}
if(state.drawing&&state.drawing.cells.some(function(p){return p.r===r&&p.c===c;})) div.classList.add('in-path');
if(cell.type!=='empty') {
var node=document.createElement('div'); node.className='node '+cell.type;
if(cell.cbdcControlled){ node.classList.add('cbdc-controlled'); node.classList.add('inactive'); }
else if(cell.active&&!cell.bloqueado) node.classList.add('active');
else node.classList.add('inactive');
if(cell.type==='inhabitant'&&!cell.cbdcControlled&&cell.turnosSinFlujo>=2){ node.classList.remove('inactive'); node.classList.add('danger'); }
if(cell.type==='inhabitant'&&!cell.cbdcControlled&&!node.classList.contains('danger')) {
var adjToCBDC=[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].some(function(p){ if(p.r<0||p.c<0||p.r>=state.size||p.c>=state.size) return false; return state.board[p.r][p.c].type==='cbdc'; });
if(adjToCBDC&&!isConnectedToPUB(r,c)){ node.classList.remove('inactive'); node.classList.add('cbdc-threat'); }
}
if(cell.type==='accumulator'&&!cell.bloqueado&&cell.acumulado>=4) node.classList.add('acum-danger');
if(cell.type==='cbdc') {
var hasVuln=[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].some(function(p){ if(p.r<0||p.c<0||p.r>=state.size||p.c>=state.size) return false; var adj=state.board[p.r][p.c]; return adj.type==='inhabitant'&&!adj.cbdcControlled&&!isConnectedToPUB(p.r,p.c); });
if(hasVuln){ node.classList.remove('inactive'); node.classList.add('cbdc-alarm'); }
}
if(cell.type==='pub'&&cell.capacity!==null) node.classList.add('reduced');
if(cell.type==='pub'){ node.appendChild(makeEcoinSVG()); }
else if(cell.type==='inhabitant'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\uD83E\uDD65'; node.appendChild(ico); }
else if(cell.type==='validator'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\u2714'; node.appendChild(ico); }
else if(cell.type==='cbdc'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\uD83D\uDC80'; node.appendChild(ico); }
if(cell.type==='checkpoint'){ var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=Math.min(cell.contadorActivo,3); node.appendChild(lbl); }
if(cell.type==='validator') {
if(!cell.active){ var pct=Math.round(Math.min(100,(cell.validadorCargado/3)*100)); node.style.background='linear-gradient(to top,#c8dde8 '+pct+'%,rgba(200,218,230,.55) '+pct+'%)'; }
var pf=preview[r+','+c]||0; var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=fmtFlow(pf); node.appendChild(lbl);
}
if(cell.type==='pub') {
var pubOutput=cell.capacity!==null?cell.capacity:4, conns=countConnections(r,c), share=conns>1?pubOutput/conns:pubOutput;
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=Number.isInteger(share)?share:share.toFixed(1); node.appendChild(lbl);
}
if(cell.type==='inhabitant'&&!cell.cbdcControlled) {
var pf=preview[r+','+c]||0, health=3-cell.turnosSinFlujo;
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=health;
lbl.style.color=pf>=1?'#fff':pf>0?'#FFA500':'#FF5252'; node.appendChild(lbl);
}
if(cell.type==='accumulator'&&!cell.bloqueado) {
var acc=cell.acumulado, pct=Math.round(Math.min(100,(acc/5)*100));
var fillColor=acc>=4.5?'#FF1744':acc>=3?'#FF5252':acc>=1.5?'#E53935':'#B71C1C';
node.style.background='linear-gradient(to top,'+fillColor+' '+pct+'%,rgba(255,82,82,.18) '+pct+'%)';
var pf=preview[r+','+c]||0, projected=Math.min(acc+pf,5);
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=fmtFlow(projected)+'/5'; node.appendChild(lbl);
}
div.appendChild(node);
}
div.setAttribute('role','button');
div.setAttribute('aria-label',cell.type==='empty'?'Empty cell':LEGEND_LABELS[cell.type]||cell.type);
div.addEventListener('click',function(){onCellClick(r,c);});
board.appendChild(div);
})(r,c);
}
}
function cellCenter(r,c) {
var boardEl=document.getElementById('board'), cellEls=boardEl.querySelectorAll('.cell');
if(cellEls.length>0) {
var idx=r*state.size+c, rect=cellEls[idx]?cellEls[idx].getBoundingClientRect():null, boardRect=boardEl.getBoundingClientRect();
if(rect) return {x:rect.left-boardRect.left+rect.width/2,y:rect.top-boardRect.top+rect.height/2};
}
var pad=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--pad'))||8;
var gap=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--gap'))||4;
var cs=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cell'))||72;
return {x:pad+c*(cs+gap)+cs/2,y:pad+r*(cs+gap)+cs/2};
}
function boardOffset() {
var boardEl=document.getElementById('board'), wrapEl=document.getElementById('boardWrap');
if(!boardEl||!wrapEl) return {x:0,y:0};
var br=boardEl.getBoundingClientRect(), wr=wrapEl.getBoundingClientRect();
return {x:br.left-wr.left, y:br.top-wr.top};
}
function renderPipes() {
var svg=document.getElementById('pipes'), boardEl=document.getElementById('board');
var off=boardOffset(), w=boardEl.offsetWidth, h=boardEl.offsetHeight;
svg.setAttribute('width',w); svg.setAttribute('height',h);
svg.style.width=w+'px'; svg.style.height=h+'px';
svg.style.left=off.x+'px'; svg.style.top=off.y+'px';
svg.innerHTML='';
var flowingIdx=computeFlowingPaths();
var cellEl=document.querySelector('#board .cell'), nodeR=cellEl?cellEl.offsetWidth*0.40:26;
function trimPt(from,toward,r){ var dx=toward.x-from.x,dy=toward.y-from.y,len=Math.sqrt(dx*dx+dy*dy); return len>0?{x:from.x+dx/len*r,y:from.y+dy/len*r}:from; }
function makePath(pts){ var d='M '+pts[0].x.toFixed(1)+' '+pts[0].y.toFixed(1); for(var i=1;i<pts.length;i++) d+=' L '+pts[i].x.toFixed(1)+' '+pts[i].y.toFixed(1); return d; }
state.paths.forEach(function(path,idx) {
var pts=path.map(function(p){return cellCenter(p.r,p.c);});
if(pts.length>=2){ pts=pts.slice(); pts[0]=trimPt(pts[0],pts[1],nodeR); pts[pts.length-1]=trimPt(pts[pts.length-1],pts[pts.length-2],nodeR); }
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d',makePath(pts)); el.classList.add('clickable');
if(flowingIdx.has(idx)) el.classList.add('flowing');
el.addEventListener('click',function(e){e.stopPropagation();removePath(idx);});
svg.appendChild(el);
});
state.cbdcCaptured.forEach(function(key){
var parts=key.split(','), cr=+parts[0], cc=+parts[1];
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
if(state.board[r][c].type!=='cbdc') continue;
var from=cellCenter(r,c), to=cellCenter(cr,cc);
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d','M '+from.x.toFixed(1)+' '+from.y.toFixed(1)+' L '+to.x.toFixed(1)+' '+to.y.toFixed(1)); el.classList.add('cbdc-line'); svg.appendChild(el);
}
});
if(state.drawing&&state.drawing.cells.length>0) {
var pts=state.drawing.cells.map(function(p){return cellCenter(p.r,p.c);});
if(pts.length>1){
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d',makePath(pts)); el.setAttribute('stroke-dasharray','8 5'); el.setAttribute('opacity','0.75'); el.setAttribute('stroke-width','4'); svg.appendChild(el);
}
var start=pts[0], circle=document.createElementNS('http://www.w3.org/2000/svg','circle');
circle.setAttribute('cx',start.x.toFixed(1)); circle.setAttribute('cy',start.y.toFixed(1)); circle.setAttribute('r',nodeR);
circle.setAttribute('fill','rgba(255,165,0,.15)'); circle.setAttribute('stroke','#FFA500'); circle.setAttribute('stroke-width','2'); svg.appendChild(circle);
}
}
function computeFlowingPaths() {
var flowing=new Set();
state.paths.forEach(function(p,i){ var a=state.board[p[0].r][p[0].c], b=state.board[p[p.length-1].r][p[p.length-1].c]; var aPub=a.type==='pub'&&a.active&&!a.bloqueado, bPub=b.type==='pub'&&b.active&&!b.bloqueado; if(aPub&&!b.bloqueado) flowing.add(i); if(bPub&&!a.bloqueado) flowing.add(i); });
return flowing;
}
function renderParticles() {
var layer=document.getElementById('particleLayer'); if(!layer) return;
var boardEl=document.getElementById('board'), off=boardOffset();
layer.style.left=off.x+'px'; layer.style.top=off.y+'px';
layer.style.width=boardEl.offsetWidth+'px'; layer.style.height=boardEl.offsetHeight+'px';
layer.querySelectorAll('.flow-particle').forEach(function(el){el.remove();});
if(state.status!=='playing'&&state.status!=='won') return;
var flowingIdx=computeFlowingPaths(), DURATION=900, N=4;
flowingIdx.forEach(function(idx){
var path=state.paths[idx]; if(!path||path.length<2) return;
var pts=path.map(function(p){return cellCenter(p.r,p.c);});
var cellA=state.board[path[0].r][path[0].c], cellB=state.board[path[path.length-1].r][path[path.length-1].c];
if(cellB.type==='pub'&&cellA.type!=='pub') pts=pts.slice().reverse();
var d='M '+pts.map(function(p){return p.x.toFixed(1)+' '+p.y.toFixed(1);}).join(' L ');
for(var k=0;k<N;k++){
var particle=document.createElement('div'); particle.className='flow-particle';
particle.style.offsetPath='path("'+d+'")'; particle.style.animationDuration=DURATION+'ms'; particle.style.animationDelay=(-k*DURATION/N)+'ms';
layer.appendChild(particle);
}
});
}
function fireEvents() {
var layer=document.getElementById('particleLayer'); if(!layer||state.pendingEvents.length===0) return;
state.pendingEvents.forEach(function(evt){
var center=cellCenter(evt.r,evt.c);
if(evt.type==='pot'){ var txt=document.createElement('div'); txt.className='pot-float'; txt.textContent='\u221250'; txt.style.left=(center.x-16)+'px'; txt.style.top=(center.y-10)+'px'; layer.appendChild(txt); txt.addEventListener('animationend',function(){txt.remove();}); }
if(evt.type==='checkpoint'){ var ring=document.createElement('div'); ring.className='check-pulse'; ring.style.left=center.x+'px'; ring.style.top=center.y+'px'; ring.style.width='30px'; ring.style.height='30px'; layer.appendChild(ring); ring.addEventListener('animationend',function(){ring.remove();}); }
});
}
function renderHud() {
document.getElementById('hudScore').textContent=state.score;
var lvl=LEVELS[state.levelIndex], turnEl=document.getElementById('hudTurn');
turnEl.textContent=state.turn;
turnEl.style.color=state.turn>lvl.par?'#FF5252':state.turn===lvl.par?'#FFA500':'var(--pub)';
}
function showMessage(text,type) {
var el=document.getElementById('message'); el.textContent=text||'';
el.className=type==='alert'?'alert':type==='warning'?'warning':'';
}
function buildTurnStatus() {
var parts=[];
var checks=[];
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='checkpoint') checks.push(cell.active?'\u2713':cell.contadorActivo+'/3'); }
if(checks.length>0) parts.push('Checkpoint: '+checks.join(' '));
var habRisk1=0,habRisk2=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='inhabitant'&&!cell.cbdcControlled){ if(cell.turnosSinFlujo===1) habRisk1++; else if(cell.turnosSinFlujo>=2) habRisk2++; } }
if(habRisk2>0) parts.push('\u26a0 '+habRisk2+' inhabitant(s) in danger');
else if(habRisk1>0) parts.push(habRisk1+' inhabitant(s) without flow');
var acumP=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='accumulator'&&!cell.bloqueado&&cell.acumulado>=4) acumP++; }
if(acumP>0) parts.push('\u26a0 Accumulator almost full');
return parts.join(' \u00b7 ');
}
function onCellClick(r,c) {
if(state.status!=='playing') return;
var cell=state.board[r][c];
if(!state.drawing) {
if(cell.type==='empty'){ showMessage('Start drawing by clicking on a node.'); return; }
if(cell.type==='inhabitant'){ showMessage('Inhabitants can only receive connections.'); return; }
state.drawing={cells:[{r:r,c:c}]};
showMessage('Drawing from '+(LEGEND_LABELS[cell.type]||cell.type).toUpperCase()+'. Click adjacent cells and finish on another node.');
render(); return;
}
var cells=state.drawing.cells, last=cells[cells.length-1];
if(cells.length===1&&last.r===r&&last.c===c){ cancelDrawing(); return; }
if(!areAdjacent(last,{r:r,c:c})){ showMessage('Can only extend to adjacent cells.'); return; }
if(cells.some(function(p){return p.r===r&&p.c===c;})){ showMessage('That cell is already in the path.'); return; }
if(cell.type==='empty'&&cellInOtherPath(r,c)){ showMessage('That cell already has a pipe.'); return; }
cells.push({r:r,c:c});
if(cell.type!=='empty'){ finalizeDrawing(); return; }
render();
}
function finalizeDrawing() {
var cells=state.drawing.cells, first=state.board[cells[0].r][cells[0].c], last=state.board[cells[cells.length-1].r][cells[cells.length-1].c];
if(first.type==='empty'||last.type==='empty'||cells.length<2){ cancelDrawing(); return; }
var a=cells[0], b=cells[cells.length-1];
if(countConnections(a.r,a.c)>=4){ showMessage('That node already has 4 connections (maximum).'); cancelDrawing(); return; }
if(last.type==='inhabitant'&&countConnections(b.r,b.c)>=1){ showMessage('This inhabitant is already connected.'); cancelDrawing(); return; }
if(countConnections(b.r,b.c)>=4){ showMessage('That node already has 4 connections (maximum).'); cancelDrawing(); return; }
var dup=state.paths.some(function(p){ var x=p[0],y=p[p.length-1]; return(x.r===a.r&&x.c===a.c&&y.r===b.r&&y.c===b.c)||(x.r===b.r&&x.c===b.c&&y.r===a.r&&y.c===a.c); });
if(dup){ showMessage('A pipe between those nodes already exists.'); cancelDrawing(); return; }
state.paths.push(cells); state.drawing=null; state.score+=2; showMessage('Pipe created. (+2)'); render();
}
function cancelDrawing(){ state.drawing=null; showMessage(''); render(); }
function removePath(idx){ if(state.status!=='playing') return; state.paths.splice(idx,1); showMessage('Pipe removed.'); render(); }
function propagateFrom(r,c,amount,visited) {
var cell=state.board[r][c], k=r+','+c;
if(visited.has(k)) return; visited.add(k);
if(cell.type!=='pub') cell.flowReceivedThisTurn+=amount;
if(cell.type!=='validator'||!cell.active||cell.bloqueado) return;
getConnectedNodes(r,c).filter(function(n){return!visited.has(n.r+','+n.c);}).forEach(function(n){propagateFrom(n.r,n.c,amount,new Set(visited));});
}
function tick() {
if(state.status!=='playing') return;
if(state.paths.length===0) showMessage('No pipes: connect nodes before advancing. Inhabitants will lose flow.','warning');
state.turn++;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) state.board[r][c].flowReceivedThisTurn=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c];
if(cell.type!=='pub'||!cell.active||cell.bloqueado) continue;
var outs=getConnectedNodes(r,c); if(!outs.length) continue;
var pubOutput=cell.capacity!==null?cell.capacity:PUB_OUTPUT, share=pubOutput/outs.length;
outs.forEach(function(n){ var v=new Set(); v.add(r+','+c); propagateFrom(n.r,n.c,share,v); });
}
var caidoDesc=null;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c], f=cell.flowReceivedThisTurn;
switch(cell.type) {
case 'inhabitant':
if(cell.cbdcControlled) break;
if(f>=1){ cell.active=true; cell.turnosSinFlujo=0; }
else{ cell.active=false; cell.turnosSinFlujo++; if(cell.turnosSinFlujo>=3&&!caidoDesc) caidoDesc='Inhabitant at row '+(r+1)+', col '+(c+1)+' has gone 3 turns without ECOin.'; }
break;
case 'validator': cell.validadorCargado+=f; if(cell.validadorCargado>=3) cell.active=true; break;
case 'shop': cell.active=f>=2; break;
case 'accumulator':
cell.acumulado+=f;
if(cell.acumulado>5&&!cell.bloqueado){ cell.bloqueado=true; cell.active=false; state.potActivations++; state.pendingEvents.push({type:'pot',r:r,c:c}); }
break;
case 'checkpoint':
if(f>0){ cell.contadorActivo++; if(cell.contadorActivo>=3){ cell.active=true; if(cell.contadorActivo===3) state.pendingEvents.push({type:'checkpoint',r:r,c:c}); } }
break;
}
}
if(state.turn%2===0) {
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
if(state.board[r][c].type!=='cbdc') continue;
[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].filter(function(p){return p.r>=0&&p.c>=0&&p.r<state.size&&p.c<state.size;}).forEach(function(p){
var adjCell=state.board[p.r][p.c];
if(adjCell.type!=='inhabitant'||adjCell.cbdcControlled) return;
if(isConnectedToPUB(p.r,p.c)) return;
adjCell.cbdcControlled=true; adjCell.active=false; state.cbdcCaptured.add(p.r+','+p.c);
if(!caidoDesc) caidoDesc='Inhabitant at row '+(p.r+1)+', col '+(p.c+1)+' was captured by CBDC. Connect PUBs first.';
});
}
}
if(caidoDesc){ state.status='lost'; document.getElementById('lossText').textContent=caidoDesc+' The network cannot leave anyone disconnected. Review your connections.'; document.getElementById('overlayLoss').classList.remove('hidden'); render(); return; }
var hasCheckpoint=false, allActive=true;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='checkpoint'){ hasCheckpoint=true; if(!cell.active) allActive=false; } }
if(hasCheckpoint&&allActive) {
state.status='won';
var lvl=LEVELS[state.levelIndex], base=100, shopsActive=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) if(state.board[r][c].type==='shop'&&state.board[r][c].active) shopsActive++;
var bonusShops=shopsActive*10, bonusTurns=Math.max(0,lvl.par-state.turn)*5, penalPot=state.potActivations*50, levelScore=base+bonusShops+bonusTurns-penalPot;
state.score+=levelScore;
var winMsg=lvl.winText;
if(state.potActivations>0&&lvl.winTextPoT) winMsg=lvl.winTextPoT;
if(state.cbdcCaptured.size>0&&lvl.winTextCBDC) winMsg=lvl.winTextCBDC;
document.getElementById('winText').textContent=winMsg;
document.getElementById('winLevelPoints').textContent=levelScore;
document.getElementById('winTotalPoints').textContent=state.score;
document.getElementById('scoreInput').value=state.score;
var bd=document.getElementById('winBreakdown'); bd.innerHTML='';
function bdLine(cls,txt){ var s=document.createElement('span'); s.className=cls; s.textContent=txt; bd.appendChild(s); }
bdLine('positive','Base: +'+base);
if(bonusShops>0) bdLine('positive','Active shops ('+shopsActive+'): +'+bonusShops);
if(bonusTurns>0) bdLine('positive','Turns under par ('+(lvl.par-state.turn)+'): +'+bonusTurns);
if(penalPot>0) bdLine('negative','PoT triggered (\u00d7'+state.potActivations+'): \u221250');
var isLastLevel=state.levelIndex+1>=LEVELS.length;
var beatsBest=state.score>ecoflowBest;
updateBest(state.score);
document.getElementById('scoreSubmit').style.display=(isLastLevel&&beatsBest&&state.score>0)?'block':'none';
var nextBtn=document.getElementById('btnNextLevel'); nextBtn.style.display=(state.levelIndex+1<LEVELS.length)?'inline-block':'none';
render(); fireEvents(); state.pendingEvents=[];
setTimeout(function(){document.getElementById('overlayWin').classList.remove('hidden');},1500);
return;
}
var statusMsg=buildTurnStatus();
if(statusMsg){ showMessage(statusMsg,statusMsg.includes('danger')||statusMsg.includes('full')?'alert':null); }
else showMessage('Turn '+state.turn+' \u2014 no issues.');
render(); fireEvents(); state.pendingEvents=[];
}
function restartLevel() {
document.getElementById('overlayWin').classList.add('hidden'); document.getElementById('overlayLoss').classList.add('hidden');
if(state.levelIndex===0) state.score=0;
loadLevel(state.levelIndex);
}
function goNextLevel() {
if(state.levelIndex+1>=LEVELS.length) return;
document.getElementById('overlayWin').classList.add('hidden'); document.getElementById('overlayLoss').classList.add('hidden');
loadLevel(state.levelIndex+1); state.status='intro'; document.getElementById('overlayIntro').classList.remove('hidden');
}
function closeIntro(){ document.getElementById('overlayIntro').classList.add('hidden'); state.status='playing'; }
function closeInfo(){ document.getElementById('overlayInfo').classList.add('hidden'); }
function mk(tag,cls,txt){ var el=document.createElement(tag); if(cls) el.className=cls; if(txt!==undefined) el.textContent=txt; return el; }
function mka(tag,cls,attrs){ var el=document.createElement(tag); if(cls) el.className=cls; if(attrs) Object.keys(attrs).forEach(function(k){el.setAttribute(k,attrs[k]);}); return el; }
function openInfo() {
var lvl=LEVELS[state.levelIndex], body=document.getElementById('infoBody'); body.innerHTML='';
var habOK=0,habRiesgo=0,habCount=0,habCBDC=0,checkpoints=[],valActivos=0,valCargando=[],acumFilling=[],acumBloqueados=0,shopsActive=0,shopsInactive=0,pubsActivos=0;
var fmt=function(n){return(Math.round(n*10)/10).toString();};
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){
var cell=state.board[r][c];
switch(cell.type){
case 'pub': if(cell.active&&!cell.bloqueado) pubsActivos++; break;
case 'inhabitant': habCount++; if(cell.cbdcControlled) habCBDC++; else if(cell.turnosSinFlujo===0) habOK++; else habRiesgo++; break;
case 'checkpoint': checkpoints.push(cell.contadorActivo); break;
case 'validator': if(cell.active) valActivos++; else valCargando.push(cell.validadorCargado); break;
case 'accumulator': if(cell.bloqueado) acumBloqueados++; else acumFilling.push(cell.acumulado); break;
case 'shop': if(cell.active) shopsActive++; else shopsInactive++; break;
}
}
function h3(t){ var el=mk('h3'); el.textContent=t; body.appendChild(el); }
function p(t){ var el=mk('p'); el.textContent=t; body.appendChild(el); }
function ul(items){ var ul=mk('ul'); items.forEach(function(t){ var li=mk('li'); li.textContent=t; ul.appendChild(li); }); body.appendChild(ul); }
h3('Level objective');
p(lvl.introText);
p('Goal: activate all checkpoints (3 turns with flow) without any inhabitant going 3 turns without ECOin.');
h3('Current status');
var stat=mk('div','stat-line');
stat.textContent='Turn '+state.turn+' \u00b7 Par '+lvl.par+' \u00b7 Score '+state.score+' \u00b7 Active PUBs '+pubsActivos+' \u00b7 PoT activations '+state.potActivations;
body.appendChild(stat);
var statusItems=[];
if(habCount>0){ var t='Inhabitants: '+habOK+' with flow'; if(habRiesgo>0) t+=', '+habRiesgo+' without flow (fall at 3 turns)'; if(habCBDC>0) t+=', '+habCBDC+' captured by CBDC'; statusItems.push(t); }
if(checkpoints.length>0) statusItems.push('Checkpoints: '+checkpoints.map(function(p){return p>=3?'active':p+'/3';}).join(' / '));
if(valActivos>0||valCargando.length>0){ var t=(valActivos>0?valActivos+' active':'')+(valCargando.length>0?(valActivos>0?', ':'')+'loading: '+valCargando.map(function(v){return fmt(v)+'/3';}).join(', '):''); statusItems.push('Validators: '+t); }
if(acumFilling.length>0||acumBloqueados>0){ var t=(acumFilling.length>0?'filling: '+acumFilling.map(function(a){return fmt(a)+'/5';}).join(', '):'')+(acumBloqueados>0?(acumFilling.length>0?', ':'')+acumBloqueados+' blocked (PoT)':''); statusItems.push('Accumulators: '+t+' (-50 pts when exceeding 5)'); }
if(shopsActive>0||shopsInactive>0) statusItems.push('Shops: '+shopsActive+' active'+(shopsInactive>0?', '+shopsInactive+' inactive':'')+' (+10 pts each when winning)');
ul(statusItems);
h3('How to play');
ul(['Click a node to start a pipe, click adjacent cells to extend, finish on another node.','Click an existing pipe to remove it.','Press ENTER (or ADVANCE TURN button) to process one flow cycle.','A node can have a maximum of 4 connections. Inhabitants accept only 1.', '+2 score per pipe placed.']);
h3('ECOin rules');
ul(['Each active PUB generates 4 ECOin units/turn (2 if reduced capacity), split equally among connections.','An inhabitant needs at least 1 unit/turn. Three turns without flow = defeat.','A validator blocks flow until it accumulates 3 units. Once active it forwards flow to other connections.','An accumulator absorbs without redistributing. Exceeding 5 units triggers PoT: -50 pts and node blocked.','A shop needs at least 2 units/turn to operate. Active shops when winning: +10 pts each.','A checkpoint activates after receiving flow for 3 consecutive turns. Activating all checkpoints wins the level.','CBDC captures adjacent inhabitants not connected to a PUB every 2 turns = defeat.']);
h3('Scoring');
ul(['+2 per pipe placed','+100 per level completed','+10 per active shop at completion','+5 per turn saved vs. par (only if completed under par)','-50 per accumulator blocked by PoT','Score accumulates across all levels']);
h3('Glossary');
ul(['PUB - Public Server: Oasis network node distributing ECOin to connected inhabitants. Not centralized.','ECOin: Community currency. Its value is anchored to human time. Cannot be issued arbitrarily.','UBI - Universal Basic Income: Flow reaching every inhabitant regardless of activity.','PoT - Proof of Transaction: Discourages accumulating ECOin without using it.','Validator: Confirms transaction legitimacy. Needs minimum flow before it can operate.','Checkpoint: Milestone on the ECOin blockchain. When reached, history is consolidated and immutable.','CBDC - Central Bank Digital Currency: Digital currency by a central bank. Enables tracking every transaction = total financial surveillance.']);
document.getElementById('overlayInfo').classList.remove('hidden');
}
function undoAction() {
if(state.status!=='playing') return;
if(state.drawing){ cancelDrawing(); }
else if(state.paths.length>0){ state.paths.pop(); showMessage('Pipe undone.'); render(); }
else showMessage('Nothing to undo.');
}
function deletePipeAtCursor() {
if(state.status!=='playing') return;
if(state.drawing){ cancelDrawing(); return; }
var r=state.cursor.r, c=state.cursor.c, before=state.paths.length;
state.paths=state.paths.filter(function(path){return!path.some(function(p){return p.r===r&&p.c===c;});});
if(state.paths.length<before){ showMessage('Pipe removed.'); render(); }
}
document.addEventListener('keydown',function(e){
if(e.code==='Enter'){
e.preventDefault();
if(!document.getElementById('overlayInfo').classList.contains('hidden')) closeInfo();
else if(!document.getElementById('overlayIntro').classList.contains('hidden')) closeIntro();
else if(!document.getElementById('overlayWin').classList.contains('hidden')){ var nb=document.getElementById('btnNextLevel'); if(nb&&!nb.disabled) goNextLevel(); else restartLevel(); }
else if(!document.getElementById('overlayLoss').classList.contains('hidden')) restartLevel();
else if(state.status==='playing') tick();
}
if(e.code==='Backspace'&&state.status==='playing'){ e.preventDefault(); undoAction(); }
if(e.code==='Space'&&state.status==='playing'){ e.preventDefault(); deletePipeAtCursor(); }
if(e.code==='Space'&&(state.status==='lost'||state.status==='won')){ e.preventDefault(); restartLevel(); }
if((e.ctrlKey||e.metaKey)&&e.code==='KeyZ'){ e.preventDefault(); undoAction(); }
var DIRS={ArrowUp:[-1,0],ArrowDown:[1,0],ArrowLeft:[0,-1],ArrowRight:[0,1]};
if(DIRS[e.code]!==undefined&&state.status==='playing'){
e.preventDefault();
var dr=DIRS[e.code][0], dc=DIRS[e.code][1], nr=state.cursor.r+dr, nc=state.cursor.c+dc;
if(nr>=0&&nc>=0&&nr<state.size&&nc<state.size){
if(e.shiftKey){ var curCell=state.board[state.cursor.r][state.cursor.c]; if(!state.drawing&&curCell.type!=='empty') onCellClick(state.cursor.r,state.cursor.c); state.cursor={r:nr,c:nc}; if(state.drawing) onCellClick(nr,nc); }
else state.cursor={r:nr,c:nc};
render();
}
}
if((e.key==='i'||e.key==='I'||e.key==='?')&&!e.ctrlKey&&!e.metaKey){
if(document.getElementById('overlayInfo').classList.contains('hidden')&&document.getElementById('overlayIntro').classList.contains('hidden')&&document.getElementById('overlayWin').classList.contains('hidden')&&document.getElementById('overlayLoss').classList.contains('hidden')){ e.preventDefault(); openInfo(); }
}
if(e.code==='Escape'){ if(!document.getElementById('overlayInfo').classList.contains('hidden')) closeInfo(); else if(state.drawing) cancelDrawing(); }
});
function resizeBoard() {
var sz = state.size || LEVELS[state.levelIndex].size;
var gap = 5, pad = 10;
var topbar = document.getElementById('topbar').offsetHeight || 40;
var ui = document.getElementById('ui').offsetHeight || 32;
var msg = document.getElementById('message').offsetHeight || 18;
var legend = document.getElementById('legend').offsetHeight || 0;
var controls = document.getElementById('controls').offsetHeight || 40;
var usedH = topbar + ui + msg + legend + controls + 12;
var avW = window.innerWidth;
var avH = Math.max(80, window.innerHeight - usedH);
var cell = Math.floor(Math.min(
(avW - pad*2 - gap*(sz-1)) / sz,
(avH - pad*2 - gap*(sz-1)) / sz
));
cell = Math.max(32, cell);
document.documentElement.style.setProperty('--cell', cell+'px');
}
window.addEventListener('resize', function() { resizeBoard(); renderPipes(); renderParticles(); });
loadLevel(0);
state.status='intro';
</script>
</body>
</html>

View file

@ -0,0 +1,113 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="10" y="10" width="380" height="200" rx="4" fill="#111" stroke="#222" stroke-width="1"/>
<!-- Grid cells -->
<rect x="22" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="22" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="22" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<!-- Pipes (orange paths between nodes) -->
<path d="M48 74 L48 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M48 134 L48 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M74 48 L82 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M134 48 L142 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M108 74 L108 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M108 134 L108 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M194 108 L202 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M254 108 L262 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M228 74 L228 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M288 74 L288 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M288 134 L288 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M314 168 L322 168" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M168 134 L168 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<!-- PUB node (orange square) row1 col1 -->
<rect x="34" y="34" width="36" height="36" rx="2" fill="#FFA500" opacity="0.92"/>
<line x1="52" y1="24" x2="52" y2="34" stroke="#FFA500" stroke-width="2"/>
<circle cx="52" cy="21" r="3" fill="#FFA500"/>
<text x="52" y="57" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
<!-- habitante node (green circle) row1 col2 -->
<circle cx="108" cy="48" r="18" fill="#8BC34A" opacity="0.9"/>
<text x="108" y="52" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- validador (diamond) row1 col3 -->
<polygon points="168,30 186,48 168,66 150,48" fill="rgba(200,218,230,0.55)" stroke="#ccc" stroke-width="1"/>
<text x="168" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">VAL</text>
<!-- tienda (hexagon) row1 col4 -->
<polygon points="228,30 241,38 241,58 228,66 215,58 215,38" fill="#4CAF50" opacity="0.9"/>
<text x="228" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- acumulador (triangle) row1 col5 -->
<polygon points="288,30 310,66 266,66" fill="rgba(255,82,82,0.25)" stroke="#FF5252" stroke-width="1"/>
<text x="288" y="58" font-family="monospace" font-size="7" fill="#eee" text-anchor="middle" font-weight="bold">ACU</text>
<!-- CBDC (grey square) row1 col6 -->
<rect x="334" y="34" width="36" height="36" rx="2" fill="#607D8B" opacity="0.9"/>
<text x="352" y="57" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
<!-- row2 nodes -->
<!-- PUB node row2 col1 -->
<rect x="34" y="94" width="36" height="36" rx="2" fill="#FFA500" opacity="0.7"/>
<text x="52" y="117" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
<!-- hab row2 col2 -->
<circle cx="108" cy="108" r="18" fill="#8BC34A" opacity="0.7"/>
<text x="108" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- hab row2 col4 -->
<circle cx="228" cy="108" r="18" fill="#8BC34A" opacity="0.85"/>
<text x="228" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- tienda row2 col5 -->
<polygon points="288,90 301,98 301,118 288,126 275,118 275,98" fill="#4CAF50" opacity="0.75"/>
<text x="288" y="112" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- checkpoint row3 col2 -->
<rect x="90" y="154" width="36" height="36" rx="2" fill="none" stroke="#FFFF55" stroke-width="3"/>
<text x="108" y="177" font-family="monospace" font-size="7" fill="#FFFF55" text-anchor="middle" font-weight="bold">CHK</text>
<!-- hab row3 col3 -->
<circle cx="168" cy="168" r="18" fill="#8BC34A" opacity="0.8"/>
<text x="168" y="172" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- CBDC row3 col4 -->
<rect x="214" y="154" width="36" height="36" rx="2" fill="#607D8B" opacity="0.8"/>
<text x="232" y="177" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
<!-- tienda row3 col6 -->
<polygon points="348,154 361,162 361,182 348,190 335,182 335,162" fill="#4CAF50" opacity="0.8"/>
<text x="348" y="176" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- Flow particles on some pipes -->
<circle cx="48" cy="78" r="3" fill="#FFA500" opacity="0.95">
<animate attributeName="cy" values="74;82" dur="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="91" cy="48" r="3" fill="#FFA500" opacity="0.95">
<animate attributeName="cx" values="74;82" dur="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
</circle>
<!-- Title -->
<text x="200" y="212" font-family="'Courier New', monospace" font-size="14" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">ECOINFLOW</text>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Labyrinth</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; border: 1px solid #333; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
#dpad-row { display: flex; gap: 4px; }
#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
#dpad button:active { background: #444; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
#btn-giveup { margin-left: auto; background: #3a1a1a; border: 1px solid #f44; color: #f44; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 13px; }
#btn-giveup:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">LABYRINTH</span>
<button id="btn-giveup" disabled>Give Up</button>
</div>
<div id="ui">
<span>LEVEL: <b id="levelEl">1</b></span>
<span>MOVES: <b id="movesEl">150</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press SPACE or tap to start</div>
<div id="dpad">
<div id="dpad-row"><button id="btn-up">&#8593;</button></div>
<div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="labyrinth">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const BASE_SIZE = 10;
let CELL = 36;
let level = 1, moves = 0, maxMoves = 150, score = 0, total = 0, gameState = 'idle';
let cols, rows, grid, px, py;
let best = parseInt(localStorage.getItem('labyrinth_best') || '-1');
function seededRand(seed) {
let s = seed;
return () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
}
function generateMaze(c, r, seed) {
const rng = seededRand(seed);
const cells = Array.from({length: r}, () => Array.from({length: c}, () => ({ n: true, s: true, e: true, w: true, visited: false })));
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function carve(x, y) {
cells[y][x].visited = true;
const dirs = shuffle([['n', 0, -1], ['s', 0, 1], ['e', 1, 0], ['w', -1, 0]]);
for (const [d, dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && nx < c && ny >= 0 && ny < r && !cells[ny][nx].visited) {
const opp = { n: 's', s: 'n', e: 'w', w: 'e' };
cells[y][x][d] = false;
cells[ny][nx][opp[d]] = false;
carve(nx, ny);
}
}
}
carve(0, 0);
return cells;
}
function getAvailableSize() {
const usedH = (document.getElementById('topbar').offsetHeight || 40)
+ (document.getElementById('ui').offsetHeight || 30)
+ (document.getElementById('msg').offsetHeight || 22)
+ (document.getElementById('dpad').offsetHeight || 100)
+ (document.getElementById('controls').offsetHeight || 16)
+ 24;
return { w: window.innerWidth - 8, h: Math.max(200, window.innerHeight - usedH) };
}
function startLevel() {
cols = BASE_SIZE + (level - 1) * 2;
rows = Math.floor(cols * 0.7);
maxMoves = cols * rows * 2;
moves = 0;
grid = generateMaze(cols, rows, level * 7919 + 42);
px = Math.floor(cols / 2); py = Math.floor(rows / 2);
const avail = getAvailableSize();
CELL = Math.max(16, Math.floor(Math.min(avail.w / cols, avail.h / rows)));
canvas.width = cols * CELL;
canvas.height = rows * CELL;
document.getElementById('levelEl').textContent = level;
document.getElementById('movesEl').textContent = maxMoves;
document.getElementById('msg').textContent = `Level ${level} — Reach the exit!`;
gameState = 'play';
document.getElementById('btn-giveup').disabled = false;
}
function tryMove(dx, dy) {
if (gameState !== 'play') return;
const cell = grid[py][px];
const dirs = { '0,-1': 'n', '0,1': 's', '1,0': 'e', '-1,0': 'w' };
const key = `${dx},${dy}`;
const wall = dirs[key];
if (!wall || cell[wall]) return;
px += dx; py += dy;
moves++;
document.getElementById('movesEl').textContent = maxMoves - moves;
if (px === cols - 1 && py === rows - 1) {
const remaining = maxMoves - moves;
const levelScore = remaining + level * 50;
total += levelScore;
document.getElementById('scoreEl').textContent = total;
document.getElementById('msg').textContent = `EXIT! +${levelScore} pts. Next level...`;
gameState = 'transit';
setTimeout(() => { level++; startLevel(); }, 1000);
} else if (moves >= maxMoves) {
endGame('moves');
}
}
function endGame(reason) {
gameState = 'over';
if (best < 0 || total > best) {
best = total;
localStorage.setItem('labyrinth_best', best);
document.getElementById('bestEl').textContent = best;
}
const head = reason === 'giveup' ? `Gave up at level ${level}.` : 'Out of moves!';
document.getElementById('msg').textContent = `${head} Score: ${total}. SPACE = new game`;
document.getElementById('scoreInput').value = total;
document.getElementById('scoreSubmit').style.display = 'block';
document.getElementById('btn-giveup').disabled = true;
}
function newGame() {
level = 1; total = 0; score = 0;
document.getElementById('scoreEl').textContent = '0';
document.getElementById('scoreSubmit').style.display = 'none';
startLevel();
}
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); newGame(); return; }
const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') newGame(); else tryMove(...map[e.code]); }
});
canvas.addEventListener('click', () => { if (gameState === 'idle') newGame(); });
window.addEventListener('resize', () => { if (gameState !== 'idle') startLevel(); });
const dpadStart = () => { if (gameState === 'idle') newGame(); };
document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
document.getElementById('btn-giveup').addEventListener('click', () => { if (gameState === 'play') endGame('giveup'); });
if (best >= 0) document.getElementById('bestEl').textContent = best;
const VIEW = 6;
function draw() {
const W = canvas.width, H = canvas.height;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
if (gameState === 'idle') { requestAnimationFrame(draw); return; }
const viewCols = Math.min(cols, Math.floor(W / CELL));
const viewRows = Math.min(rows, Math.floor(H / CELL));
const offX = Math.max(0, Math.min(px - Math.floor(viewCols / 2), cols - viewCols));
const offY = Math.max(0, Math.min(py - Math.floor(viewRows / 2), rows - viewRows));
for (let r = offY; r < Math.min(offY + viewRows + 1, rows); r++) {
for (let c = offX; c < Math.min(offX + viewCols + 1, cols); c++) {
const x = (c - offX) * CELL, y = (r - offY) * CELL;
const cell = grid[r][c];
ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 2;
if (cell.n) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + CELL, y); ctx.stroke(); }
if (cell.s) { ctx.beginPath(); ctx.moveTo(x, y + CELL); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
if (cell.w) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + CELL); ctx.stroke(); }
if (cell.e) { ctx.beginPath(); ctx.moveTo(x + CELL, y); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
}
}
const ex = (cols - 1 - offX) * CELL + CELL / 2, ey = (rows - 1 - offY) * CELL + CELL / 2;
if (cols - 1 >= offX && cols - 1 < offX + viewCols && rows - 1 >= offY && rows - 1 < offY + viewRows) {
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.arc(ex, ey, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('EXIT', ex, ey);
}
const ppx = (px - offX) * CELL + CELL / 2, ppy = (py - offY) * CELL + CELL / 2;
ctx.fillStyle = '#00ff88';
ctx.beginPath(); ctx.arc(ppx, ppy, 7, 0, Math.PI * 2); ctx.fill();
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#111"/>
<g stroke="#FFA500" stroke-width="2" fill="none">
<rect x="5" y="5" width="110" height="70"/>
<line x1="25" y1="5" x2="25" y2="45"/>
<line x1="45" y1="35" x2="45" y2="75"/>
<line x1="65" y1="5" x2="65" y2="45"/>
<line x1="85" y1="35" x2="85" y2="75"/>
<line x1="5" y1="25" x2="45" y2="25"/>
<line x1="65" y1="25" x2="105" y2="25"/>
<line x1="25" y1="55" x2="65" y2="55"/>
<line x1="85" y1="55" x2="105" y2="55"/>
</g>
<circle cx="15" cy="15" r="4" fill="#00ff88"/>
<circle cx="105" cy="65" r="4" fill="#ff4444"/>
</svg>

After

Width:  |  Height:  |  Size: 672 B

Some files were not shown because too many files have changed in this diff Show more